Commonwealth Medallion Model
pipelines/projects/commonwealth · shared/dbt-medallion · phoenix

Commonwealth Medallion Model

How the Commonwealth dbt project defines its grains, uses stewardship master entities, and computes the attributes that pick a reimbursement rule set before pricing runs, and where Phoenix adds or repeats business logic on top of gold.

Repository state f731c7e (main) Read on 2026-10-02 Source system athenaOne, context 16521

Grains

An encounter is an Athena claim family. A claim is one coordination position on one Athena claim. A service line is one non-voided charge.

Master entities

Location, provider, practice and TIN masters come from the stewardship ledger through checked-in crosswalks. Their IDs are everywhere; their values are read in only a few places.

Scored attributes

Eight attributes, computed in dbt silver, score every encounter against every rule set. The pricing scorer only reads the candidates they produce.

Phoenix

Reads seven gold serving models and nothing else. It runs its own opportunity rule, which disagrees with gold's, and repeats several gold rules on the server and again in the browser.

PART 1Grains

Every grain is built on Athena's claim table and its originalclaimid family link. The three core grains are the encounter, the claim and the service line. Every other grain is one of those plus a COB position.

Encounter

The silver docs define it as "a single billable event scoped to who is billing": a surgery produces separate encounters for the surgeon, the anesthesiologist and the facility. Athena has no such object, so the claim family stands in for it.

How a family is formed (models/silver/staging/stg_claim_family.sql):

FlagMeaningRule
is_rootThe family anchorsource_claim_id = root_claim_id
is_primary_claimThe live primary submissionLatest lastbilleddate1, then highest claim id. None in a family never billed primary.
is_attribute_claimThe one claim per family whose attributes stand in for the encounterPrimary submission if one exists, else the root, else the highest id

Patient, department, provider, TIN, diagnosis, admit dates and primary coverage all come from the attribute claim. encounter_coverage selects on the same flag, so the encounter and its coverage can't describe different claims. Only the service date span (min and max over every charge in the family), has_remit and drg_code are aggregated across the family. AR balances are taken from the attribute claim, because summing a rebill and the claim it replaced would double-count.

Who gets in. A family is published only if its location, provider, practice and TIN all resolve to masters that are not hidden, and its primary coverage is not a package Athena types as non-insurance (payer.noninsurancetype). Every other family goes to excluded_encounter with a reason. tests/encounter_population_reconciles.sql checks that each family lands in exactly one of the two.

Claim

"A coordination position on an athena claim": one row per (claim, cob), built in stg_claim_position.sql and claim.sql. claim_id = sk([source_claim_id, cob]), so one Athena claim produces up to three rows and source_claim_id alone is not unique.

cobPositionExists whenPay-to TIN / NPI
1Primarylastbilleddate1 is set, or a primary policy is configuredpaytotaxid1 / paytonpi1
2Secondarylastbilleddate2 is set, or a secondary policy is configuredpaytotaxid2, falling back to slot 1
0PatientAlwayspaytotaxid1 / paytonpi1

Service line

"A single billed service within an encounter": one row per non-voided CHARGE transaction (stg_tx_charge.sql, service_line.sql). service_line_id = sk(parentchargeid), the same id space as erarecord.chargeid, which is how remits attach to a line. The line records only what was billed; payer answers live in service_line_adjudication.

Payer answers and money

ModelGrainNotes
encounter_coverage(encounter, cob)Policies from the attribute claim's slots, as of the date of service. No tertiary on the claim side.
service_line_adjudication(line, cob)The current answer by adjudication_recency(). 861,326 of 3,402,341 adjudicated lines are answered at two positions.
claim_adjudication(claim, cob) = claim_idRemit rows with no charge id: 537,948 rows, $37.4M allowed, $27.2M paid.
transactionOne money movementRemits and practice postings unioned. is_superseded marks which copy to believe, and the remit wins. claim_id or service_line_id is null where they don't resolve.

Gold (shared/dbt-medallion/models/gold) keeps these grains one to one. dim_/fact_encounter is one row per encounter, dim_claim one row per position (unbilled included), dim_/fact_service_line one row per charge, and each *_adjudication model one row per (grain, cob). The calculation facts are one row per (encounter or line, run, scenario). fact_encounter_variance covers only scored encounters, and only against cob 1.

