Files
cyclone/docs/superpowers/specs/2026-07-02-cyclone-rendering-npi-extraction-design.md

14 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 on Claim.
  • Extract NM1*1P (service provider, Loop 2100) from 835 files into a typed field on ClaimPayment and an aggregated column on Remittance.
  • Persist all three new fields in their typed columns AND mirror to raw_json for 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_match in reconcile.py to read the new NPI sources (typed-column primary, raw_json fallback).

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_json API paths; rendering them in the UI is a separate SP.
  • Multi-payer payer_id filtering — 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 every ClaimPayment.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:

  1. Read Claim.rendering_provider_npi (typed) and Remittance.rendering_provider_npi (typed).
  2. Split Remittance.rendering_provider_npi on "," and treat as a set (single-CLP produces a one-element set; multi-CLP produces N elements).
  3. If the claim's rendering NPI is present in that set, count rendering_provider_npi as a matched key.
  4. Fall back to the raw_json chain only when the typed columns are None or 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.

Why explicit file args. The Batch ORM does not retain a column for the original on-disk ingest path — the live ingest pipeline reads the upload body, parses, writes rows, and never persists the bytes. So there is no claim_batches.inbound_path / remittances.inbound_path to "look up" and re-parse from. The operator must point the CLI at the files (or the directory that holds them) that need backfill. Re-running ingest end-to-end would be the alternative, but it would re-INSERT claim rows and is a destructive operation; backfill is intentionally a read-reparse-targeted-UPDATE.

Flags:

  • --file PATH (repeatable) — one or more specific X12 files to re-parse. Each path is validated to exist before parsing.
  • --input-dir DIR — directory to scan one level deep for *.txt / *.edi / *.x12 files. Falls back to $CYCLONE_BACKFILL_INPUT_DIR if neither --file nor --input-dir is passed.
  • --type {837p,835} (optional) — pin the parser. When omitted, each file's transaction kind is auto-sniffed (filename hint + ISA/ST prefix) so a mixed directory can be processed in one pass.

Behavior:

  1. For each file resolved by --file / --input-dir, re-parse with the new parser (T4 wiring).
  2. 837p: for every claim in the batch, write Claim.rendering_provider_npi = parsed_value only when the column is currently NULL (idempotent — already-populated rows are left alone).
  3. 835: for every remit, write Remittance.rendering_provider_npi (typed column) only when NULL, and persist the per-segment service_provider_npi values into Remittance.raw_json["service_provider_npis"] (D5).
  4. After all files are processed, run reconcile.run() once across every 835 Batch so the D6 NPI arm can fire retroactively on newly-populated pairs.
  5. Log per-file counts (parsed, updated, skipped). Emit one-line summary to stdout: claims_updated=N remits_updated=M files_processed=K files_skipped=J.
  6. Exit code 0 on success (zero populated rows is still exit 0), 2 on file-level failure (no file processed), 1 on unexpected exception.

Idempotent: re-running on a fully-populated DB is a no-op (typed columns not overwritten; reconcile skips already-matched pairs 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_payment to handle the NM1 child segment: parse NM1*1P, capture NM109 as service_provider_npi for the enclosing CLP. Other NM1 qualifiers (QC, IL, PR, 85, 87) within the CLP loop are kept in raw_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 on Claim.
  • backend/src/cyclone/parsers/models_835.py — extend ClaimPayment with service_provider_npi.
  • backend/src/cyclone/db.pyClaim.rendering_provider_npi, Remittance.rendering_provider_npi. No claim_payments ORM — service_provider_npi persists only via Remittance.raw_json["service_provider_npis"] (D5).
  • backend/src/cyclone/writer.py / writer_835.py — set the new typed columns and the raw_json mirrors at write time. writer_835.py writes the per-CLP service_provider_npi into Remittance.rendering_provider_npi (D4, single value) and into raw_json["service_provider_npis"] (D5).
  • backend/src/cyclone/reconcile.py — extend _content_keys_match (D6).
  • backend/src/cyclone/store/backfill.pynew helper module owning backfill_rendering_provider_npi(files, input_dir, transaction_type): file/CLI argument resolution, auto-sniffing (ISA/ST + filename hint), re-parse dispatch, idempotent typed-column writes, and the post-backfill reconcile.run() sweep. Returns a small summary dataclass for the CLI summary line.
  • backend/src/cyclone/cli.py — new backfill-rendering-npi subcommand (Click). Wires --file / --input-dir / --type / --log-level, delegates the heavy lifting to store.backfill.backfill_rendering_provider_npi, and prints the one-line summary.
  • backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql — D9.
  • backend/tests/fixtures/ — new 837p fixture(s) with NM1*82 and new 835 fixture(s) with NM1*1P.
  • backend/tests/test_reconcile.py — new tests for D6 (typed-column primary path, typed-column beats raw_json fallback, NPI arm fires when both sides populated).
  • backend/tests/test_parse_835.py — new test for _consume_claim_payment NM1*1P extraction.
  • 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*1P extraction — claim with NM1*1P*2*...*XX*1234567890 populates ClaimPayment.service_provider_npi == "1234567890". Multi-CLP with mixed NPIs produces per-segment values correctly.
  • test_parse_835.py: NM1*1P absent — service_provider_npi is None, no exception.
  • test_parse_837.py: NM1*82 extraction — Loop 2420A rendering provider populates Claim.rendering_provider_npi.
  • test_parse_837.py: 837p without NM1*82 — column None.

Unit tests (matcher level)

  • test_reconcile.py: typed-column primary path — Remittance.rendering_provider_npi = "1234567890" and Claim.rendering_provider_npi = "1234567890" counts NPI as a matched key.
  • test_reconcile.py: multi-key fallback — NM1*1P NPI 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 beats raw_json fallback — 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, run backfill-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_npi populated and any retroactive score-auto Match 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_id filter restoration — SP31 documented deviation; needs schema migration or raw_json read.
  • Per-segment NPI display in RemitDrawer's CLP rows — separate SP.