Criteria artifacts: how clinical logic travels¶
Clinical criteria in this specification are written in CQL and consumed as artifacts. Translation happens once, when a criterion is authored. Everything downstream reads JSON.
This page explains that boundary, because it is the thing most likely to surprise an implementer: you do not need a CQL compiler to consume clinical logic from this specification. In most cases you should not have one.
Four activities, not one¶
"Working with CQL" is four separate jobs, and conflating them is the usual mistake:
| activity | when it happens | what it needs | |
|---|---|---|---|
| 1 | Translate CQL to ELM | authoring time | a CQL translator |
| 2 | Analyse ELM for data requirements | authoring time | an ELM walker |
| 3 | Evaluate logic against patient data | serving time | an ELM evaluator |
| 4 | Read the resulting artifacts | any time | a JSON parser |
Activities 1 and 2 run when someone writes or saves a criterion. They are rare, they are not latency-sensitive, and their output is deterministic. So they are done once, ahead of time, and their results are published.
Activity 4 is what most consumers actually do.
The artifact set¶
A compiled criterion is three files and an entry in a manifest.
The ELM (<name>.elm.json) is the criterion's abstract syntax tree: the logic, fully resolved,
with every code and value set bound. It is what an evaluator or a query compiler consumes.
The module-definition Library (<name>.requirements.json) is a FHIR Library with
type = module-definition, stating what data the logic reads -- resource types, the profiles they
must satisfy, code filters, and element paths. It is derived from the compiled logic, never
hand-written, so it cannot drift from the logic it describes. For the same reason a criteria
library should never carry a hand-authored dataRequirement of its own: the derived manifest is
the only copy that can be trusted.
The module-definition Library is profiled, as the
Criteria Requirements Library.
It earned a profile because it has a consumer that refuses to run without it: a criteria pack is
verified when a consuming host starts, and the host will not start on a manifest whose
dataRequirement is empty, or on one carrying a codeFilter with no path. A criterion that
reads no data fires for everyone or for no one, and a code filter with no path filters on nothing
a consumer can determine. Those two refusals lived in one service's startup path and were stated
nowhere a reader of this specification could find them; they are cardinality on the profile now.
Neither is new work for a producer -- the generator already cannot emit a manifest that breaks
them -- and the profile deliberately requires no id and no meta.profile, because a manifest
inside a pack carries neither.
The model-definition Library is not profiled, and is not expected to be. It is a publication
wrapper: the compiler that uses the ModelInfo it carries loads that ModelInfo as an XML file, not
as FHIR, so nothing reads the wrapper as a FHIR resource and a profile would constrain nothing
anyone checks. The two shapes could not share one profile in any case -- they disagree on status
and type, and the model definition has no dataRequirement at all.
The manifest (manifest.json) indexes the pack and carries, per criterion: the SHA-256 of the
CQL source it was built from, the library identifier and version read back out of the compiled
ELM, and which model the retrieves were bound against.
Why the manifest carries hashes¶
A generated artifact that cannot say what produced it is one nobody can trust after the inputs move. The hashes make three otherwise-silent failures loud:
- a
.cqledited without regenerating -- the source hash no longer matches; - logic compiled against base FHIR where a constrained model was intended -- the manifest names the model, so this is visible without reading any ELM;
- two criteria in one pack claiming the same library identifier and version.
A consumer should verify the hash before trusting a pack. Serving stale clinical logic is worse than failing to serve it, because it succeeds.
Consuming a pack¶
Everything a consumer needs is ordinary JSON, in any language:
Read dataRequirement[].type from the module-definition Library. Union across the estate to
bound an initial load.
Read the criteria-relative-date extension. true means membership can change through the
passage of time alone -- "no visit in the last six months" -- so it needs periodic
re-evaluation. false means membership changes only when data changes, so a change feed is
sufficient.
The value is always present. An absent extension means the analysis did not produce that Library, not that the value is false; those have opposite consequences.
Read the repeating criteria-elements-read extension, one element path per occurrence. It is
a lower bound -- derived from retrieves and property accesses, without inference across
included libraries -- so an absent path is not proof the logic never reads it.
Read the ELM. It is a JSON tree; Retrieve nodes carry the data access. An evaluator or an
ELM-to-SQL compiler works from this directly.
Where translation lives¶
The mature CQL translator is a JVM and JavaScript implementation. Where a tool needs to compile CQL at runtime -- a report builder that turns a user's template into criteria when they hit save -- it embeds that translator through an opt-in package rather than reimplementing the language.
Two properties of that arrangement are deliberate, and both are about correctness rather than convenience.
One translator, not one per language. Every additional engine that can decide what a criterion means is a new authority on clinical semantics, and such engines diverge in the places that show least: null propagation in three-valued logic, interval boundary inclusivity, timezone handling at date boundaries, and unit conversion. A divergence of that kind inside a signed clinical rule is a patient-safety problem, not a bug. Embedding one implementation keeps the count at one; a reimplementation would not.
One door, not one per caller. Runtime compilation happens at a single chokepoint. A user-authored criterion needs profile validation, terminology binding, data-requirements analysis and a governance gate before anything executes -- and those are exactly what a chokepoint exists to enforce. A translator reachable from every call site is a translator whose gate can be skipped.
And nobody pays for it who does not use it. The CQL surface is a separate package in every
language -- @awaremd/typed-fhir/cql, Well.Services.TypedFhir.Cql, typed-fhir[cql] -- so a
consumer who wants a facade, a patient cache or a FHIR query acquires no language runtime and
starts no engine. See
Evaluating one patient for which of the three are published
today.
Consumers of compiled criteria need none of this. If you are reading a pack, you need a JSON parser.
What this does not cover¶
Stated plainly, because these are real:
- Ad-hoc evaluation of logic that has never been authored and compiled.
- Source-position diagnostics beyond what the ELM's annotations carry.
If you need either, you need a translator -- see above -- and you still do not need one in every process.
Partial packs are refused¶
A pack generator must never write a partial pack. If one criterion fails to compile, nothing is written.
The reason is that a consumer cannot tell "this criterion is absent because nobody wrote it" from "this criterion is absent because it failed to compile and we published anyway". The second silently drops clinical logic that somebody believes is running.