Keys

PART 2Master entities

Four entity types are mastered: location, provider, practice and billing entity (TIN). Master IDs are attached all through silver and gold. Master attribute values are read in only a few places. Much of an encounter's context still comes straight from Athena.

Where masters come from

Ledgerbronze_stewardship_ledger → stg_stewardship_current. Append-only; latest event per entity.
→
Attributesstg_mdc_location, _provider, _practice, _tin unpack the JSON payload.
→
Crosswalkcrosswalk/*.csv → stg_crosswalk. Kept only if the master exists in the ledger.
→
Hiddenstg_hidden_master. Ledger hidden flag wins; otherwise the seed CSV.
→
Publishedpublished_crosswalk(): the crosswalk minus rows pointing at a hidden master.

The checked-in crosswalks hold 151 location, 316 provider, 1 practice and 2 TIN rows. hidden_master.csv lists 9 locations, all with reason "AllyMed".

EntityObservation keySource column
locationsk(departmentid)The claim's servicedepartmentid
providersk(providerid)The claim's renderingproviderid
practicesk(context_id)The whole Athena context counts as one practice
tinglobal_key('tin', raw tax id)paytotaxid1, plus paytotaxid2 on claim cob ≥ 2

Where master IDs attach

Modellocation_idrendering_provider_idpractice_idbilling_entity_idHidden master
stg_encounter_resolution → encounter✓✓✓✓Gates publication; the family goes to excluded_encounter
claim✓✓–✓ (cob 2 uses paytotaxid2)Becomes a null ID
service_line✓✓––Becomes a null ID. Resolved from the line's own claim, not the root.
billing_entity_location✓––✓Edges from claim (TIN × department) pairs
provider_location✓✓––Edges from the provider payload's location_ids, filtered to published masters

In gold, the fact_* models carry these IDs forward from encounter, or from service_line for line-grain facts. The encounter's scopes column ([location_id, practice_id]) drives row-level access on every gold model.

Where master values are read

MasterPayload fields → silver columnConsumers
locationname, address, city, state→state_code, zip→zip_code (5 digits), place_of_service, activedim_location; encounter_details.location_name; scoring zip_code and state_code; pricing scorer facility stateCode and zipCode (scorer/conform.py:64)
providernpi→individual_npi, first and last name, specialist_code→specialty, credential→provider_type, taxonomy_code→taxonomy, location_idsdim_provider, dim_provider_location; encounter_details provider name, type and NPI (feeds the "provider" filter); pricing scorer charge-level NPI and taxonomy (conform.py:77)
practicename, tin, activedim_practice only
tintax_id with non-digits removed, name→display_name, activedim_billing_entity; encounter_details tax_id and billing_entity_name (feeds the "tax_id" filter)

Where source values are used instead

ValueTaken fromUsed byMaster equivalent bypassed
encounter.tax_idRaw paytotaxid1Scoring tax_id attributebilling_entity.tax_id, which encounter_details uses
place_of_serviceAthena department (stg_department)dim_encounter, dim_claim, encounter_details, scoring pos_codelocation.place_of_service
place_of_service_type, care_setting, type_of_billAthena departmentdim_encounter; pricing facilityTypeNone in the payload
dos_state, localityAthena department (GPCI)dim_encounterlocation.state_code
billing_npiRaw paytonpi1 (paytonpi2 on claim cob 2)dim_encounter, dim_claimNone; the TIN payload has no NPI
line place_of_serviceAthena charge, and the charge's departmentdim_service_line, service_line_details, pricing charge placeOfService–
observed_location / _provider / _billing_entityEntirely source data, with resolves and hidden flagsGold observed_* (passed through); Dagster check location_resolutionDeliberately source-side: this is the review queue

entity_encounter would mix both kinds (source department_name beside master city and state), but it is disabled and isn't built.

PART 3Scored attributes

Rule set selection runs in dbt silver, before the pricing scorer. encounter_rule_set_score scores every (encounter, cob) against every rule set's include and exclude lists. The pricing scorer (defs/pricing/assets.py with shared/pricing-scorer) reads the candidates above its consistency threshold and resolves calculations from them.

The attribute set

Defined by three macros in projects/commonwealth/dbt/macros/. scored_attributes() joins them in this order. Weights are versioned by scoring_weight_version: "2026-09-24" and scoring_algo_version: "1.5.0" in dbt_project.yml.

GroupMacroAttributeWeightInclude gates?
coveragecoverage_attributes()payer_id8.0Yes
coveragelob6.0Yes
coverageplan_type5.0Yes
coverageplan_descriptor9.0Yes
billing_entitybilling_entity_attributes()tax_id4.0No, credit only
locationlocation_attributes()zip_code4.0No, credit only
locationpos_code4.0No, credit only
locationstate_code3.0No, credit only

Where each value comes from

encounter_coverage_resolved.sql is the input contract: one row per (encounter, cob) with a service date.

AttributeDerivationOrigin
payer_idRegex rules over insurancereportingcategory, then insurancepackagename, give a brand name. medallion.payer_reference() maps it to a UUID. Medicare Set-Aside, TRICARE, VA and Medicaid deliberately resolve to no payer.Computed, source_payer.sql + routing_rules.sql
lobName keywords first (military, workers' comp, auto), then product type, package type, IRC group and the package name's trailing parenthetical (plan_type_hint)Computed, same files
plan_typeinsuranceproducttype, then plan_type_hintComputed, same files
plan_descriptorinsurancepackagename, passed throughSource
tax_idThe claim's paytotaxid1 (encounter.sql:21)Source
pos_codeAthena department's placeofservicecodeSource
zip_codeMastered location, 5 digitsMaster
state_codeMastered locationMaster

For each routing target, the matching rule with the lowest priority wins. The rule that fired is recorded in source_payer.routing_evidence as JSON, which Phoenix shows. The payer, line-of-business and plan-type vocabularies are generated from shared/payer-reference/payers.json.

Normalization

macros/scored_attribute_value.sql reduces each value to one comparable form. The encounter side and the rule set selector side both pass through it (normalized_selector_values()), so the two vocabularies can't drift. A value that doesn't fit its shape becomes null; it is never forced into one.

AttributeCanonical form
tax_idNine digits with an optional dash after the first two; the dash is removed
payer_idA UUID is lowercased; anything else compares exactly. A null becomes the sentinel 'unresolved'.
lob, plan_typeTrimmed, lowercased
plan_descriptorTrimmed, uppercased
zip_codeZIP+4, 9, 5 or 4 digits; keeps 5, left-padded with zeros
pos_code1–2 digits, left-padded to 2
state_codeTwo letters, uppercased. Never derived from the ZIP.

How scoring works

Other derived fields

PART 4Phoenix

Phoenix is the RevFind app: a .NET API and a React web app over the customer's gold layer. Its docs say gold "arrives precomputed" and that pricing math belongs to the medallion stack. In practice Phoenix owns a set of rules of its own, and in several places it recomputes something gold already publishes, sometimes with a different answer.

What it reads and writes

Phoenix reads gold only, through DuckDB over parquet (src/Phoenix.Infrastructure/DataLake/GoldDbContext.cs). It reads no silver and none of the star schema. Every read is scoped by the row's scopes array: a caller sees a row if any of their practice or location ids is in it.

DirectionGold model or landing fileUsed for
Readsencounter_detailsEncounter page, search, project and recovery figures
Readsservice_line_detailsCharge and remit tab, first-line CPT and billed sum per encounter
Readsencounter_transaction_detailsTransaction ledger, denial reasons, allowed as of a date
Readsencounter_coverage_detailsCoverage panel, primary payer, source-payer to payer mapping
Readscalculation_detailsRate benchmark table (line estimates only)
Readsencounter_filter_optionsSearch filter lists
Readspatient_encounter_detailsOther Encounters tab
Writesencounter-label-event.ndjsonAutomatic, every 30 minutes and on shutdown. Feeds silver encounter_label.
Writesappeal-stage-event.ndjsonAutomatic, same schedule. Feeds silver encounter_appeal_stage.
Writesunderpayment-threshold.ndjsonOn every threshold change. Feeds silver org.
Writesstewardship-ledger.ndjsonManual only, from a button on the internal setup page. Feeds every master entity (Part 2).

Each write is a full snapshot to landing/mdc/dt=YYYY-MM-DD/ in the customer bucket. Notes, projects, packages, outcomes, lump sums and templates are not written back, so gold doesn't see them.

Three opportunity rules

"Is this encounter an underpayment or a denial?" is answered three ways, and none of them reads gold's opportunity_type.

Gold opportunity_typePhoenix serverPhoenix web app
Wheremacros/opportunity_classification.sql on fact_encounter, fact_service_lineDataLake/VarianceOpportunity.cs and again in Core/Projects/AppealEligibility.csChargeRemitTab.tsx, EncounterTable.tsx, ProjectEncounterTable.tsx, RateBenchmarkTable.tsx
ValuesOne of No Response, Denial, Underpayment, No OpportunityAny of underpayment and denial, both possible, or noneRed or not
No remitNo ResponseNo such state. Variance equals expected, so it reads as an underpayment.Same as the server
DenialThe payer's denied money > 0 (CARC Denial category, excluding 18 and B13)Variance on denied lines > $0.005Line variance > threshold, regardless of denial
UnderpaymentPrimary allowed − primary paid > thresholdExpected − allowed − denied-line variance > thresholdLine variance > threshold (charge tab), or variance > 0 (search and project tables)
ThresholdLake org.underpayment_threshold; the macro treats a missing value as 0Postgres Customers.UnderpaymentThreshold, default 1Same as the server, or 0 until it loads

The gold and Phoenix rules measure different gaps. Gold compares what the payer allowed with what it paid. Phoenix compares what we expected with what the payer allowed. They can agree on an encounter by coincidence, but they are not two versions of one rule. The threshold value is the same number in both places, because Phoenix writes it to the lake, but each side reads its own copy.

Phoenix's rule decides real behavior: the search filter, the opportunity badges on the encounter page, whether an encounter can join a project, and whether the appeal deadline is shown at all.

Where Phoenix reimplements gold

Phoenix logicWhereGold equivalentVerdict
Opportunity typesVarianceOpportunity.cs, AppealEligibility.csopportunity_typeContradicts
Allowed as of a date: latest cob-1 remit row per (line, claim) on or before the cutoff, summedDuckDbGoldEncounterReader.cs:235-264adjudication_recency() ranks by processed position, posted date, transaction date, id, then reversalReimplements recency with only date and id
Charge tab totals: sum of line columnsEncounterEndpoints.cs:309-317Header money on encounter_details; gold warns line variance doesn't sum to itContradicts the header by construction
Encounter billed amount: sum of linesDuckDbGoldEncounterReader.cs:205fact_encounter.billed_amount (not read)Same math, different model
Latest claim answer: order by date, take firstDuckDbGoldEncounterReader.cs:310-312Already resolved in encounter_detailsRedundant, harmless
Filter options fallback when the file is missingGoldEncounterSearch.cs:201-231encounter_filter_optionsFull copy of the gold model
Primary coverage: cob == 1EncounterEndpoints.cs:686, SourcePayers.cs, AppealEncounterList.cs, and again in the web appencounter_details.source_payer_* already names the primaryTwo sources for one value
Sort orders for transactions, coverages, other encounters, calculation stepsSeveral readersGold pre-sorts each modelRedundant, consistent

Rules Phoenix owns

These have no gold equivalent. Most are platform state that gold deliberately leaves out (fact_encounter_variance.sql says validated opportunity and appeal state are "platform state").

Phoenix as the stewardship writer

Phoenix is where master entities are created and edited (Part 2). The pipeline folds the ledger the same way Phoenix does: latest event by occurred_at desc, id desc. The payload keys mostly match, with two gaps.

EntityPhoenix writesPipeline also readsEffect
locationname, address1, address2, city, state, zip, practice_id, npi, place_of_service, activehiddenHiding comes only from hidden_master.csv
providerfirst_name, last_name, credential, npi, taxonomy_code, location_ids, activespecialist_code, hiddenprovider.specialty is always null in silver and gold
practicename, tin (digits only), activehiddenKeys match
tintax_id (digits only), name, location, activehiddenKeys match; both sides strip non-digits

Rules in the web app

The React app (web/src/) adds another layer. Some of it duplicates server rules; some is found nowhere else.

RuleWhereServer or gold source
Appeal deadline as days left, or "overdue", on the browser's clockencounter/EncounterDetailsPage.tsx:426-432Gold has the date only (and it is null for Commonwealth today)
Stage labels and stage sets (active, before filing, needs attestation, ready)projects/appealStages.ts, SetStageModal.tsxCopies of Core/Projects/AppealStage.cs
Stage-change planner (forward, escalate, rewind, missing-level warnings)projects/appealStages.ts:182-248Client only. Counts the Ready shelves as the level being prepared; the server's LevelOf counts them as the level already reached.
Root cause editable only in In Reviewencounter/ProjectsTab.tsx:230The server and the appeal package page allow any stage before filing
Recovery page totals, average days, by payer, by monthencounter/RecoveryPage.tsxThe API returns rows only
Project % complete, package total variance, recovered %api.ts:2288-2292, projects/AppealPackagePage.tsxPackage total reads live variance, even for a filed package
Rate benchmark: current rate, deltas, "paid at this rate" within $0.005, "within tolerance"encounter/RateBenchmarkTable.tsxRepeats the server's half-cent floor and applies the threshold to overpayments too
Place of service namescodeSets.ts and settings/placesOfService.tsTwo lists with different names; gold has codes only
Care setting, claim form, COB and variance-type labelsapi.ts:573-597, format.ts:71-77Hardcoded. The encounter page and the search filter label care setting and claim form differently.
Appeal field catalog and letter length limitsprojects/appealFields.tsCopy of Core/Projects/AppealFieldCatalog.cs

PART 5Findings

Things that look inconsistent, unverified, unused or wrong. Some may be intentional; most need a decision about which side is right before anything is fixed.

Mismatch two sources for one value can disagree Fragile correct today, rests on an untested assumption Unused data we collect and don't read Docs the docs say something the code doesn't Bug shows the user a wrong value or wording

Pipeline

01

The pricing request's facility comes from two places Mismatch

stateCode and zipCode come from the master location, while facilityType comes from the Athena department. A stewardship correction changes the first two and not the third.

shared/pricing-scorer/scorer/conform.py:63-67
02

Scoring reads source data for tax_id and pos_code Mismatch

tax_id is raw paytotaxid1 and pos_code is the department's. Only zip and state come from the master. In the same encounter, encounter_details shows the master TIN, and the master location.place_of_service is not used by scoring.

models/silver/encounter_coverage_resolved.sql
03

Two states per encounter Mismatch

dim_encounter.dos_state comes from the department and dim_location.state_code from the master. Nothing checks that they agree.

models/silver/encounter.sql, models/silver/location.sql
04

Claim and encounter can resolve different TINs Mismatch

For cob 2, claim resolves the TIN from paytotaxid2, but encounter publication checks only paytotaxid1. A secondary claim can carry a null or different billing_entity_id from its encounter.

models/silver/claim.sql, models/silver/staging/stg_encounter_resolution.sql
05

Lines resolve location and provider from their own claim Mismatch

The encounter uses the attribute claim's department and provider; a line uses the department and provider of the claim it sits on. After a rebill that moves department, a line's location_id can differ from its encounter's.

models/silver/service_line.sql
06

TIN observations are matched on the raw string Fragile

'41-1234567' and '411234567' are separate observations. Both rows in crosswalk_tin.csv map to one master, which is probably this case. Every spelling has to be listed, or a new spelling leaves encounters unresolved.

medallion global_key('tin', …) in stg_encounter_resolution.sql, claim.sql
07

Families assume originalclaimid is flat Fragile

The family groups claims by originalclaimid, which assumes it always points at the very first claim. Nothing tests that. If Athena ever chains it (a rebill pointing at the previous rebill), one real encounter splits into several.

models/silver/staging/stg_claim_family.sql:10
08

Practice adds nothing beyond the context Fragile

The practice observation is sk(context_id) with one crosswalk row, so every Commonwealth encounter gets the same practice and scopes depends entirely on location. A second context or practice split will need a real observation key.

crosswalk/crosswalk_practice.csv
09

An encounter mixes one claim's attributes with family-wide dates Fragile

Attributes come from the attribute claim, but service_date_from/to span every charge in the family, including charges on claims the attribute claim replaced.

models/silver/encounter.sql (charge_span)
10

A line has one claim_id but can be answered at two positions Fragile

Use the claim_id on the adjudication row, not the line's, when you need the position that answered.

models/silver/service_line.sql (line_position)
11

The legacy account and the encounter are different grains Fragile

Several families on one appointment share a legacy_account_id, so old-versus-new comparisons at encounter grain won't reconcile row for row.

models/legacy/transform/transform_account_grain.sql, models/silver/encounter.sql
12

Master payload fields nothing reads Unused

location.npi (billing_entity_location.location_npi is hardcoded null), location.practice_id (so no practice-to-location link is published), and tin.location. practice.tin and location.place_of_service reach only their dimensions.

models/silver/staging/stg_mdc_*.sql
13

The claim position docs overstate the slot guarantee Docs

The docs say stg_claim_position's population "matches stg_claim_coverage_slot's by construction." In the code, positions are a superset: billed-but-unconfigured positions and unconfigured cob 0 exist only as positions. The guarantee that holds is slot ⊆ position, which is the one that prevents orphaned fact rows.

models/silver/staging/_staging__models.yml, stg_claim_position.sql, stg_claim_coverage_slot.sql

Phoenix

14

Phoenix's opportunity rule disagrees with gold's Mismatch

Gold's opportunity_type is never read. An encounter with no remit is No Response in gold and an underpayment in Phoenix, because its variance equals the full expected amount. The two underpayment tests measure different gaps (allowed − paid versus expected − allowed). One of them is the definition the business means; the other should go.

src/Phoenix.Infrastructure/DataLake/VarianceOpportunity.cs; pipelines macros/opportunity_classification.sql
15

The same rule is written several times Fragile

VarianceOpportunity and AppealEligibility each implement denial and underpayment with their own $0.005 floor. The web app colors variance by a third and fourth rule (over threshold, or just over 0), and repeats the floor in the rate benchmark. Stage sets and the appeal field catalog are also copied into the web app.

src/Phoenix.Core/Projects/AppealEligibility.cs; web/src/encounter/ChargeRemitTab.tsx:299; web/src/projects/appealStages.ts
16

A master created in Phoenix doesn't reach gold on its own Fragile

Phoenix writes the ledger but never the crosswalk, and silver drops any master without a checked-in crosswalk row. The ledger also lands in the lake only when someone presses the internal push button.

src/Phoenix.Api/Endpoints/SetupEndpoints.cs:31-52; pipelines macros/crosswalk_csv.sql
17

The pipeline reads payload fields Phoenix never writes Unused

specialist_code is never written, so provider specialty is null all the way to dim_provider. hidden is never written either, so hiding works only through the seed CSV.

src/Phoenix.Api/Providers/ProviderPayload.cs; pipelines stg_mdc_provider.sql, stg_hidden_master.sql
18

Locations are matched to gold by name Fragile

The location picker and search filter join on normalized location_name, not location_id. A rename on either side, or two locations with one name, moves encounters into the wrong group or the "unmapped" group. Phoenix's name rule also rejects &, apostrophes, hyphens, commas and slashes.

src/Phoenix.Api/Endpoints/LocationEndpoints.cs:59-71; src/Phoenix.Infrastructure/DataLake/GoldEncounterSearch.cs:56-57
19

Allowed-as-of-filing uses its own recency rule Mismatch

Gold's adjudication_recency() ranks a below-primary answer filed at cob 1 last, then orders by posted date. Phoenix orders by transaction date and id only, because the ledger model doesn't carry the other columns. The Recovery page's baseline can therefore pick a different answer than gold would.

src/Phoenix.Infrastructure/DataLake/DuckDbGoldEncounterReader.cs:235-264
20

Two definitions of "recovered" and of "last remit" Mismatch

The encounter page, project stats and appeal package use allowed now minus the first allowed. The Recovery page uses allowed now minus allowed at filing. The package page's "disbursed" date is the claim-level answer date; the Recovery page uses the latest answer including line level.

src/Phoenix.Infrastructure/DataLake/GoldEncounterSummary.cs:52-56; src/Phoenix.Api/Endpoints/RecoveryEndpoints.cs:97-122
21

Charge tab totals won't match the encounter header Mismatch

The totals row sums the line columns. Gold's own comment says line variance doesn't sum to the encounter's, because claim-level answers are invisible to lines.

src/Phoenix.Api/Endpoints/EncounterEndpoints.cs:309-317; gold service_line_details.sql:15-20
22

Search totals count unremitted encounters Mismatch

The positive-variance total includes encounters the payer hasn't answered, where variance is the full expected amount. Gold says a variance means nothing until the payer has answered, but encounter_details doesn't expose has_remit to filter on.

src/Phoenix.Infrastructure/DataLake/GoldEncounterSearch.cs:145
23

Search by account id doesn't work Docs

Gold publishes root_claim_id and source_clinical_encounter_id with the comment "Phoenix searches this … for an encounter by account id." Phoenix searches encounter id, provider name and payer name only, and doesn't map root_claim_id.

src/Phoenix.Infrastructure/DataLake/GoldEncounterSearch.cs:32-35; gold encounter_details.sql:164
24

Denial appeals get underpayment letters Bug

The generated letter always argues "reimbursed below the negotiated fee schedule." The package handler never branches on appeal type.

src/Phoenix.Core/Projects/AppealLetters.cs:52-55
25

Missing money shows as $0.00 Bug

Four tables coalesce null to 0 for sorting and then render the coalesced value, so an unscored variance or unknown recovery reads "$0.00" and sorts between overpaid and underpaid rows. The search CSV export does the same.

web/src/encounter/EncounterTable.tsx:107; web/src/projects/ProjectEncounterTable.tsx:33,66; web/src/encounter/RecoveryPage.tsx:71; web/src/encounter/SearchPage.tsx:137
26

The denial callout shows the billed amount Bug

"Denied · $X" renders line.billed. Gold has the line's denied_amount and the reader loads it, but the API doesn't pass it to the page. Likewise gold's is_denial flag isn't passed, so denial adjustments get the plain "adjustment" pill.

web/src/encounter/ChargeRemitTab.tsx:364,408-412
27

Every estimate carries the encounter's own payer Docs

calculation_details.source_insurance_id is the encounter's primary payer on every row. The C# comment calls it "the rule set payer's insurance id," so the rate benchmark's coverage sort does nothing and every row is labeled with the same payer.

gold calculation_details.sql:140-159; src/Phoenix.Infrastructure/DataLake/GoldCalculationDetail.cs:41; web/src/encounter/RateBenchmarkTable.tsx
28

Root cause editing differs between screens Mismatch

The encounter's Projects tab allows it only in In Review. The server and the appeal package page allow any stage before filing.

web/src/encounter/ProjectsTab.tsx:230; src/Phoenix.Api/Projects/ProjectHandler.cs:405
29

Client and server count ladder levels differently Fragile

The web app's stage planner groups "Ready for Level 1" under Level 1; the server's LevelOf puts it at level 0, the level already reached. The same one-step offset applies to every Ready shelf. The planner's escalate, rewind and missing-level warnings can disagree with what the server allows.

web/src/projects/appealStages.ts:42-149; src/Phoenix.Core/Projects/AppealTransitions.cs:109-121
30

Gold keeps one appeal stage per encounter Mismatch

Phoenix stage belongs to the (project, encounter) pair. fact_encounter.appeal_stage takes the latest change across all projects, so Omni shows whichever project moved last.

gold fact_encounter.sql:65-70
31

Labels disagree between pages Mismatch

There are two place-of-service lists with different names. The encounter page shows care setting "Asc" and claim form "837P"; the search filter shows "Surgery Center" and "CMS-1500" for the same values.

web/src/codeSets.ts; web/src/settings/placesOfService.ts; web/src/api.ts:573-584
32

The coverage switcher assumes the first row is primary Fragile

It opens on index 0. Positions are independent, so a secondary-only encounter opens on secondary while Plan, Group Name and Contract still come from the cob-1 fields.

web/src/encounter/EncounterDetailsPage.tsx:291-292,375-389
33

Project "Total Denial variance" isn't denial variance Mismatch

The stat sums each member's full payment variance whatever the project type, under a label that names the type.

web/src/projects/ProjectDetailsPage.tsx:340; src/Phoenix.Api/Projects/ProjectHandler.cs:474-477