Processes FHIR R4 electronic Case Reporting (eCR) resources used for chronic-disease surveillance and persists them to an OAuth2-secured target FHIR server using update-in-place semantics. Covers three CMS quality measures:
- CMS122 — Diabetes HbA1c Poor Control (≥9%) — folder
poor-diabetic-control - CMS165 — Controlling High Blood Pressure — folder
controllable-bp - CMS2 — Depression Screening — folder
depression-screening(in scope; no fixtures yet)
Originally developed for Washington State Department of Health's TRAX "Translational Repository and Analytics Exchange" project.
- Recursively discovers
*.jsonunder the input tree, organized by{measure}/{population}/{scenario}/, and classifies each file as a collection bundle, MeasureReport, eCR message bundle, or unknown (skipped). Seedocs/input-data.mdfor how the input files are organized and how the files within a scenario relate. - Stamps every persisted resource (and a message Bundle's own
meta) with searchable provenance metadata, then persists it as independent, first-class resources — there is no atomictransactionbinding a scenario together, so failures isolate per resource and resource types persist independently:- collection Bundles are split into one independent
PUT [Type]/<id>per contained resource (ids retained, update-in-place). A non-atomicbatchBundle is an allowed round-trip optimization; atransactionBundle is never used. - standalone MeasureReports and message Bundles are
PUTunder their retained ids. - for in-population scenarios the nested eICR
Compositionis promoted to a first-class resource (PUT /Composition/<id>) in addition to persisting the message Bundle whole — making it queryable for downstream SQL-on-FHIR analytics. Only the Composition is promoted (no lower-fidelity overwrite of the authoritative collection-Bundle resources).
- collection Bundles are split into one independent
- Makes every emitted resource storable on the Aidbox ingestion surface without
fabricating or dropping clinical content (see Aidbox storability):
non-mutating levers clear reference and terminology rejections, and a documented,
non-fabricating stratum-prune transform makes MeasureReports storable (removing each
malformed
mrp-2stratum that carries no stratification key) rather than deferring them. - Lets a problem resource type be landed in a separate idempotent run via
--only-types/--skip-types: persist one type first, then land another later — the second run neither duplicates nor rolls back the first. - Logs every outcome to the console and a timestamped audit file, reporting each FHIR type
as stored / remediated / deferred, and exits with a three-state code:
0when every in-scope resource stored (remediation is still0),1on an unexpected failure, and2when a type was deferred (isolated to avoid fabrication). A rejected resource never blocks its siblings.
The runtime uses the Python 3 standard library only — no pip install required.
cp config.example.json config.json
# Edit config.json: set server.base_url, token_endpoint, client_id, client_secret.
# config.json is git-ignored (never commit real credentials).| Key | Required | Notes |
|---|---|---|
software.name, software.identifier_system, software.identifier_value |
yes | Provenance identity. |
server.base_url, server.token_endpoint, server.client_id, server.client_secret |
yes (unless --dry-run) |
OAuth2 client-credentials. Secrets — never commit. |
ig_versions{} |
recommended | IG → version for the conformance gate, mirroring the test-data supplier's pinned set (hl7.fhir.us.core, hl7.fhir.us.qicore, hl7.fhir.us.cqfmeasures, hl7.fhir.us.davinci-deqm, hl7.fhir.us.ecr, hl7.fhir.us.ph-library). |
paths{input_dir,output_dir,log_dir} |
optional | Defaults input/output/log. |
The software version is not a config field — it is derived at runtime from
git describe --tags --always --dirty(falls back tounknownwhen git is unavailable).
python3 process.py [--config PATH] [--input-dir PATH] [--measure NAME]
[--only-types LIST] [--skip-types LIST]
[--output-dir PATH] [--no-output-mirror] [--dry-run]
[--log-dir PATH] [--verbose]
| Flag | Default | Meaning |
|---|---|---|
--config |
config.json |
Run config (template: config.example.json). |
--input-dir |
config.paths.input_dir (input) |
Root of the input tree. |
--measure |
all | Restrict to one measure folder. |
--only-types |
all | Comma-separated FHIR resourceTypes to persist exclusively this run (accepts the measure-report kind alias). Mutually exclusive with --skip-types. |
--skip-types |
none | Comma-separated FHIR resourceTypes to exclude this run (excluded resources counted skipped). |
--output-dir |
config.paths.output_dir (output) |
Submitted-JSON mirror location. |
--no-output-mirror |
off | Skip writing the local output mirror. |
--dry-run |
off | Discover/classify/stamp/plan but do not submit (no server needed). |
--log-dir |
config.paths.log_dir (log) |
Audit-log directory. |
--verbose |
off | Console DEBUG verbosity (file log is always detailed). |
# Dry run over the test fixtures (no server, no config required):
python3 process.py --input-dir test/input --dry-run --verbose
# Persist to the configured FHIR server:
python3 process.py --input-dir test/input --config config.json
# Independent per-type runs (operational convenience — MeasureReports are storable now
# via the stratum-prune transform, so this split is no longer required for them):
# 1. land everything except MeasureReports …
python3 process.py --input-dir test/input --skip-types MeasureReport
# 2. … then land just those (idempotent — neither duplicates nor rolls back run 1):
python3 process.py --input-dir test/input --only-types MeasureReportAidbox's FHIR Schema engine (kept enabled — BOX_FHIR_SCHEMA_VALIDATION=true) is a
second validation surface, distinct from the HL7 validator gate. Making every emitted
resource storable there is done without fabricating or dropping clinical content
(constitution Principle VIII, subordinate to Principle V). The four documented Aidbox
rejection causes are cleared in priority order — non-mutating levers first, a transform
only where no lever exists (see known-validation-issues.md):
- Cause 1 — reference target-profile conformance (Observation, MedicationRequest, and
the message Bundles carrying them). Cleared by a non-mutating lever: the per-request
aidbox-validation-skip: referenceheader the processor already sends whenconfig.server.validation_skipincludes"reference". No content is edited — the submitted bytes equal the emitted bytes. - Cause 3 — terminology display binding (the MeasureReports). Cleared box-side by
leaving
BOX_FHIR_TERMINOLOGY_SERVICE_BASE_URLunset. The processor makes no content change and never rewrites terminology displays. - Cause 2 — base-FHIR
mrp-2invariant (every MeasureReport, standalone and nested in message Bundles). The only cause with no non-mutating lever, so the only content transform: the processor removes each stratifierstratumthat has neithervaluenorcomponent— a stratum with no stratification key is malformed structure, not clinical content. The transform is non-fabricating (it only removes, never invents avalue/component), idempotent, and runs on the write path before theoutput/mirror so the mirrored bytes equal the PUT bytes. Every removal is logged at WARNING with the population counts the stratum carried, and re-validating the transformed output through the HL7 gate introduces no new signature vs.test/conformance-baseline.sigs. MeasureReports are thus made storable rather than deferred — replacing the earlier "land MeasureReports in a later run once Aidbox is relaxed" stance. - Cause 4 — base-FHIR
ext-1invariant (the CMS2 eICR Composition + its message Bundle). Also no non-mutating lever (ext-1is a base cardinality invariant), so a second structure-only transform: the processor removes each child of aneicr-trigger-code-flag-extensionthat carries neither avalue[x]nor nested extensions — in the sample data an emptytriggerCodeValueSetVersion. ThetriggerCode/triggerCodeValueSetsiblings (the clinical payload) are preserved and no version is fabricated (non-fabricating, idempotent, runs before the mirror). Every removal is logged at WARNING, and the transformed output introduces no new HL7-gate signature.
The FHIR Schema engine stays enabled throughout (BOX_FHIR_SCHEMA_VALIDATION=true) —
it is never disabled to force storage (that reverts Aidbox to the deprecated legacy engine
and breaks the FHIR REST API). Every applied lever/transform is logged at WARNING
([remediation:<key>]) and documented in known-validation-issues.md; an undocumented
remediation is a defect, enforced by a test. The run reports each FHIR type as
stored / remediated / deferred / transformed (where transformed counts resources a
content transform touched — visible even if the resource then failed on an independent
cause) and exits 0 (all stored) / 1 (unexpected failure) / 2 (a type deferred), so
operators and CI can branch on the outcome without Aidbox logs.
Downstream analytics (e.g. a DoH team) query flattened, one-row-per-resource SQL views
rather than raw FHIR. The materialized views now span thirteen resource types — Patient
plus Condition, Encounter, Observation, Practitioner, Organization, Location, Bundle,
Procedure, MedicationRequest, ServiceRequest, MeasureReport, and Composition (metadata-only).
They no longer ship an empty Measure view — Measure is a type this pipeline never
persists, so that view could never return a row and was retired. Those views are defined by
checked-in SQL-on-FHIR ViewDefinition resources under
viewdefinitions/ and pushed to
the target Aidbox server by a separate entry point, publish_views.py. It is a rare,
schema-change activity (run it to change a view), so it is its own script — not a
process.py subcommand — sharing the OAuth2 client, config, and logging via
fhir_common.py.
For each *.json ViewDefinition it discovers, the step PUTs it to
{base}/ViewDefinition/{id} (update-in-place — re-runs never duplicate) and then POSTs
{base}/ViewDefinition/{id}/$materialize, reporting publish and materialize outcomes
separately per view. Any publish or materialize failure is reflected in a non-zero
exit; one view's failure never blocks another.
python3 publish_views.py [--config config.json]
[--viewdefinitions-dir viewdefinitions]
[--materialize-type view|materialized-view|table]
[--dry-run] [--verbose] [--log-dir log]--materialize-type— the$materializetype. Defaults toserver.materialize_typeinconfig.json, elseview(an always-current SQL view that reflects live data on every read;materialized-view/tableare point-in-time snapshots Aidbox does not auto-refresh).--dry-run— discover and validate the ViewDefinition files and report the plannedPUT/$materializecalls without contacting the server (no credentials needed).
# Verify discovery + config without touching the server:
python3 publish_views.py --dry-run --verbose
# Publish + materialize every checked-in view (twelve resource types):
python3 publish_views.py --config config.json --verboseAfter a successful run the Patient view is queryable as sof.patient_view (one row per
first-class Patient, default DoH demographic columns; absent source fields are NULL,
never fabricated). It also carries a cms_measure column (the resource's CMS-measure tag —
see Provenance & search recipes), so an analyst filters the
flattened view to one measure with a single predicate:
SELECT * FROM sof.patient_view WHERE cms_measure = 'CMS165'; -- one measure
SELECT cms_measure, count(*) FROM sof.patient_view GROUP BY cms_measure; -- distributionA Patient persisted before this column existed (not yet re-processed) yields NULL here,
never an error.
The Observation view (sof.observation_view) surfaces each Observation's nested
component[] measurements as two generic, self-describing triads —
component1_display/component1_value/component1_unit and
component2_display/component2_value/component2_unit — sourced positionally from the
first and second components (e.g. a blood-pressure panel reports Systolic 128 mmHg /
Diastolic 88 mmHg, previously invisible because the readings live in component[], not the
top-level value). It also carries value_code_display, the human-readable label for a
coded result alongside the existing value_code. All are single-valued (one row per
Observation preserved); an Observation with no components or no coded value leaves them
NULL, never fabricated.
Reference columns (e.g. Observation subject) hold the referenced resource's key —
emitted via getReferenceKey(), equal to the target view's getResourceKey() id — so
views join directly without a Type/ prefix to strip. (A raw .reference string column
would be NULL under Aidbox's reference normalization; see
research.md R4.) Save a reusable query as
an AidboxQuery resource, then call it by name. In the Aidbox REST console at
https://<aidbox-url>/ui/console#/rest:
PUT /AidboxQuery/aidboxquery_ecr_test_patient_obs
accept:application/json
content-type:application/json
{
"resourceType": "AidboxQuery",
"query": "select pt.id, pt.name_family, pt.name_given, pt.birth_date, ob.cms_measure, ob.category, ob.code_display from sof.patient_view pt join sof.observation_view ob on ob.subject = pt.id;"
}Then invoke it by name — it's a plain GET, so you can just paste this URL into your
browser's address bar (no CLI or special UI needed). The join key ob.subject = pt.id is
what getReferenceKey() makes possible:
https://<aidbox-url>/$query/aidboxquery_ecr_test_patient_obs
This is an illustrative join, not a clinically-vetted public-health query — it shows the
mechanics (key-based view joins, cms_measure carried through), and the column list is the
analyst's to refine.
Conformance gate. A ViewDefinition is a SQL-on-FHIR logical-model resource, outside
the eCR/US-Core IG set, so its conformance gate is Aidbox acceptance (PUT accepted +
$materialize succeeds) — not validator_cli.jar. A unit test checks the checked-in
file is valid JSON with the required fields before any network call.
The mechanism is resource-type-agnostic: drop a new <type>.ViewDefinition.json into
viewdefinitions/ and re-run publish_views.py — no code or
invocation change. Twelve views are authored today (Patient plus the eleven listed
above), each with the same provenance scoping and cms_measure column as Patient. Two are
special cases: sof.measure_view materializes but returns zero rows until Measure
resources are loaded (this project's fixtures only reference APHL Measure canonicals), and
sof.bundle_view exposes container metadata only — its nested clinical content is promoted
to the other first-class views, not flattened here. Each view's column set is an informed,
reviewable default, refined as the analytics team specifies the columns they need.
Every persisted resource carries four searchable meta.tag[] entries plus
meta.source, under the canonical base
https://uwcirg.github.io/ecr-fhir-processor/CodeSystem:
| Goal | FHIR _tag query |
|---|---|
| Everything this software wrote | GET [base]/Patient?_tag=<BASE>/processed-by|ecr-fhir-processor |
| A specific processing run | GET [base]/Patient?_tag=<BASE>/processed-on|2026-06-09T14:03:22+00:00 |
| Everything from one source file | GET [base]/Patient?_tag=<BASE>/source-file|CMS165_bulk_dial_high_00042.json |
| One CMS quality measure (any resource type) | GET [base]/Condition?_tag=<BASE>/cms-measure|CMS165 |
| The un-attributed bucket | GET [base]/Condition?_tag=<BASE>/cms-measure|unknown |
All resources in a single run share one processed-on value, so one _tag query
isolates a run. Re-running identical input updates the same resources in place (no
duplicates); only the provenance tags and server-managed meta.lastUpdated/versionId
change.
Every persisted resource is tagged with its CMS quality measure under
<BASE>/cms-measure, derived purely from the input filename: a name beginning
CMS<n> (case-insensitive — CMS2, CMS122, CMS165, …) yields that uppercased code;
any other name yields the positive sentinel unknown. The parent directory is never
consulted for the value — if a file's CMS<n> prefix disagrees with the measure folder it
sits in, the processor logs a WARNING (naming both) and the filename wins; it is
never silently reconciled. The tag's display is the human slug from the authoritative
crosswalk (MEASURE_SLUG_BY_CMS in process.py): CMS2→depression-screening,
CMS122→poor-diabetic-control, CMS165→controllable-bp (unknown→unknown measure).
The tag is idempotent (exactly one per resource, replaced in place on re-stamp), so the
production fix for an un-attributed file is simply to rename it to the CMS<n>
convention and re-run — the same resources re-attribute from unknown to CMS<n> with
no duplicate resource or tag. unknown is a positive value in the project-owned system (no
published CodeSystem resource — consistent with the other provisional tag systems), so
un-attributed resources can be positively listed and counted; the HL7
DataAbsentReason/unknown vocabulary is the noted standards-track alternative.
# 1. Unit tests for pure logic (per-resource PUT planning, Composition promotion, type
# filter, stamping, collision, version, config):
python3 -m unittest discover -s tests
# 2. Conformance gate — validate the would-be submissions with the HL7 validator:
python3 process.py --input-dir test/input --dry-run --output-dir output
scripts/validate.sh # wraps validator_cli.jar with versioned -ig packages
# 3. Server acceptance gate — persist to a test FHIR server and confirm acceptance.See docs/input-data.md for the input-data reference,
specs/001-mvp-fhir-processor/quickstart.md
for the full validation guide, and
.specify/memory/constitution.md for the governing
principles. Documented upstream validator issues are tracked in
known-validation-issues.md.