The connector (bcda.express.cclf / bcda.pipe.cclf) still routed on the v2 near-line RIC code, read v2-only amount columns, and had never run end to end: its contracts named CCLF columns the EOB table does not have, so the runner's exact-match check would have refused every stage. One header stage now carries the v3 semantics. _stg_claim_header drops NotFinalAction claims (9% of the sandbox), derives claim_family from EOB-TYPE → CLM-TYPE-CD → pde_id → RIC (v3 emits the RIC on only some claims), collapses a claim seen in two sources on its claim control number (NationalClaimsHistory/DDPS over SharedSystem, then newest last_updated; rows without a control number are never collapsed) and resolves the MBI. The four family stages are filters on it. One mapping produces input_layer.medical_claim for institutional, carrier and DME lines: admission/discharge from the C4BB admission period, NUBC type of bill (new clm_bill_type_cd on the EOB table; rebuilt as "0" + the three-character v3 frequency code when the coding is missing — the v2 fac+clsfctn+freq concatenation yields "88787J" on v3), MS-DRG, revenue centre, HCPCS + 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 from clm_pd_dt → NCH weekly → CMS process. Part D uses the PDE id, prescribing care-team NPI, contained pharmacy NPI, NDC quantity, total drug cost as charge and paid-by-patient as copayment. Eligibility reads the Part A row (Part B fallback) for status, entitlement and enrollment, the Dual row for dual status, and accepts both CCW sex codes and the FHIR words the v2 flattener passed through. Contracts inherit: header stages subclass ExplanationOfBenefit, outputs subclass aco.table.input_layer.InputLayer*, so downstream pipelines run on BCDA output exactly as on CCLF output. load_bcda(run_pipeline=True), stack load bcda --run-pipeline and stack run bcda land input_layer.*. Large-entity sandbox pull: 221,021 medical lines on 28,282 claims (equal to the FinalAction non-PDE EOBs by type), 11,968 pharmacy rows, 9,582 eligibility rows; all 16 reconciliation checks in the notebook pass. Tests run the whole connector on CMS's official v3 samples through DuckDB. refs #1139
12 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.