Skip to content

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 .cql edited 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.