Medallion Data-Quality Stack
Seven draft PRs, each based on the one before it. The first changes how silver is built and nothing it outputs. The rest fix how claims group into encounters, fill provider gaps from the NPI registry, flag bad NPIs for review, and make claims and lines agree with their encounter.
A source boundary
Every athena assumption lives in one adapter, so an 835/837 feed fills in a known list of tables.
Encounters that hold together
Rebill chains root at their first claim, and every claim and line carries its encounter's location.
Providers filled from NPPES
Taxonomy, specialty and pricing's mid-level class, where the stewardship ledger is silent. The ledger always wins.
A review queue with evidence
Each observed provider and billing NPI shows whether NPPES issued it, to whom, and whether it's still active.
The stack
Listed in merge order: each PR is based on the one above it, so they land from the top down. The left edge shows what each does to output.
- #6258Put athena behind an adapter; move platform silver into the packageHow silver is built, not what it builds. Gold untouched.
- #6259Root rebill chains at their first claimA rebill of a rebill joins the original's encounter instead of opening a second one.
- #6260Read NPPES and NUCC in place from the shared rootReader macros and sources in the package. Nothing reads them yet.
- #6261Fill provider taxonomy, specialty and nonphysician classThe ledger wins; NPPES and NUCC fill its gaps.
- #6262Flag what NPPES knows about each observed provider's NPIFour columns on the provider review queue.
- #6263Flag what NPPES knows about each billing NPIThe same four columns on the billing entity review queue.
- #6264One TIN per spelling; claims and lines take their encounter's mastersFindings 04, 05 and 06 from the medallion review.
Before merging
Production checks on October 4 cleared two of the original three items and found a new one. Two PRs still need something done first, and one fix goes to main on its own.
| PR | Status | What has to happen | Who |
|---|---|---|---|
| #6264 | blocked | Regenerate crosswalk_tin.csv from production TIN keys. The hash-only query is in the PR. Merging without it could leave every Commonwealth encounter unresolved. | Someone with production access runs the query |
| #6259 | check | The lake is clean: no label or appeal events on the encounter that disappears. It is published today, so Phoenix's own database still needs the six-table check in the PR comment. | Someone with Phoenix database access |
| #6267 | fix for main | The NPPES observation reads a manifest lapidary has never published, so it has most likely failed on every run since #6222. This observes the newest partitions instead. #6260 and #6261 carry the same change. | Reviewers |
| #6261 | cleared | Bucket access is granted on both sides (checked from policy text). The first observation after deploy is the live read. | — |
| #6258, #6260, #6262, #6263 | ready | Review. | Reviewers |
| After merge | deploy step | Sync Glue for dim_provider and the three observed_* tables in each mdc_omni_* database. The catalog had already drifted (hidden from #6248); review the full plan before applying. | Omni ops |
Product calls made along the way, and six open questions for product, are tracked in Data-quality decisions.
Adapter refactor
Silver used to read athena bronze from a dozen models, with every athena assumption implicit. Now ten src_* tables are the only thing core silver reads from the source, and a test fails if that changes. Stewardship, masters, labels and appeal stages moved into the shared medallion package, so Commonwealth, mvp and mvpdev share one copy instead of three drifting ones.
-- athena columns, read directly
from {{ ref('bronze_claim') }}
coalesce(nullif(cast(originalclaimid as varchar), ''),
cast(claimid as varchar)) as root_claim_id-- the adapter decides the family; core just reads it from {{ medallion.staging_parquet('src_claim', 'source_claim_id') }} select context_id, source_claim_id, claim_sequence, root_claim_id
Customers see
Nothing. Gold is untouched.
Verified
Commonwealth: 60 of 61 silver models row-identical. remittance_adjustment differs only in the order of one array, which already varied between runs. mvp: the only differences are that ordering and per-run IDs.
The Silver Adapter Refactor page walks through this PR with seven before/after examples.
Claim families
An encounter is a family of claims: an original and its rebills. athena's originalclaimid names the claim a rebill replaced, and that can itself be a rebill. Silver took it as the root, so a rebill of a rebill opened a second family and split one encounter in two. src_claim now walks the chain back to the first claim. A new test, claim_families_are_flat, fails if any claim's root is itself a rebill.
| Claim | originalclaimid | Root before | Root after |
|---|---|---|---|
| 100 | — | 100 | 100 |
| 101 | 100 | 100 | 100 |
| 102 | 101 | 101 · a second encounter | 100 |
chained claim in production; its encounter merges into its original's
chained claims on the synthetic lake, all re-rooted; the test went from 168 failures to 0
synthetic claims left published data: their merged encounter takes an unresolved or hidden location
Check first. In production, encounter 541adf44… disappears and its one claim joins 5634c6e2…, which is published. The lake has no labels or appeal stages on the old encounter. It is published today, so Phoenix's own database (projects, notes, appeal packages) still needs checking; the query is on the PR.
NPPES reader
The CMS NPI registry (NPPES) and the NUCC taxonomy code set are read where lapidary publishes them, under one shared root. Nothing is copied. Two package macros return the newest month: each NPI's primary taxonomy and deactivation status, read with the same rules pricing uses, and the newest NUCC release. When a project has no NPPES root set, both return an empty, typed result, so mvp still builds.
from {{ medallion.nppes_npi_current() }}
-- npi, entity_type, registry_name, primary_taxonomy_code,
-- is_deactivated, deactivation_date, practice address, …
from {{ medallion.nucc_taxonomy_current() }}
-- code, grouping, classification, specialization, display_name-- the slot NPPES marks primary, else slot 1
coalesce(
case when switch_1 = 'Y' then taxonomy_code_1 end,
…
case when switch_15 = 'Y' then taxonomy_code_15 end,
taxonomy_code_1)Customers see
Nothing yet. The next three PRs read it.
Dagster
The three tables are observed sources under common/cms/nppes/*, each versioned from its newest partition, so a run sees when lapidary publishes a new month.
Found in production: lapidary's nppes.manifest.json, which main's observation reads, has never been published, and its table list would leave out nucc_taxonomy. The observation now reads the partitions themselves; #6267 is the same fix for main. Also: nppes_deactivated stops at July while nppes_npi is at September, so recent deactivations read as active.
Providers
The stewardship ledger always wins. Where it's silent, NPPES fills the taxonomy and NUCC names the specialty, and taxonomy_source and specialty_source say which supplied each value. Mid-level means the taxonomy falls in one of the eight nonphysician classes pricing applies its rate by (MdClarity/MdClarity#6233). dim_provider gains nonphysician_classification and npi_status.
| Column | Before | After |
|---|---|---|
| taxonomy | null | 363LF0000X · from nppes |
| specialty | null | Family Nurse Practitioner · from nucc |
| nonphysician_classification | (new) | NP |
| is_mid_level | null | true |
| npi_status | (new) | active |
A provider whose ledger entry has an NPI but no taxonomy. The test fixtures use this case.
Commonwealth mastered providers; every NPI is in NPPES
of them deactivated
ledger taxonomies that differ from NPPES; the ledger's value is kept
Commonwealth's ledger already has a taxonomy for every provider, so the NPPES fallback rarely fires there. What changes is specialty, mid-level, the class and NPI status, which were empty.
Cleared. The bucket policy admits the prod and dev accounts, and the three task roles carry the matching policy. Once provider depends on NPPES, a failed observation would hold back silver in all three projects, which is why the observation no longer depends on lapidary's manifest.
Observed providers
observed_provider is the queue of providers as athena records them, before stewardship. It gains four columns that say what the registry knows about the NPI athena recorded: npi_status, nppes_entity_type, nppes_name and nppes_taxonomy. A name that doesn't match nppes_name points to an NPI keyed against the wrong person.
| What NPPES says | Commonwealth providers | Shows as |
|---|---|---|
| An individual's NPI, active | 316 | active · individual |
| An organization's NPI, recorded as a rendering provider | 33 | active · organization |
| Deactivated, with its other fields blanked | 2 | deactivated |
| Blank, or never issued | 63 | missing / not_found |
npi_status is one shared macro, so provider and observed_provider classify an NPI the same way. It's blank when a build has no NPPES, so a project without it never calls every NPI unregistered.
Billing NPIs
The same four columns on observed_billing_entity, for the billing NPI each TIN bills under most often. NPPES publishes no EINs (every one reads <UNAVAIL>), so nothing here can confirm an NPI bills under its TIN. The registered legal name beside the medical group's name is the check a reviewer can make.
Commonwealth today
All 22 billing NPIs are organizations, so this mostly confirms the data and catches new problems as they appear.
Location from NPPES
Not attempted. observed_location is a department and has no NPI to look up.
Master resolution
One TIN, whatever its spelling
A TIN observation was keyed on the raw string, so each spelling needed its own row in the hand-edited crosswalk, and a new spelling left encounters unresolved. medallion.tin_key keys a nine-digit TIN, with or without its dash, on its digits. Any other shape keeps its own spelling: an SSN-style value or free text is never coerced into a TIN.
| Recorded | Key before | Key after |
|---|---|---|
| 41-1234567 | md5('tin:41-1234567') | md5('tin:411234567') |
| 411234567 | md5('tin:411234567') | md5('tin:411234567') |
| 123-45-6789 | md5('tin:123-45-6789') | unchanged |
Claims and lines sit with their encounter
An encounter's location decides whether it's shown and who can see it. Lines used to resolve location and provider from their own claim, so a line could carry a different location from its encounter, or none at all. Reports by location then didn't tie out, and lines from a hidden location showed up unlabeled inside visible encounters. Claims and lines now carry the encounter's location, rendering provider and billing entity, and a new test enforces it.
| A line on a replaced claim | Before | After |
|---|---|---|
| Encounter's location | Location A | Location A |
| Line's own claim department | an unresolved department | (not used) |
Line's location_id | null · drops out of location reports | Location A |
synthetic claims and lines that disagreed with their encounter; 0 after
synthetic lines in published encounters that had no location
mvp fact_service_line rows whose masters moved; no other column changed
In production
| Commonwealth | Rows | Location changes | Provider changes | Billing entity changes |
|---|---|---|---|---|
| service_line | 3,482,980 | 0 | 0 | — |
| claim | 9,548,754 | 2,139 | 3,600 | 2,229 |
No production line moves. Most likely athena moves a rebill's charges onto the new claim, so every line already sits on the claim that speaks for its encounter, while a replaced submission's claim positions keep their own department. In production this PR corrects about 2,100 to 3,600 claims; the line-level fix guards against data like the synthetic lake's.
Blocked. Commonwealth's crosswalk_tin.csv holds two hashed rows, most likely two spellings of one TIN. They have to be regenerated from production keys before this merges. The PR has a query that returns hashes only, no raw TINs.
Emerson's MdClarity/MdClarity#6254 maps each department to its own location. It touches different crosswalk files, so the two don't conflict, but it makes a line in a different location from its encounter more likely, which this PR prevents.
What's next
| Layer | What | Findings |
|---|---|---|
| 6 | Master versus source values. For the scoring TIN and place of service, the pricing request's facility, and the encounter's state, decide whether the mastered location or athena's department wins. | 01–03 |
| 7 | Move core silver into the package, with an mvp adapter, so all three projects share one silver. | — |
Two explorations sit outside the stack and aren't PRs. cary/explore-fewer-assets inlines cheap staging models so they write no parquet and aren't Dagster assets. cary/spike-duckdb-relations runs the claim-family slice as plain SQL over DuckDB without dbt: same output, about 6× faster on synthetic data, one asset instead of three.