infra/images/bcda.Dockerfile follows api.Dockerfile (uv base, patched packages, curl for the healthcheck) with `uv sync --extra cli --extra bcda`; Streamlit's server settings live in infra/images/bcda/ streamlit.toml (headless, 0.0.0.0:8501, no usage stats, no file watcher); health is /_stcore/health. The compose service `bcda` sits on the gateway network, mounts ./data read-write (exports, aco.duckdb, replica publish) and ./dev read-only (vendored upstream, crosswalk seeds, vendor script), and takes BCDA_CLIENT_ID / BCDA_CLIENT_SECRET / BCDA_BASE_URL from .env with the public sandbox credentials as the default. Routed at bcda.<domain> behind git-sso with secure-headers, tiled on the dashboard, and added to [platform].subdomains (CoreDNS hosts and the SSO cert list follow) and [images] (deploy.yml builds, scans and pushes it on src/**, pyproject.toml or image-file changes; harden/rebuild-all/infra-ci regenerated accordingly). Built locally (2.15 GB), /_stcore/health -> ok, root -> 200; brought up on the host with compose. refs #1139
16 KiB
P52 — BCDA v3: client, crosswalk-driven flatteners, vendored upstream, proving notebook
Tracker: milestone P52, #1139. Builds on the v2 bcda module (client/store/flatten, #6, #8) and the bcda.express.cclf input-layer pipeline.
Why now
BCDA v3 went GA on 2026-07-01 and CMS discontinues v1/v2 on 2027-07-30. The module targets v2 end to end: Client defaults to /api/v2, and bcda.express.flatten matches BlueButton extensions by the retired https://bluebutton.cms.gov/resources/variables/<name> URL suffix. On a v3 payload that match never fires, so almost every coded column comes out NULL without an error. The live spike against the public Large Model Entity sandbox (2026-10-25) confirmed the transport still works on v3 and that the payload is where the migration lives.
Sources of truth (in priority order)
- BCDA v3 Data Dictionary (
BCDA_v3_Data_Dictionary.xlsx, published 2026-07-01). Each v3 field has afhirPath, the IDRsourceColumn, and two crosswalk columns:ccwMapping(the CCW/v2 variable names the current table models are named after) andcclfMapping(CCLF file.element). TheCCLF-BCDA v3 Mappingsheet is the CCLF-side view of the same crosswalk;CCLF fields absent from BCDA v3lists what cannot be reproduced (HICNs, FIPS county, address lines, BETOS, several CCLFA/B amounts). - Migration guide (
/api-documentation/how-to-migrate-v3.html) and the July 9 2026 office-hours transition deck: endpoint path change only;/authand/dataunchanged; Claim/ClaimResponse retired into EOB;_typeFilterwith theSystem-Typetag (SharedSystem,NationalClaimsHistory,DDPS; default omits SharedSystem); extension/CodeSystem URLs move tofhir/StructureDefinition/<CODE>andfhir/CodeSystem/<CODE>; Patient/Coverage/EOB ids are new (match on MBI and claim control number); Coverage drops reference year/month forCoverage.periodand gains a Dual Medicare/Medicaid row; BETOS removed; v3 deduplicates claim versions within a source. - Official v3 sample files (
v3_Patient/Coverage/ExplanationOfBenefit.ndjsonfrom bcda.cms.gov) — the fixtures the flatteners are tested against. - CMSgov/bcda-app — the server. Vendored sparsely: the v3 handler and its tests, request validation (
_typeFilter,_since, Prefer/Accept rules), the v3 OperationOutcome writer, the v3 Postman collection, gzip download fixtures, the sandbox model config, and LICENSE.
Decisions
- One set of tables, two payload dialects.
bcda.patient,bcda.coverage,bcda.explanation_of_benefit,bcda.eob_itemkeep their CCW-named columns (theccwMappingcolumn is exactly the crosswalk from v3 fields onto them). v3-native data with no v2 home gets new nullable columns rather than being crammed into monthly slots. The flattener detects the dialect per resource (meta.profilecarriesC4BB-…|2.2.0, or anybluebutton.cms.gov/fhir/URL) and dispatches; v2 seeds and tests keep passing. - v3 extraction is table-driven.
bcda.express.v3has a handful of primitives that mirror the dictionary's fhirPath idioms (identifier by system,typecoding by system, supportingInfo by category code → code/timing/value, headeradjudication/totalby Adjudication code, item adjudication by Adjudication code, item extension by StructureDefinition code, careTeam by role, contained provider byprovider.reference) and declarative maps from v3 codes to columns. Every map entry cites the dictionarysourceColumn, so a reader can check it against the spreadsheet. - Identity follows the migration guide.
bene_idis the v3Patient.id(BENE_SK; the EOBpatient.referenceuses it).bene_mbi_idis the current MBI (theus-mbiidentifier without aperiod.end, else the latest).clm_idis the C4BB unique claim id;clm_cntl_numis the claim control number and the key to pair a SharedSystem EOB with its NationalClaimsHistory successor.system_typeandfinal_actioncome frommeta.tag;eob_typefrom theEOB-TYPEcoding,clm_type_cdfromCLM-TYPE-CD. - Coverage is one row per plan per period, as v3 delivers it:
class_plan∈ {Part A, Part B, Part C, Part D, Dual Medicare/Medicaid},period_start/period_endplus the plan's extensions in v3-named columns. The legacy part-A/B/D begin/end columns are filled from the matching plan's period sobcda.express.cclfkeeps working;dual_01is left NULL and the pipeline readscoalesce(dual_01, dual_stus_cd)after a self-join to the Dual row — the only pipeline change in scope. - Race. v3 moves race to the US Core extension (OMB codes).
us_core_raceholds the OMB code;bene_race_cdis derived through the published CCW crosswalk (2106-3→1, 2054-5→2, 2028-9/2076-8→4, 1002-5→6, Hispanic ethnicity→5, unknown→0) so the eligibility race labels do not go blank. - Client: default
v3;type_filter=onstart_export/export;jobs()sendsPrefer: respond-async+Accept: application/fhir+json(required on v2 and v3) and returns the Task resources from the Bundle; every non-2xx surfaces the OperationOutcome diagnostics (BcdaHttpError, still anhttpx.HTTPStatusError); a poll that hits404 Job has been cancelledraisesJobCancelledError(the public sandbox credentials are shared, so this happens)./_versionand/_healthare exposed for the notebook's preflight. - Crosswalk is data, not prose. The dictionary and both CCLF sheets are flattened to
dev/seeds/bcda_v3_data_dictionary.csv,dev/seeds/bcda_v3_cclf_crosswalk.csv,dev/seeds/bcda_v3_cclf_absent.csvand land inreference_dataviastack load seed, so the notebook (and later the chat) can show, per table column, where it came from and which CCLF element it replaces. - Vendoring is a manifest, not a submodule.
dev/vendor/MANIFEST.tomlpins the bcda-app commit and lists each vendored path with its git blob sha; bcda.cms.gov downloads are pinned by sha256.dev/scripts/vendor_bcda_app.py --checkcompares against upstream (GitHub tree API + content hashes) and exits non-zero on drift;--syncrefreshes files and the manifest. A weekly workflow runs--checkand files an issue on drift (no auto-commit: a payload change needs a human to look at the flattener).tests/bcda/test_vendor.pyverifies the tree matches the manifest offline, and the flattener tests run on the vendored official samples. - The notebook proves, it does not analyse.
notebooks/bcda_v3_sandbox.pywalks preflight → auth → attribution → export (reusing a completed job younger than 24 h when one exists, since a fresh Large-entity export takes 20–40 min) →Storedownload → flatten → DuckDB load (duckdb_batch+publish_replica, never a long-lived writer) → checks. Each check is a sentence with a number and a pass/fail mark: every Patient has an MBI, Coverage rows per plan, EOB counts by System-Type × EOB-TYPE, claim-control-number uniqueness within a source, item amounts populated, dictionary field population per table. Pulls are gated behindBCDA_PULL=1or the button so the nightly notebooks-integration run only re-validates data already on disk.
Data flow
sandbox /api/v3 ──Client──▶ Store (data/bcda/exports/<key>/*.ndjson, jobs/files parquet)
└─ flatten_export ──dialect?──▶ v2 flatteners (unchanged)
└──────────▶ bcda.express.v3 ──▶ flat/*.parquet
└─ load_bcda ──▶ aco.duckdb bcda.* ──▶ publish_replica
BCDA_v3_Data_Dictionary.xlsx ──gen_bcda_v3_crosswalk.py──▶ dev/seeds/bcda_v3_*.csv ──stack load seed──▶ reference_data.*
CMSgov/bcda-app + bcda.cms.gov ──vendor_bcda_app.py──▶ dev/vendor/{bcda-app,bcda-site}/ (+ MANIFEST.toml) ──▶ tests, weekly drift check
Out of scope
Reworking bcda.express.cclf semantics for v3 (claim routing by nch_near_line_rec_ident_cd still works because v3 carries CLM_NRLN_RIC_CD; a v3-native routing on eob_type is a follow-up). Partially adjudicated exports end to end (the client supports _typeFilter; the sandbox Large entity is adjudicated-only). Databricks/Iceberg landing of the new columns.
Results (2026-10-25, Large Model Entity sandbox)
Pulled as three single-type jobs: the EOB job (89596, 6 min server-side, 192 files / 2.2 GB) was adopted in 44 s; Patient (11 MB) and Coverage (44 MB) exports took 24 and 32 min, most of it queued as Pending. Flatten + load of the whole pull: ~2 min.
| table | rows |
|---|---|
bcda.patient |
9,582 |
bcda.coverage |
19,396 (Part A 5,643 · Part B 5,649 · Part C 3,236 · Dual 4,868) |
bcda.explanation_of_benefit |
44,361 (PDE 14,395 · CARRIER 6,298 · DME 6,242 · SNF 5,813 · HOSPICE 2,942 · INPATIENT 2,910 · OUTPATIENT 2,883 · HHA 2,878) |
bcda.eob_item |
248,528 |
All 20 notebook checks pass: every Patient has a unique current MBI and bene_id == Patient.id; every Coverage row has period.start, every Part A row ms_cd, every Dual row dual_stus_cd, no orphan beneficiaries; every EOB has a claim id, a claim control number, System-Type and Final-Action tags, EOB-TYPE and CLM-TYPE-CD; control numbers are unique within a source (v3 deduplication holds); lines carry HCPCS or NDC on 100% and paid/submitted amounts on 94% (Part D lines carry drug-cost amounts instead); no orphan lines. The entity is adjudicated-only (no SharedSystem claims), so the control-number pairing check is exercised but finds zero pairs — partially adjudicated data needs an entity with PAC access and _typeFilter.
Connector pipeline on v3 semantics (bcda.express.cclf, 2026-10-25)
The input_layer connector was still written for v2 and had never run end to end (its contracts named CCLF columns the EOB table does not have, and the runner's exact-match check would have refused every stage). Rewritten:
- One header stage carries the v3 semantics.
_stg_claim_headerdropsNotFinalActionclaims, derivesclaim_familyfromEOB-TYPE→CLM-TYPE-CD→pde_id→ the v2 near-line RIC (which v3 emits on only some claims: HHA shows U/W/V/NULL), collapses a claim seen in two sources on its claim control number (NationalClaimsHistory/DDPS over SharedSystem, then newestlast_updated; rows without a control number are never collapsed), and resolves the MBI. The four family stages are filters on it. - One mapping for medical_claim.
_medical_claim(kind)produces the fullaco.table.input_layer.InputLayerMedicalClaimshape for institutional, carrier and DME lines: admission/discharge from the C4BB admission period, NUBC type of bill (clm_bill_type_cd, rebuilt as0+ the three-character v3 frequency code when the coding is missing), MS-DRG, revenue centre, HCPCS and five modifiers, attending→operating→rendering / rendering / rendering→ordering NPIs, billing Organization NPI and tax number, paid / allowed / charge / coinsurance / deductible from the consolidated v3 Adjudication amounts, paid date fromclm_pd_dt→ NCH weekly process → CMS process date, 12 header diagnoses with POA on the first, first ICD procedure. - Part D uses the PDE id, the prescribing care-team NPI, the contained pharmacy Organization NPI, NDC quantity, total drug cost as charge and paid-by-patient as copayment; plan-paid falls back line → header (the sandbox emits neither).
- Eligibility reads the Part A row (Part B fallback) for status, entitlement and the enrollment period and the Dual row for dual status; gender accepts CCW codes and the FHIR words the v2 flattener passed through;
enrollment_statuscomes fromCoverage.status. - Contracts inherit. The header stages subclass
ExplanationOfBenefit; the outputs subclass the canonicalInputLayer*models, so the same downstream pipelines run on BCDA output as on CCLF output.load_bcda(run_pipeline=True)/stack load bcda --run-pipelineandstack run bcdalandinput_layer.medical_claim,pharmacy_claim,eligibility.
Run on the Large-entity pull (94 s including re-flattening): 221,021 medical lines on 28,282 claims (16,468 institutional, 11,814 professional — equal to the FinalAction non-PDE EOB counts by type; 4,111 NotFinalAction excluded), 11,968 pharmacy rows (= FinalAction PDEs), 9,582 eligibility rows (7,290 with a Part A/B row, 4,868 with dual status). Every institutional line has a 4-character type of bill, admission date, paid, charge, deductible and coinsurance; every professional line has paid, rendering NPI and (99 %) place of service.
BCDA manager (bcda.ui, stack bcda, 2026-10-25)
A Streamlit app and a CLI group over the same service layer, mirroring the surface of the CMS server (CMSgov/bcda-app, vendored): /_version, /_health, metadata, /auth/token, attribution_status, $export on Group/all · Group/runout · Patient with _type / _since / _typeFilter, job history, status, cancel, /data download — plus the local side: the Store's job and file history, flatten + load into bcda.*, the connector into input_layer.*, the replica for browsing, the v3 data dictionary and CCLF crosswalk, and the vendored-upstream manifest.
Decisions:
bcda.ui.servicehas no Streamlit in it. Every operation takes explicit objects (aConnection, aClient, aStore), so the CLI reuses it verbatim and tests hand in mock transports and temp directories. Views are thin.- Connections are presets, environment or custom. The four public sandbox entities from bcda.cms.gov are built in (the legacy REACH pair pinned to v2);
BCDA_CLIENT_ID/BCDA_CLIENT_SECRET(+BCDA_BASE_URL,BCDA_API_VERSION) win when set; production is a selectable environment. - Long work runs on background threads (
bcda.ui.runner): a process-level registry that survives Streamlit reruns, captures thebcda.*log lines emitted on each run's own thread (job started, progress, files stored) and flipsstatelast so readers never see a finished run with an incomplete log. Ast.fragment(run_every="5s")panel shows runs on every page that starts them. - Exports default to one job per type with cancel retries, because that is the only shape that survives the shared sandbox's sweeps; the
_typeFilterbuilder composes the System-Type_tag,outcomeandservice-datesubqueries from the transition deck; completed jobs under 24 h are offered for adopt, in-progress ones for cancel. - Data pages read the read-only replica only, so the manager never holds the primary's single-writer lock; the load page republishes the replica after
load_bcda(run_pipeline=…). - Launch:
stack bcda ui(port, headless flags) runsstreamlit run src/bcda/ui/app.py; Streamlit lives in thebcdaextra. Containerising it (image + traefik route + dashboard tile) is deliberately left out of this slice.
Tests: service with httpx.MockTransport, runner for thread-routed logs and state ordering, every page through AppTest.from_string with the service stubbed (buttons, forms, error paths), the app shell through AppTest.from_file, and the CLI through CliRunner — all at 100 % line coverage.
Containerised (2026-10-25)
infra/images/bcda.Dockerfile (same base and install pattern as the api image; uv sync --extra cli --extra bcda, Streamlit settings in infra/images/bcda/streamlit.toml, health at /_stcore/health), compose service bcda on the gateway network with ./data read-write (exports, aco.duckdb, replica publish) and ./dev read-only (vendored upstream, crosswalk seeds, vendor script), Traefik route bcda.<domain> behind git-sso, a dashboard tile, the bcda subdomain in stack.toml (CoreDNS hosts and the SSO cert list follow) and an [images.bcda] entry so the deploy workflow builds, scans and pushes it on changes to src/**, pyproject.toml or the image files. Public sandbox credentials are built in; BCDA_CLIENT_ID / BCDA_CLIENT_SECRET / BCDA_BASE_URL in .env override.