12 KiB
Status: Draft, awaiting user sign-off.
SP32 — 835/837P rendering & service-provider NPI extraction
Scope
In scope
- Extract
NM1*82(rendering provider, Loop 2420A) from 837p files into a typed field onClaim. - Extract
NM1*1P(service provider, Loop 2100) from 835 files into a typed field onClaimPaymentand an aggregated column onRemittance. - Persist all three new fields in their typed columns AND mirror to
raw_jsonfor audit and fallback reads. - Backfill existing rows by re-parsing on-disk files via a new CLI subcommand.
- Re-reconcile open claim/remit pairs after backfill so the NPI arm of SP31's 2-of-3 content-match rule can actually fire on historical data.
- Widen
_content_keys_matchinreconcile.pyto read the new NPI sources (typed-column primary,raw_jsonfallback).
Out of scope
- NPI Luhn validation or any other validation of format/length. Plain string, like the existing
Claim.provider_npi. - UI surface — no changes to drawer/kpi/activity widgets. The new columns are exposed via the existing
raw_jsonAPI paths; rendering them in the UI is a separate SP. - Multi-payer
payer_idfiltering — still deferred (SP31 documented deviation stands). - Spec-stricter lineage fields (CLP09 frequency, NM1*82 last-name vs organization-name semantics) beyond what the spec requires for matching.
Decisions
D1 — Both sides extracted (apples-to-apples)
The 837p parser today only captures the billing provider NPI (NM1*85). The 835 parser today discards NM1*1P entirely. SP32 extracts both: 837p NM1*82 → rendering NPI; 835 NM1*1P → service provider NPI. They are compared against each other in the matcher's NPI arm, not against Claim.provider_npi (billing).
D2 — Per-segment typed field on ClaimPayment
Add ClaimPayment.service_provider_npi: str | None to backend/src/cyclone/parsers/models_835.py. Length-10 string (NPI is 10 digits; no Luhn enforced). Empty/missing stored as None, not "".
D3 — Per-Claim typed column on Claim (837p)
Add Claim.rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True) to backend/src/cyclone/db.py. Mirrors the existing Claim.provider_npi column. Schema migration 0016_add_rendering_and_service_provider_npis.sql adds both this and the next column.
D4 — Per-Remit column for the matcher
Add Remittance.rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True). Today, each Remittance row corresponds to exactly one ClaimPayment (the write layer keys Remittance by cp.payer_claim_control_number; see store/write.py:134), so the column holds the single NPI for that segment (or None). The column is left unaggregated to keep the schema simple — if multi-CLP-per-Remit support is added in a future SP, the schema is large enough (String(16)) to upgrade to comma-joined storage without re-migration. The matcher's NPI arm reads this column directly.
D5 — raw_json mirrors
ClaimPayment is a parser wire-format model only — there is no claim_payments SQL table. The typed per-segment NPI persists through these mirrors:
Remittance.raw_json["service_provider_npis"]: list of everyClaimPayment.service_provider_npi, written verbatim at parse time. Source of truth for audit.Claim.raw_json["rendering_provider_npi"]: mirror of the typed column.
These keep the existing raw_json fallback chain in _content_keys_match functional and provide a structured audit trail.
D6 — _content_keys_match NPI arm
Extend the existing matcher's NPI branch (currently a raw_json fallback chain) with a typed-column primary path:
- Read
Claim.rendering_provider_npi(typed) andRemittance.rendering_provider_npi(typed). - Split
Remittance.rendering_provider_npion","and treat as a set (single-CLP produces a one-element set; multi-CLP produces N elements). - If the claim's rendering NPI is present in that set, count
rendering_provider_npias a matched key. - Fall back to the
raw_jsonchain only when the typed columns areNoneor empty.
Effective algorithm unchanged: 2-of-3 {PCN, charge, NPI} still fires when any two keys agree. The NPI arm now actually contributes when the data is present.
D7 — Backfill via CLI
New subcommand: python -m cyclone.cli backfill-rendering-npi.
Behavior:
- For every
claim_batches.inbound_path, re-parse the 837p file with the new parser; for every claim in the batch, writeClaim.rendering_provider_npi = parsed_valueonly when the column is currentlyNULL(idempotent). - For every
remittances.inbound_path, re-parse the 835 file with the new parser; for every remit, write both the per-segment NPIs (ClaimPayment.service_provider_npi) and the aggregatedRemittance.rendering_provider_npi. - Run
reconcile.run()once over all open pairs at the end so the NPI arm can fire retroactively. - Log per-batch counts (parsed, updated, skipped).
- Exit code 0 on success, 2 on file-level failure (no batch updated), 1 on unexpected exception.
Idempotent: re-running on a fully-populated DB is a no-op (typed columns not overwritten; reconcile already matched pairs skipped via Claim.matched_remittance_id IS NOT NULL).
D8 — Backfill event emission
Each new score-auto match produced during post-backfill reconcile emits an auto_matched_835 ActivityEvent with payload {new_state, strategy, keys_matched, candidate_count} — same shape as SP31 D8. The audit trail explicitly captures backfill work; nothing is silent.
D9 — Migration is forward-only, additive
Migration 0019_add_rendering_and_service_provider_npis.sql adds:
ALTER TABLE claims ADD COLUMN rendering_provider_npi TEXT;
ALTER TABLE remittances ADD COLUMN rendering_provider_npi TEXT;
ClaimPayment.service_provider_npi has no SQL column because claim_payments has no SQL table — the typed value lives in Remittance.raw_json["service_provider_npis"].
No UPDATE statements in the migration — backfill is a separate, deliberate step via the CLI. No indexes — both columns are nullable, used for set-equality at match time, not range queries. Scale (Colorado Medicaid single-payer) doesn't justify index write cost.
D10 — File paths and code shape
backend/src/cyclone/parsers/parse_835.py— extend_consume_claim_paymentto handle theNM1child segment: parseNM1*1P, captureNM109asservice_provider_npifor the enclosing CLP. OtherNM1qualifiers (QC,IL,PR,85,87) within the CLP loop are kept inraw_segments(unchanged behavior).backend/src/cyclone/parsers/parse_837.py— add a new_consume_rendering_provider(or analogous) function for Loop 2420A; wire into the existing claim assembly path so the rendering NPI lands onClaim.backend/src/cyclone/parsers/models_835.py— extendClaimPaymentwithservice_provider_npi.backend/src/cyclone/db.py—Claim.rendering_provider_npi,Remittance.rendering_provider_npi. No claim_payments ORM — service_provider_npi persists only viaRemittance.raw_json["service_provider_npis"](D5).backend/src/cyclone/writer.py/writer_835.py— set the new typed columns and theraw_jsonmirrors at write time.writer_835.pywrites the per-CLPservice_provider_npiintoRemittance.rendering_provider_npi(D4, single value) and intoraw_json["service_provider_npis"](D5).backend/src/cyclone/reconcile.py— extend_content_keys_match(D6).backend/src/cyclone/cli.py— newbackfill-rendering-npisubcommand.backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql— D9.backend/tests/fixtures/— new 837p fixture(s) withNM1*82and new 835 fixture(s) withNM1*1P.backend/tests/test_reconcile.py— new tests for D6 (set-membership, comma-split, fallback chain).backend/tests/test_parse_835.py— new test for_consume_claim_paymentNM1*1Pextraction.backend/tests/test_parse_837.py— new test for rendering-provider loop extraction.
Edge cases
| Case | Behavior |
|---|---|
NM1*1P missing or 1P qualifier absent in a CLP segment |
ClaimPayment.service_provider_npi = None. Matcher's NPI arm simply doesn't fire for that key. |
NM1*82 missing or empty in an 837p claim |
Claim.rendering_provider_npi = None. Same graceful degrade. |
NM1*1P with NM109 empty |
Treat as missing — store None, never empty string. |
| Multi-CLP per Remit in a future SP | Schema-size forward note (D4). Today, each Remit = one CLP = single NPI, no aggregation needed. |
| 837p rendering NPI == billing NPI (solo practice) | Both columns populated independently. Matcher compares against the service-provider NPI; identity is coincidence in this case. |
| Backfill on a deleted-on-disk file | CLI logs a WARNING, skips the batch. Typed columns stay NULL for unmatched rows. Existing match state untouched. |
Re-reconcile creates new score-auto matches |
Each emits an auto_matched_835 ActivityEvent per SP31 D8 (D8). Audit trail is explicit. |
| Idempotent re-run | Typed columns written only when currently NULL; reconcile skips already-matched pairs. backfill-rendering-npi exit code 0. |
| Existing 115 SP31 tests | All pass; widened matcher signature preserves the boolean-return path used today. |
Testing
Unit tests (parser-level)
test_parse_835.py:NM1*1Pextraction — claim withNM1*1P*2*...*XX*1234567890populatesClaimPayment.service_provider_npi == "1234567890". Multi-CLP with mixed NPIs produces per-segment values correctly.test_parse_835.py:NM1*1Pabsent —service_provider_npi is None, no exception.test_parse_837.py:NM1*82extraction — Loop 2420A rendering provider populatesClaim.rendering_provider_npi.test_parse_837.py: 837p withoutNM1*82— columnNone.
Unit tests (matcher level)
test_reconcile.py: comma-list set membership — single-CLP-remit withRemittance.rendering_provider_npi = "1234567890,0987654321"andClaim.rendering_provider_npi = "0987654321"counts NPI as a matched key.test_reconcile.py: multi-key fallback —NM1*1PNPI absent (None) on one side, present on the other → NPI arm does not fire, PCN+charge still matches (1-of-2 effective, but per SP31 the rule is 2-of-3 of {PCN, charge, NPI}, so PCN+charge alone does NOT auto-link — must stay 2 of 3 with NPI also matching or charge differing).test_reconcile.py: typed-column primary path beatsraw_jsonfallback — fixture sets both, matcher uses typed column.test_reconcile.py: existing 22+ SP31 tests still pass without modification.
Integration tests
test_backfill_cli.py: synthetic batch + remit in DB, fixture files on disk, runbackfill-rendering-npi— assert columns populated, exit code 0, ActivityEvents emitted for any new matches.test_backfill_idempotent.py: run twice, second run is no-op.test_backfill_missing_file.py: deleted file path → WARNING log, no crash, other batches still processed.
Regression
- Full backend
pytest(target: 115 existing + ~8 new = ~123 total, all passing). - Frontend
typecheck: no new errors (no new types; matcher's typed return is internal). - Manual smoke: ingest a real 835 via CLI, query the DB, confirm
Remittance.rendering_provider_npipopulated and any retroactivescore-autoMatch row exists.
Out of scope (deferred to follow-up SPs)
- NPI Luhn validation — separately enforced if/when claim submission rejects malformed NPIs upstream.
- UI rendering of rendering/service-provider NPI in ClaimDrawer / RemitDrawer — separate SP.
- Multi-payer
payer_idfilter restoration — SP31 documented deviation; needs schema migration or raw_json read. - Per-segment NPI display in RemitDrawer's CLP rows — separate SP.