Medallion Data-Quality Stack
pipelines · commonwealth · mvp · mvpdev · shared/dbt-medallion

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.

  1. #6258Put athena behind an adapter; move platform silver into the packageHow silver is built, not what it builds. Gold untouched.no output change
  2. #6259Root rebill chains at their first claimA rebill of a rebill joins the original's encounter instead of opening a second one.changes encounterscheck first
  3. #6260Read NPPES and NUCC in place from the shared rootReader macros and sources in the package. Nothing reads them yet.no output change
  4. #6261Fill provider taxonomy, specialty and nonphysician classThe ledger wins; NPPES and NUCC fill its gaps.fills empty columns
  5. #6262Flag what NPPES knows about each observed provider's NPIFour columns on the provider review queue.adds columns
  6. #6263Flag what NPPES knows about each billing NPIThe same four columns on the billing entity review queue.adds columns
  7. #6264One TIN per spelling; claims and lines take their encounter's mastersFindings 04, 05 and 06 from the medallion review.changes line factsblocked
no output change same rows, same columns adds new or filled columns, existing values unchanged changes existing values move

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.

PRStatusWhat has to happenWho
#6264blockedRegenerate 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
#6259checkThe 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
#6267fix for mainThe 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
#6261clearedBucket access is granted on both sides (checked from policy text). The first observation after deploy is the live read.—
#6258, #6260, #6262, #6263readyReview.Reviewers
After mergedeploy stepSync 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.

#6258no output changecary/vigilant-cori-qicnjy

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.

Beforestg_claim_family.sql
-- athena columns, read directly
from {{ ref('bronze_claim') }}
coalesce(nullif(cast(originalclaimid as varchar), ''),
         cast(claimid as varchar)) as root_claim_id
Afterstg_claim_family.sql
-- 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.

#6259changes encounterscary/data-quality-claim-family-guard

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.

ClaimoriginalclaimidRoot beforeRoot after
100—100100
101100100100
102101101 · a second encounter100
1

chained claim in production; its encounter merges into its original's

168

chained claims on the synthetic lake, all re-rooted; the test went from 168 failures to 0

10

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.

#6260no output changecary/data-quality-nppes-reference

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.

Any modelreads the registry
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
Primary taxonomypricing's rule
-- 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.

#6261fills empty columnscary/data-quality-provider-nppes

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.

ColumnBeforeAfter
taxonomynull363LF0000X · from nppes
specialtynullFamily Nurse Practitioner · from nucc
nonphysician_classification(new)NP
is_mid_levelnulltrue
npi_status(new)active

A provider whose ledger entry has an NPI but no taxonomy. The test fixtures use this case.

281

Commonwealth mastered providers; every NPI is in NPPES

2

of them deactivated

6

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.

#6262adds columnscary/data-quality-observed-provider-nppes

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 saysCommonwealth providersShows as
An individual's NPI, active316active · individual
An organization's NPI, recorded as a rendering provider33active · organization
Deactivated, with its other fields blanked2deactivated
Blank, or never issued63missing / 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.

#6263adds columnscary/data-quality-billing-entity-nppes

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.

#6264changes line factsblockedcary/data-quality-master-resolution

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.

RecordedKey beforeKey after
41-1234567md5('tin:41-1234567')md5('tin:411234567')
411234567md5('tin:411234567')md5('tin:411234567')
123-45-6789md5('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 claimBeforeAfter
Encounter's locationLocation ALocation A
Line's own claim departmentan unresolved department(not used)
Line's location_idnull · drops out of location reportsLocation A
3,503

synthetic claims and lines that disagreed with their encounter; 0 after

852

synthetic lines in published encounters that had no location

2,877

mvp fact_service_line rows whose masters moved; no other column changed

In production

CommonwealthRowsLocation changesProvider changesBilling entity changes
service_line3,482,98000—
claim9,548,7542,1393,6002,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

LayerWhatFindings
6Master 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
7Move 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.