Design: Per-Criterion Eligibility Status¶
Warning
This is a design proposal, not yet implemented. It extends the Trino SQL-based query module and assumes that module as a prerequisite.
Motivation¶
Today, query-sql-on-fhir's PollForStudies evaluates exactly one SQL
query per study (ResearchStudy.enrollment -> Group with
Group.code = trino-sql -> the first Group.characteristic -> a Library
holding base64-encoded SQL). The query returns a flat list of patient_ids
that satisfy the entire criteria set, and those patients become
ResearchSubject entries on the study's screening List. There is no way to
tell, from the resulting FHIR data, which individual criteria a patient
does or doesn't satisfy - only the collapsed pass/fail outcome of the whole
query.
The goal of this design is to let list show, per patient, a
per-criterion breakdown with four states:
- met - the criterion is satisfied
- not met - the criterion is definitively not satisfied
- unknown - the data needed to evaluate the criterion doesn't exist
- indeterminate - the criterion's logic was evaluated, but the result is inconclusive (as opposed to unknown, where evaluation couldn't even be attempted). Both behave identically for merge purposes (see below) - the distinction only matters for display.
Alternatives considered and rejected¶
MeasureReport- built for quality-measure population counting, not per-patient per-criterion results.population.countis an integer (0/1 would be a hack for booleans),population.codeis bound to a fixed quality-measure vocabulary with no slot for an arbitrary named criterion, and there's no native "unknown"/data-absent equivalent onpopulation. Its nestedgroup[].population[]shape is also awkward to flatten for SQL-on-FHIR ViewDefinitions compared to a flatObservationper fact. It could still be layered on top later as a per-patient "evaluation run" envelope referencing theObservations below viaevaluatedResource, but it doesn't replace them.Evidence/EvidenceVariable- built for evidence synthesis (systematic reviews, GRADE certainty ratings for published findings), not per-patient evaluation.Evidencehas nosubject: Reference(Patient)at all.EvidenceVariablecan define a population by characteristics (similar toGroup.characteristic) but has no element for recording a result against an individual patient, soObservationwould still be needed downstream regardless. Its post-R5 characteristic model is also heavier and less broadly tooled (HAPI/Pathling) thanGroup/Library, which this module already depends on.- A dedicated
CodeSystemper criterion - implies upfront governance: every criterion, including one-off study-specific ones, would need a registered code, and "is this criterion shared with another study" becomes a coordination question. Rejected in favor of using theLibraryresource itself as the criterion's identity (see below) - sharing then falls out naturally from resource references rather than needing a controlled vocabulary.
Criterion definition¶
Extend Group.characteristic from a single entry (today) to one entry per
criterion. Group.code stays trino-sql so the existing discovery query in
PollForStudies still finds it unchanged.
{
"resourceType": "Group",
"id": "trial-042-eligibility",
"type": "person",
"actual": true,
"code": {
"coding": [{ "system": "https://recruit.miracum.org/fhir/CodeSystem/eligibility-criteria-types", "code": "trino-sql" }]
},
"characteristic": [
{
"code": { "text": "Age >= 18 years" },
"valueReference": { "reference": "Library/trial-042-age-min-18" },
"exclude": false
},
{
"code": { "text": "HbA1c > 7.0%" },
"valueReference": { "reference": "Library/trial-042-hba1c-elevated" },
"exclude": false
},
{
"code": { "text": "No prior chemotherapy" },
"valueReference": { "reference": "Library/trial-042-no-prior-chemo" },
"exclude": true
}
]
}
Notes:
characteristic.codeis text-only (CodeableConcept.text, nocoding) - valid FHIR, no controlled vocabulary required.- The
Libraryresource is the criterion's identity. A criterion that turns out to be reusable across studies is simply referenced by more than oneGroup.characteristic, from differentGroups, pointing at the sameLibrary:
Group/trial-042 -> characteristic -> Library/common-age-min-18
Group/trial-057 -> characteristic -> Library/common-age-min-18 (same resource)
Group/trial-088 -> characteristic -> Library/trial-088-custom-ecog (one-off)
No dedup process is needed up front - start every criterion study-scoped
and only point a second study's characteristic at an existing Library
once it's clear the logic is genuinely identical. A future "catalog" of
proven-reusable criteria is just Library resources with real canonical
urls that studies opt into referencing.
- characteristic.exclude follows FHIR's own semantics: the Library's SQL
should compute the raw, un-negated predicate (e.g. "does the patient
have a prior chemotherapy procedure"), and exclude=true says "reject
patients for whom this is true." Negation is applied once, generically, in
the merge step below - not duplicated by every SQL author.
Library SQL contract¶
Every criterion Library must contain SQL returning patient_id and is_met,
covering the full patient population (so that "no matching data" reliably
produces NULL, not a missing row):
patient_idis_met(nullable boolean) - the raw, un-negated predicate
It may additionally return two optional columns - a criterion whose SQL omits
either is treated exactly as if it had selected a NULL literal for it, so
there's no need to pad every criterion's SQL with columns it doesn't use:
is_indeterminate(boolean) - only meaningful whenis_met IS NULL; distinguishes "evaluated but inconclusive" from plain "unknown" (missing data). Criteria that never produce an indeterminate result can omit it, or selectCAST(NULL AS BOOLEAN) AS is_indeterminateexplicitly - a fixedFALSEalso works, butNULLreads more honestly as "this axis doesn't apply here."result_note(nullable string) - a free-text explanation, carried through unchanged to the resultingObservation.value.text(see below). Most useful alongsideis_indeterminate=true(explaining why), but not limited to it. Criteria that never need it can omit it, or selectCAST(NULL AS VARCHAR) AS result_noteexplicitly.
-- crit-age-min-18
SELECT p.id AS patient_id,
CASE WHEN p.birth_date IS NULL THEN NULL
ELSE date_diff('year', date(p.birth_date), current_date) >= 18
END AS is_met,
CAST(NULL AS BOOLEAN) AS is_indeterminate,
CAST(NULL AS VARCHAR) AS result_note
FROM fhir.default.patient p
-- crit-hba1c-elevated (indeterminate example: more than one conflicting result)
SELECT p.id AS patient_id,
CASE WHEN result_count = 0 THEN NULL
WHEN result_count > 1 AND max_value > 7.0 <> (min_value > 7.0) THEN NULL
ELSE max_value > 7.0
END AS is_met,
(result_count > 1 AND max_value > 7.0 <> (min_value > 7.0)) AS is_indeterminate,
CASE WHEN result_count > 1 AND max_value > 7.0 <> (min_value > 7.0)
THEN 'Conflicting HbA1c results: ' || CAST(min_value AS VARCHAR) ||
'-' || CAST(max_value AS VARCHAR)
END AS result_note
FROM (
SELECT p.id,
count(o.value_quantity_value) AS result_count,
max(o.value_quantity_value) AS max_value,
min(o.value_quantity_value) AS min_value
FROM fhir.default.patient p
LEFT JOIN fhir.default.observation o
ON o.subject_reference = p.id AND o.code_coding_code = '4548-4' -- LOINC HbA1c
GROUP BY p.id
) p
-- crit-no-prior-chemo (raw predicate; NOT pre-negated - exclude=true handles that)
SELECT p.id AS patient_id,
EXISTS (SELECT 1 FROM fhir.default.procedure pr
WHERE pr.subject_reference = p.id AND pr.code_coding_code = '367336001') AS is_met,
CAST(NULL AS BOOLEAN) AS is_indeterminate,
CAST(NULL AS VARCHAR) AS result_note
FROM fhir.default.patient p
Absence-of-evidence caveat
For exclusion criteria based on the absence of a record (like "no prior chemotherapy"), "no matching Procedure" is ambiguous: it could mean the patient genuinely never had chemotherapy, or that this site's data simply doesn't cover their full history. SQL can't tell these apart without an explicit completeness signal. This has to be decided per-criterion when it's authored - don't let every exclusion criterion silently default to "confirmed met" just because no rows were found.
Anchor criterion scope and candidate population size
The merged query is anchored on the first criterion's result set
(crit_0). Every patient that appears in crit_0 and is not definitively
excluded by any other criterion will be sent to the FHIR server - including
patients for whom a later criterion returns no row (i.e. is_met = NULL
after the LEFT JOIN, treated as unknown).
This means: if crit_0's SQL uses a WHERE clause that pre-filters to a
broad cohort (e.g. WHERE age >= 18), and a narrowing criterion like
"has diagnosis C92" only returns a few thousand rows, then every patient
in crit_0 who doesn't appear in the narrowing criterion has
is_met = NULL for it and passes the IS DISTINCT FROM FALSE filter as
unknown. With a few million adult patients and a rare diagnosis, that is
hundreds of thousands of unknowns submitted as candidates.
To avoid this:
- Prefer writing the most selective criterion as
crit_0(the firstGroup.characteristic). If the rare-diagnosis criterion returns 5 000 patients, use it first - then the anchor is 5 000, not 2 000 000. - Or write each criterion's SQL to cover the full patient population
and return
NULLfor non-matching patients (see thepatient p LEFT JOIN ...pattern in the examples above), so that a patient absent from a criterion's narrow data set yieldsNULLexplicitly rather than propagating as unknown viaLEFT JOIN. - Use dry-run mode (see below) to check the expected candidate count before a query first runs against production data.
Merge logic¶
At the scale this needs to run at (~2 million patients per site), the merge
must not be done by pulling each criterion's full-population result set over
JDBC into the query module's JVM and folding them with in-memory maps - that
means N x 2M rows crossing the network and landing on the heap, when only a
much smaller candidate/unknown subset is ever actually needed downstream.
Instead, the merge is expressed as one generated SQL query per study,
built by wrapping each Library's already-standalone SQL as a CTE:
WITH crit_0 AS ( <library-0-sql> ), -- age-min-18, exclude=false
crit_1 AS ( <library-1-sql> ), -- hba1c-elevated, exclude=false
crit_2 AS ( <library-2-sql> ) -- raw "has_chemo" predicate, exclude=true
SELECT
p.id AS patient_id,
c0.is_met AS crit_age_min_18,
c1.is_met AS crit_hba1c_elevated,
NOT c2.is_met AS crit_no_prior_chemo, -- exclude negation applied here, once, generically
(c0.is_met AND c1.is_met AND NOT c2.is_met) AS overall_met,
-- is_indeterminate/result_note are carried through per criterion the same way, just never
-- referenced in the AND/WHERE below - they're pure passthrough for display purposes.
c0.is_indeterminate AS crit_age_min_18_indeterminate,
c0.result_note AS crit_age_min_18_note
FROM fhir.default.patient p
LEFT JOIN crit_0 c0 ON c0.patient_id = p.id
LEFT JOIN crit_1 c1 ON c1.patient_id = p.id
LEFT JOIN crit_2 c2 ON c2.patient_id = p.id
WHERE (c0.is_met AND c1.is_met AND NOT c2.is_met) IS DISTINCT FROM FALSE
Two things this relies on that are native SQL behavior, not custom logic:
ANDover nullable booleans already implements Kleene three-valued logic (TRUE AND NULL = NULL,FALSE AND NULL = FALSE,TRUE AND TRUE = TRUE) - one definitefalsedominates over any number ofunknowns, and only turns intounknownoverall when nothing is definitively false but something is missing.IS DISTINCT FROM FALSEfilters to "candidate or unknown," dropping definite non-matches, and is pushed down so Trino/Delta never materializes or ships back the (likely large) portion of the population that fails outright.
This keeps each Library's SQL independently authored, standalone, and
reusable (it still only needs to satisfy the (patient_id, is_met) contract,
plus whichever of the optional is_indeterminate/result_note columns it
actually uses, to run alone) - the wrapper is mechanically generated from
Group.characteristic, not hand-written per study, so this doesn't
reintroduce fragile per-study SQL composition.
The query module's Java code shrinks to: loop over Group.characteristic,
template each Library's SQL into a crit_N CTE plus one LEFT JOIN and
one (possibly negated) term in the final AND, execute one query, and
iterate the result - which now only contains patients who are true
candidates or genuinely undecidable, not the full 2 million.
List / ResearchSubject membership¶
overall_met = trueoroverall_met = null(unknown) -> patient gets aResearchSubject+List.entry, same as today'screateScreeningListBundle. Unknown patients are not dropped silently- "we don't have enough data to be sure" is exactly the kind of case a screening worklist should surface for human review, not hide.
overall_met = false-> excluded from theListentirely, same as today's single-query behavior. Materializing an entry for every definite non-match doesn't scale and isn't what a screening worklist is for.
Result representation¶
For every patient that ends up on the List (candidate or unknown), emit one
Observation per criterion - not for patients who were filtered out, since
nothing in the UI needs to explain a checklist for a patient nobody reviews.
{
"resourceType": "Observation",
"status": "final",
"identifier": [{ "system": "https://fhir.miracum.org/uc1/NamingSystem/eligibilityObservationId", "value": "<sha256 of patient+study+library>" }],
"extension": [{ "url": "https://fhir.miracum.org/uc1/StructureDefinition/derivedFromLibrary", "valueReference": { "reference": "Library/trial-042-age-min-18" } }],
"category": [{ "coding": [{ "system": "https://fhir.miracum.org/uc1/CodeSystem/observation-category", "code": "eligibility-assessment" }] }],
"code": { "text": "Age >= 18 years" },
"subject": { "reference": "Patient/123" },
"focus": [{ "reference": "ResearchStudy/trial-042" }],
"valueCodeableConcept": { "coding": [{ "system": "http://snomed.info/sct", "code": "373066001", "display": "Yes" }] },
"effectiveDateTime": "2026-08-10T09:00:00Z"
}
{
"resourceType": "Observation",
"status": "final",
"identifier": [{ "system": "https://fhir.miracum.org/uc1/NamingSystem/eligibilityObservationId", "value": "<sha256 of patient+study+library>" }],
"extension": [{ "url": "https://fhir.miracum.org/uc1/StructureDefinition/derivedFromLibrary", "valueReference": { "reference": "Library/trial-042-no-prior-chemo" } }],
"category": [{ "coding": [{ "system": "https://fhir.miracum.org/uc1/CodeSystem/observation-category", "code": "eligibility-assessment" }] }],
"code": { "text": "No prior chemotherapy" },
"subject": { "reference": "Patient/123" },
"focus": [{ "reference": "ResearchStudy/trial-042" }],
"valueCodeableConcept": { "coding": [{ "system": "http://snomed.info/sct", "code": "373067005", "display": "No" }] },
"effectiveDateTime": "2026-08-10T09:00:00Z"
}
{
"resourceType": "Observation",
"status": "final",
"identifier": [{ "system": "https://fhir.miracum.org/uc1/NamingSystem/eligibilityObservationId", "value": "<sha256 of patient+study+library>" }],
"extension": [{ "url": "https://fhir.miracum.org/uc1/StructureDefinition/derivedFromLibrary", "valueReference": { "reference": "Library/trial-042-hba1c-elevated" } }],
"category": [{ "coding": [{ "system": "https://fhir.miracum.org/uc1/CodeSystem/observation-category", "code": "eligibility-assessment" }] }],
"code": { "text": "HbA1c > 7.0%" },
"subject": { "reference": "Patient/123" },
"focus": [{ "reference": "ResearchStudy/trial-042" }],
"valueCodeableConcept": { "coding": [{ "system": "http://snomed.info/sct", "code": "261665006", "display": "Unknown" }] },
"effectiveDateTime": "2026-08-10T09:00:00Z"
}
{
"resourceType": "Observation",
"status": "final",
"identifier": [{ "system": "https://fhir.miracum.org/uc1/NamingSystem/eligibilityObservationId", "value": "<sha256 of patient+study+library>" }],
"extension": [{ "url": "https://fhir.miracum.org/uc1/StructureDefinition/derivedFromLibrary", "valueReference": { "reference": "Library/trial-042-hba1c-elevated" } }],
"category": [{ "coding": [{ "system": "https://fhir.miracum.org/uc1/CodeSystem/observation-category", "code": "eligibility-assessment" }] }],
"code": { "text": "HbA1c > 7.0%" },
"subject": { "reference": "Patient/456" },
"focus": [{ "reference": "ResearchStudy/trial-042" }],
"valueCodeableConcept": {
"coding": [{ "system": "http://snomed.info/sct", "code": "82334004", "display": "Indeterminate" }],
"text": "Conflicting HbA1c results: 6.2-8.9"
},
"effectiveDateTime": "2026-08-10T09:00:00Z"
}
Notes:
valueCodeableConcept(notvalueBoolean/dataAbsentReason) carries a SNOMED CT "Yes / No / Unknown / Indeterminate (qualifier value)" coding -373066001/373067005/261665006/82334004respectively - and is always the eligibility-facing value (i.e. already negated perexclude, matching theoverall_metcomputation): a reviewer always reads "Yes = good" regardless of whether the underlying criterion was framed as inclusion or exclusion. This is the samecrit_Ncolumn value the merge query already produced, just carried through unchanged.is_met=nullsplits into two of these codes depending onis_indeterminate:Unknown(data was simply missing) vs.Indeterminate(the criterion's SQL ran but the result was inconclusive)- both count identically as "unresolved" in the merge, the distinction is
display-only.
dataAbsentReasonis unused here: every state, including the two null-ish ones, is a real coded value, so the Observation's value is never actually absent. valueCodeableConcept.text, when the criterion's SQL provided aresult_note, carries a free-text explanation (e.g. why a result is indeterminate) - supplementing, not replacing, the coding's owndisplay.category = eligibility-assessmentis a custom, non-standard code - deliberately, to keep these synthetic/derived Observations distinguishable from genuine clinical Observations elsewhere in the app's data.focusstaysResearchStudy-only, matching how it's already used on other resources in this app (context/scope, not the assessed entity). The criterionLibraryisn't a valid target forObservation.derivedFrom(HAPI rejects it: onlyDocumentReference | ImagingStudy | ImagingSelection | QuestionnaireResponse | Observationare allowed there), so it's referenced via thederivedFromLibraryextension instead - a plainvalueReference, notvalueCanonical, sinceLibraryis already referenced by relative reference elsewhere in this design (Group.characteristic.valueReference) and doesn't carry a canonical.url.identifier(a SHA-256 hash ofpatient+study+library) is the conditional-update key for this Observation, notfocus/derived-fromsearch parameters - thederivedFromLibraryextension's value isn't searchable without a customSearchParameterregistered on the FHIR server, butidentifieris a standard, always-searchable parameter on every resource, so no server-side setup is needed.code.textis for display only;identifier(or a resolvedderivedFromLibrary) is the computable join key for "all Observations for criterion X" (e.g. in a SQL-on-FHIR ViewDefinition or alistquery).
Bundle size at scale¶
Even with the IS DISTINCT FROM FALSE filter pushed down, the surviving
candidate/unknown set can still be large for an unselective study. Batch the
resulting FHIR transaction bundle (ResearchSubject + List.entry +
N x Observation per patient) into chunks of a few hundred/thousand entries
rather than one transaction for the whole study - most FHIR servers have
practical limits on single-transaction size regardless of how efficiently
the candidate set was computed.
Dry run mode¶
Before a new or revised query runs against production data, it is often useful
to know how many patients would be submitted (and why) without actually
writing anything to the FHIR server. Set query-sql-on-fhir.dry-run=true
(e.g. via an environment variable QUERY_SQL_ON_FHIR_DRY_RUN=true) to enable
this mode.
In dry-run mode the module:
- Runs the full merged eligibility query against Trino exactly as it would in
a normal poll cycle (so streaming, JDBC fetch size, and the
IS DISTINCT FROM FALSEfilter all apply identically). - Logs a per-study and per-criterion breakdown at INFO level - total patients that would be submitted, split by "all criteria met" vs "at least one criterion unknown", and per-criterion met/unknown counts.
- Does not write any
ResearchSubject,Observation, orListresource to the FHIR server.
Example output:
Dry run for study ResearchStudy/trial-042 - evaluating queries but not writing to FHIR
Dry run result for study ResearchStudy/trial-042: 847 patients would be submitted (312 all criteria met, 535 with at least one unknown criterion)
Criterion 'Age >= 18 years' (inclusion): 847 met, 0 unknown
Criterion 'Not deceased' (inclusion): 847 met, 0 unknown
Criterion 'AML (C92.x)' (inclusion): 312 met, 535 unknown
A large "unknown" count for a narrowing criterion (like a rare diagnosis) is the main indicator that the anchor criterion order should be revised - see the anchor criterion scope warning above.
Implementation touchpoints¶
query-sql-on-fhir'sPollForStudies.java:- Loop
group.getCharacteristic()instead ofcharacteristicFirstRep(). - Replace the single
jdbcTemplate.queryForList(contentString)call with the generated wrapper query described above. - Extend
createScreeningListBundleto also emit oneObservationentry per(patient, criterion)for every patient added to the bundle, and to chunk large bundles into multiple transactions. list:- A read method (e.g. on
ResearchSubjectService, or a newEligibilityService) queryingObservation?subject=Patient/{id}&focus=ResearchStudy/{studyId}&category=eligibility-assessment, grouped/sorted by resolving each entry'sderivedFromLibraryextension. - A UI surface for the per-criterion checklist - candidate slot: a new tab
on
PatientDialog.razor, or a header strip above the existing Clinical tab. Not yet decided.
Open questions / follow-ups¶
- Exact chunk size for batched transaction bundles.
- Whether
listneeds a distinct visual treatment for "unknown" overall status on the patient list (today'sSystemDeterminedIneligibleflag only covers "system says no," not "system can't tell"). - Whether excluded (
overall_met = false) patients need an audit trail at all, or whether "not on the list" is a sufficient record. - Conventions for a shared
Library"catalog" once enough criteria turn out to be reused across studies (canonicalurls, versioning).