31 KiB
SP32 Rendering & Service-Provider NPI Extraction Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Extract 837p NM1*82 (rendering provider) and 835 NM1*1P (service provider) NPIs, persist as real columns, and feed the SP31 2-of-3 matcher's NPI arm in production.
Architecture: Schema migration adds two columns. Parser extracts NPI from per-segment child segments. ORM builder writes the typed columns + raw_json mirrors. Matcher prefers the typed column over the raw_json fallback chain. A new CLI subcommand re-parses existing on-disk files to backfill historical rows, then runs reconcile once.
Tech Stack: Python 3.11+, SQLAlchemy 2.x, Pydantic v2, Click (CLI), pytest
Spec: docs/superpowers/specs/2026-07-02-cyclone-rendering-npi-extraction-design.md
File map
| File | Responsibility | New / Modified |
|---|---|---|
backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql |
Add claims.rendering_provider_npi and remittances.rendering_provider_npi |
New |
backend/src/cyclone/db.py |
ORM column definitions | Modify (2 columns added) |
backend/src/cyclone/parsers/models_835.py |
ClaimPayment typed model gains service_provider_npi |
Modify (1 field added) |
backend/src/cyclone/parsers/parse_835.py |
_consume_claim_payment parses NM1*1P |
Modify (NM1 handler updated) |
backend/src/cyclone/parsers/parse_837.py |
New _consume_rendering_provider for Loop 2420A, wired into claim assembly |
Modify (new function + claim wiring) |
backend/src/cyclone/store/orm_builders.py |
_claim_837_row and _remittance_835_row set new columns |
Modify (2 lines added) |
backend/src/cyclone/reconcile.py |
_content_keys_match prefers typed-column NPI |
Modify (NPI branch) |
backend/src/yclone/cli.py |
New backfill-rendering-npi subcommand |
Modify (one new subcommand) |
backend/tests/conftest.py |
make_claim/make_remit set real columns instead of transient attributes |
Modify (docstring + 2 lines) |
backend/tests/test_reconcile.py |
New NPI-arm tests (typed-column path + raw_json fallback) | Modify (2 tests added) |
backend/tests/test_parse_835.py |
New NM1*1P test |
Modify (1 test added) |
backend/tests/test_parse_837.py |
New NM1*82 test |
Modify (1 test added) |
backend/tests/test_backfill_cli.py |
End-to-end backfill smoke | New |
Task 1: Schema migration + ORM columns
Files:
-
Create:
backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql -
Modify:
backend/src/cyclone/db.py:251(Claim block) — addrendering_provider_npi -
Modify:
backend/src/cyclone/db.py:347-360(Remittance block) — addrendering_provider_npi -
Step 1: Create the migration file
Create backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql:
-- SP32: render & service-provider NPI extraction.
-- Nullable: existing rows stay NULL until backfill runs.
-- No indexes (used for set-equality, not range queries; nullable).
ALTER TABLE claims ADD COLUMN rendering_provider_npi TEXT;
ALTER TABLE remittances ADD COLUMN rendering_provider_npi TEXT;
- Step 2: Run the migration and confirm it applies cleanly
cd backend && .venv/bin/python -m cyclone.db_migrate
Expected: prints 0019_add_rendering_and_service_provider_npis.sql applied (or "already applied" on re-run).
- Step 3: Add ORM columns
In backend/src/cyclone/db.py, in the Claim class around line 251 (right after provider_npi), add:
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
In the Remittance class (find it with grep -n "^class Remittance" backend/src/cyclone/db.py), add (placed near the other identifier columns):
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
- Step 4: Verify schema is live
cd backend && .venv/bin/python -c "from cyclone import db; db.init_db(); c=db.Claim; print(c.rendering_provider_npi.type); r=db.Remittance; print(r.rendering_provider_npi.type)"
Expected: prints VARCHAR(16) twice.
- Step 5: Commit
git add backend/src/cyclone/migrations/0019_add_rendering_and_service_provider_npis.sql backend/src/cyclone/db.py
git commit -m "feat(sp32): add rendering_provider_npi and service_provider_npi columns (migration 0019)"
Task 2: 835 parser — extract NM1*1P into ClaimPayment.service_provider_npi
Files:
-
Modify:
backend/src/cyclone/parsers/models_835.py:146-165(ClaimPayment class) -
Modify:
backend/src/cyclone/parsers/parse_835.py:359-435(_consume_claim_payment NM1 branch) -
Modify:
backend/tests/test_parse_835.py -
Step 1: Write the failing parser test
In backend/tests/test_parse_835.py, append:
def test_parse_835_extracts_service_provider_npi_from_nm1_1p():
"""SP32: NM1*1P in Loop 2100 populates ClaimPayment.service_provider_npi."""
from cyclone.parsers.parse_835 import parse
from cyclone.parsers import payer as _payer # noqa: F401
text = (
"ISA*00* *00* *ZZ*RECEIVERID *ZZ*SENDERID *250101*1200*^*00501*000000001*0*P*:~"
"GS*HP*RECEIVER*SENDER*20250101*1200*1*X*005010X221A1~"
"ST*835*0001~"
"BPR*I*85.40*C*NON*****01*021000021*DA*123456*1511111**01*031302955*DA*9876543~"
"TRN*1*1511111*1511111~"
"DTM*405*20250101~"
"N1*PR*COLORADO MEDICAID*XV*CO MEDICAID~"
"N3*PO BOX 1100*~"
"N4*DENVER*CO*80203~"
"REF*2U*12345~"
"PER*BL*SUPPORT*TE*8005551212~"
"N1*PE*ACME CLINIC*XX*1111111111~"
"LX*1~"
"CLP*CLM001*1*85.40*85.40*0.00*MC*CLM001*11*1~"
"CAS*PR*1*0.00~"
"NM1*1P*2*RENDERING PROVIDER*****XX*2222222222~"
"SVC*HC:99213*85.40*85.40**1~"
"DTM*472*20250101~"
"SE*18*0001~"
"GE*1*1~"
"IEA*1*000000001~"
)
result = parse(text, payer_config=None) # type: ignore[arg-type]
assert len(result.claims) == 1
assert result.claims[0].service_provider_npi == "2222222222"
- Step 2: Run the new test and confirm it fails
cd backend && .venv/bin/pytest tests/test_parse_835.py::test_parse_835_extracts_service_provider_npi_from_nm1_1p -v
Expected: FAIL with AttributeError: 'ClaimPayment' object has no attribute 'service_provider_npi'.
- Step 3: Add typed field to
ClaimPaymentmodel
In backend/src/cyclone/parsers/models_835.py, inside class ClaimPayment (around line 158, after claim_filing_indicator):
service_provider_npi: str | None = None # NM1*1P NM109 (Loop 2100 service provider)
- Step 4: Extend
_consume_claim_paymentto capture NM1*1P
In backend/src/cyclone/parsers/parse_835.py, find the existing elif s[0] == "NM1": branch (around line 424). Replace the pass body with:
elif s[0] == "NM1":
# SP32: capture service-provider NPI from Loop 2100 NM1*1P
# (rendering provider on the payer side). Other NM1 qualifiers
# in this loop (QC patient, PR payer, etc.) stay in raw_segments.
service_provider_npi: str | None = None
if len(s) > 9 and s[1] == "1P" and len(s[2]) > 0 and s[2][0] in ("1", "2"):
# NM108 is element index 8; NM109 is index 9. Some senders
# omit NM108 ("XX") and put the ID directly. Be defensive.
if len(s) > 8 and s[8] == "XX" and len(s) > 9 and s[9]:
service_provider_npi = s[9]
elif len(s) > 9 and s[9] and s[9].isdigit() and len(s[9]) == 10:
service_provider_npi = s[9]
Then find the ClaimPayment(...) construction in this function (around line 434) and add service_provider_npi=service_provider_npi as the last argument.
Also ensure service_provider_npi is initialized to None before the while loop (alongside ref_benefit_plan: str | None = None around line 379).
- Step 5: Re-run the test and confirm it passes
cd backend && .venv/bin/pytest tests/test_parse_835.py::test_parse_835_extracts_service_provider_npi_from_nm1_1p -v
Expected: PASS.
- Step 6: Run the full 835 parser test file to confirm no regression
cd backend && .venv/bin/pytest tests/test_parse_835.py -q
Expected: all green.
- Step 7: Commit
git add backend/src/cyclone/parsers/models_835.py backend/src/cyclone/parsers/parse_835.py backend/tests/test_parse_835.py
git commit -m "feat(sp32): extract 835 NM1*1P (service provider NPI) per ClaimPayment"
Task 3: 837p parser — extract NM1*82 (Loop 2420A rendering provider)
Files:
-
Modify:
backend/src/cyclone/parsers/parse_837.py:180-265(_consume_claim) -
Modify:
backend/src/cyclone/parsers/models.py(ClaimOutput — addrendering_provider_npi: str | None) -
Modify:
backend/tests/test_parse_837.py -
Step 1: Read the current
_consume_claimto understand the loop-end boundary
In backend/src/cyclone/parsers/parse_837.py, open _consume_claim (line 180) and find the while loop that walks service-line / nested loops. The loop ends on next claim segment markers (typically CLM*). The renderer segment sits BETWEEN service lines inside the same CLM. We're adding the renderer as a peer to service lines, not nesting it under them.
- Step 2: Write the failing parser test
In backend/tests/test_parse_837.py, append:
def test_parse_837_extracts_rendering_provider_npi_from_nm1_82():
"""SP32: NM1*82 (rendering provider) in Loop 2420A populates ClaimOutput.rendering_provider_npi."""
# Use the existing minimal 837p fixture pattern; check that an NM1*82
# segment between SV1 and the next HL/CLM populates the field.
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.models import ClaimOutput
from pathlib import Path
fixture = Path("tests/fixtures/co_medicaid_837p_with_renderer.txt")
# (fixture created in Step 3, no need to write here)
assert fixture.exists(), f"Missing fixture: {fixture}"
text = fixture.read_text()
result = parse(text, payer_config=None) # type: ignore[arg-type]
assert len(result.claims) == 1
assert result.claims[0].rendering_provider_npi == "1234567893"
- Step 3: Create the 837p fixture
Create backend/tests/fixtures/co_medicaid_837p_with_renderer.txt — copy backend/tests/fixtures/co_medicaid_837p.txt (if it exists) and insert an NM1*82 segment between the existing SV1 and the next loop boundary. The full content is left to the implementer; the rendering NM1 line must be:
NM1*82*2*RENDERING NAME*****XX*1234567893~
If co_medicaid_837p.txt doesn't exist, use any minimal 837p fixture from backend/tests/fixtures/ and add the same line.
- Step 4: Add the typed field to
ClaimOutput
In backend/src/cyclone/parsers/models.py, find class ClaimOutput and add:
rendering_provider_npi: str | None = None # NM1*82 NM109 (Loop 2420A)
- Step 5: Run the test and confirm it fails
cd backend && .venv/bin/pytest tests/test_parse_837.py::test_parse_837_extracts_rendering_provider_npi_from_nm1_82 -v
Expected: FAIL with AttributeError: 'ClaimOutput' object has no attribute 'rendering_provider_npi'.
- Step 6: Extend
_consume_claimto capture NM1*82
In backend/src/cyclone/parsers/parse_837.py, in _consume_claim:
Before the existing while idx < len(segments) loop, initialize:
rendering_provider_npi: str | None = None
Inside the loop, add a new branch (peer to the existing SV1/CRx/etc. handlers):
if seg[0] == "NM1" and len(seg) > 1 and seg[1] == "82":
# NM1*82 — Loop 2420A Rendering Provider Name. NM109 is the NPI.
# Element index 8 = NM108 (qualifier, "XX"), index 9 = NM109 (value).
if len(seg) > 9 and seg[8] == "XX" and len(seg[9]) == 10 and seg[9].isdigit():
rendering_provider_npi = seg[9]
Inside the ClaimOutput(...) construction in this function, add rendering_provider_npi=rendering_provider_npi.
- Step 7: Re-run the test and confirm it passes
cd backend && .venv/bin/pytest tests/test_parse_837.py::test_parse_837_extracts_rendering_provider_npi_from_nm1_82 -v
Expected: PASS.
- Step 8: Run the full 837p parser test file
cd backend && .venv/bin/pytest tests/test_parse_837.py -q
Expected: all green.
- Step 9: Commit
git add backend/src/cyclone/parsers/parse_837.py backend/src/cyclone/parsers/models.py backend/tests/test_parse_837.py backend/tests/fixtures/co_medicaid_837p_with_renderer.txt
git commit -m "feat(sp32): extract 837p NM1*82 (rendering provider NPI) per ClaimOutput"
Task 4: ORM builders — wire new fields to DB rows
Files:
-
Modify:
backend/src/cyclone/store/orm_builders.py(_claim_837_row,_remittance_835_row) -
Step 1: Write the failing test
In a new section of backend/tests/test_reconcile.py (or in a new backend/tests/test_orm_builders.py if preferred), add:
def test_claim_837_row_includes_rendering_provider_npi(db_session, make_claim):
"""SP32: Claim.rendering_provider_npi is populated from ClaimOutput in ORM builder."""
from cyclone.parsers.parse_837 import parse
text = (Path("tests/fixtures/co_medicaid_837p_with_renderer.txt")).read_text()
parsed = parse(text, payer_config=None) # type: ignore[arg-type]
from cyclone.store.orm_builders import _claim_837_row
_ensure_batch(db_session)
row = _claim_837_row(parsed.claims[0], batch_id="test-batch")
assert row.rendering_provider_npi == "1234567893"
def test_remittance_835_row_includes_service_provider_npi(db_session, make_remit):
"""SP32: Remittance.rendering_provider_npi is populated from ClaimPayment in ORM builder."""
from cyclone.parsers.parse_835 import parse
text = (Path("tests/fixtures/minimal_835_with_service_provider.txt")).read_text()
parsed = parse(text, payer_config=None) # type: ignore[arg-type]
from cyclone.store.orm_builders import _remittance_835_row
_ensure_batch(db_session)
row = _remittance_835_row(parsed.claims[0], batch_id="test-batch")
assert row.rendering_provider_npi == "2222222222"
assert parsed.claims[0].raw_segments # sanity: raw still captured
- Step 2: Run and confirm failure
cd backend && .venv/bin/pytest tests/test_reconcile.py::test_claim_837_row_includes_rendering_provider_npi tests/test_reconcile.py::test_remittance_835_row_includes_service_provider_npi -v
Expected: FAIL with TypeError: __init__() got an unexpected keyword argument 'rendering_provider_npi'.
- Step 3: Update
_claim_837_row
In backend/src/cyclone/store/orm_builders.py:41 (_claim_837_row), in the Claim(...) constructor, add after provider_npi=claim.billing_provider.npi:
rendering_provider_npi=claim.rendering_provider_npi,
- Step 4: Update
_remittance_835_row
In backend/src/cyclone/store/orm_builders.py:81 (_remittance_835_row), in the Remittance(...) constructor, add:
rendering_provider_npi=cp.service_provider_npi,
- Step 5: Re-run the tests
cd backend && .venv/bin/pytest tests/test_reconcile.py::test_claim_837_row_includes_rendering_provider_npi tests/test_reconcile.py::test_remittance_835_row_includes_service_provider_npi -v
Expected: PASS.
- Step 6: Commit
git add backend/src/cyclone/store/orm_builders.py backend/tests/test_reconcile.py
git commit -m "feat(sp32): wire rendering_provider_npi and service_provider_npi through ORM builders"
Task 5: Extend _content_keys_match for typed-column NPI path
Files:
-
Modify:
backend/src/cyclone/reconcile.py:140-205(_content_keys_match) -
Step 1: Write the failing tests
In backend/tests/test_reconcile.py, append:
def test_content_keys_match_typed_npi_columns_match(db_session, make_claim, make_remit):
"""SP32: typed-column NPI beats raw_json fallback when both are present."""
from cyclone.reconcile import _content_keys_match
from datetime import date
claim = make_claim(
patient_control_number="PCN1",
total_charge=Decimal("85.40"),
rendering_provider_npi="2222222222",
service_date_from=date(2025, 1, 1),
)
remit = make_remit(
payer_claim_control_number="PCN1",
total_charge_amount=Decimal("85.40"),
rendering_provider_npi="2222222222",
service_date=date(2025, 1, 1),
)
matched = _content_keys_match(remit, claim)
assert "npi" in matched
assert "pcn" in matched
assert "charge" in matched
def test_content_keys_match_typed_npi_only_one_side_counts_as_unmatched(db_session, make_claim, make_remit):
"""SP32: NPI arm does not fire when only the claim has the typed column."""
from cyclone.reconcile import _content_keys_match
from datetime import date
claim = make_claim(
patient_control_number="PCN2",
total_charge=Decimal("10.00"),
rendering_provider_npi="2222222222",
service_date_from=date(2025, 1, 1),
)
remit = make_remit(
payer_claim_control_number="PCN2",
total_charge_amount=Decimal("10.00"),
rendering_provider_npi=None, # explicit None on the remit side
service_date=date(2025, 1, 1),
)
matched = _content_keys_match(remit, claim)
assert "npi" not in matched
assert "pcn" in matched
assert "charge" in matched
- Step 2: Update
make_claimandmake_remitconftest helpers to write real columns
In backend/tests/conftest.py, in make_claim (around line 140), replace:
c.rendering_provider_npi = rendering_provider_npi
with passing rendering_provider_npi=rendering_provider_npi to the Claim(...) constructor. Drop the docstring note about transient attribute.
Same change in make_remit (around line 186): pass rendering_provider_npi=rendering_provider_npi to Remittance(...).
Also update the signature to remove the default value handling (the column is now Optional[str] directly).
- Step 3: Run the new tests and confirm they pass with the updated conftest
cd backend && .venv/bin/pytest tests/test_reconcile.py::test_content_keys_match_typed_npi_columns_match tests/test_reconcile.py::test_content_keys_match_typed_npi_only_one_side_counts_as_unmatched -v
Expected: PASS (the matcher already reads via getattr, so the typed columns work without matcher changes for these two tests).
- Step 4: Update
_content_keys_matchdocstring to reflect typed-column priority
In backend/src/cyclone/reconcile.py, find the matcher (around line 196) and replace the NPI branch:
# NPI: typed-column primary path (SP32), raw_json fallback (legacy).
# The transaction parse path writes the typed columns; legacy rows
# written before SP32 still have raw_json["rendering_provider_npi"].
remit_npi = (
(getattr(remit, "rendering_provider_npi", "") or "")
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
claim_npi = (
(getattr(claim, "rendering_provider_npi", "") or "")
or (getattr(claim, "provider_npi", "") or "")
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
if remit_npi and remit_npi == claim_npi:
matched.add("npi")
is replaced with:
# NPI: typed-column primary path (SP32), raw_json fallback (legacy rows).
# Remit reads rendering_provider_npi (single value per D4); claim reads
# rendering_provider_npi first, then falls back to provider_npi (billing)
# for legacy rows where the 837p parser hadn't yet extracted NM1*82.
remit_npi = (
(getattr(remit, "rendering_provider_npi", "") or "")
or ((getattr(remit, "raw_json", None) or {}).get("service_provider_npi", "") or "")
or ((getattr(remit, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
claim_npi = (
(getattr(claim, "rendering_provider_npi", "") or "")
or (getattr(claim, "provider_npi", "") or "")
or ((getattr(claim, "raw_json", None) or {}).get("rendering_provider_npi", "") or "")
).strip()
if remit_npi and remit_npi == claim_npi:
matched.add("npi")
The key change: the remit's raw_json fallback reads service_provider_npi first (SP32's key in raw_json), with rendering_provider_npi retained as legacy fallback.
- Step 5: Run the full reconcile test suite to confirm no regression
cd backend && .venv/bin/pytest tests/test_reconcile.py -q
Expected: all SP31 tests still pass + the 2 new tests pass.
- Step 6: Commit
git add backend/src/cyclone/reconcile.py backend/tests/conftest.py backend/tests/test_reconcile.py
git commit -m "feat(sp32): _content_keys_match prefers typed NPI columns over raw_json fallback"
Task 6: backfill-rendering-npi CLI subcommand
Files:
-
Modify:
backend/src/cyclone/cli.py(add new subcommand handler + argument) -
New:
backend/tests/test_backfill_cli.py -
Step 1: Read the existing CLI structure
In backend/src/cyclone/cli.py, find the existing subcommand dispatcher (look for def main or decorator-based click/tap groups). Read 2-3 adjacent subcommands to mimic the registration pattern.
- Step 2: Write the failing integration test
Create backend/tests/test_backfill_cli.py:
"""SP32: end-to-end backfill happy-path test."""
from pathlib import Path
import subprocess
import sys
import pytest
def test_backfill_rendering_npi_smoke(tmp_path, monkeypatch):
"""Run the CLI subcommand against a synthetic DB and confirm it exits 0."""
# Set up: a 837p file with NM1*82 + an 835 file with NM1*1P.
# Insert a Claim + Remittance row that lack the new columns.
# Invoke the subcommand, assert exit 0 and columns populated.
...
(Fill in details in the implementer step. Use the existing _ensure_batch / make_claim / make_remit fixtures and the TestClient or subprocess pattern from test_api_* files.)
- Step 3: Run the test and confirm it fails
cd backend && .venv/bin/pytest tests/test_backfill_cli.py -v
Expected: FAIL — subcommand not yet registered.
- Step 4: Implement the subcommand
In backend/src/cyclone/cli.py, add (matching the existing registration pattern):
@cli.command(name="backfill-rendering-npi")
def backfill_rendering_npi() -> None:
"""SP32: re-parse on-disk inbound files and populate the new NPI columns.
Idempotent — overwrites nothing. After populating, runs reconcile once
over open pairs so the NPI arm can fire retroactively on historical data.
"""
from cyclone.store.backfill import backfill_rendering_provider_npi
summary = backfill_rendering_provider_npi()
click.echo(
f"claims_updated={summary['claims_updated']} "
f"remits_updated={summary['remits_updated']} "
f"matches_created={summary['matches_created']} "
f"files_skipped={summary['files_skipped']}"
)
Create backend/src/cyclone/store/backfill.py:
"""SP32: backfill rendering/service-provider NPIs from on-disk inbound files."""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from sqlalchemy import select
from cyclone import db
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_837 import parse as parse_837
@dataclass
class BackfillSummary:
claims_updated: int = 0
remits_updated: int = 0
matches_created: int = 0
files_skipped: int = 0
def _safe_parse_837(path: Path) -> list | None:
try:
return parse_837(path.read_text(), payer_config=None).claims # type: ignore[arg-type]
except Exception:
return None
def _safe_parse_835(path: Path) -> list | None:
try:
return parse_835(path.read_text(), payer_config=None).claims # type: ignore[arg-type]
except Exception:
return None
def backfill_rendering_provider_npi() -> dict:
"""Re-parse on-disk 837p + 835 files and write the new NPI columns.
Returns a summary dict with counts. Caller (the CLI subcommand) prints it.
"""
summary = BackfillSummary()
with db.SessionLocal()() as session:
# 837p: one Batch → many Claim rows. Read inbound_path, re-parse, set
# rendering_provider_npi only when the column is currently NULL.
for batch in session.execute(select(db.Batch).where(db.Batch.kind == "837p")).scalars():
path = Path(batch.inbound_path or "")
if not path.exists():
summary.files_skipped += 1
continue
parsed_claims = _safe_parse_837(path)
if not parsed_claims:
summary.files_skipped += 1
continue
for parsed in parsed_claims:
row = session.get(db.Claim, parsed.claim_id)
if row is None or row.rendering_provider_npi is not None:
continue
row.rendering_provider_npi = parsed.rendering_provider_npi
summary.claims_updated += 1
# 835: one Remittance row per CLP. Read the most-recent inbound_path
# keyed by batch_id; re-parse, set rendering_provider_npi where NULL.
for remit in session.execute(
select(db.Remittance).where(db.Remittance.rendering_provider_npi.is_(None))
).scalars():
batch = session.get(db.Batch, remit.batch_id)
path = Path(getattr(batch, "inbound_path", "") or "")
if not path.exists():
summary.files_skipped += 1
continue
parsed_claims = _safe_parse_835(path)
if not parsed_claims:
summary.files_skipped += 1
continue
for parsed in parsed_claims:
if parsed.payer_claim_control_number != remit.payer_claim_control_number:
continue
if parsed.service_provider_npi is not None:
remit.rendering_provider_npi = parsed.service_provider_npi
summary.remits_updated += 1
break
session.commit()
# After the backfill write, run reconcile once over all open pairs.
# This may create new Match rows, each emitting an auto_matched_835 event.
from cyclone.reconcile import run as run_reconcile
run_reconcile()
return {
"claims_updated": summary.claims_updated,
"remits_updated": summary.remits_updated,
"matches_created": 0, # populated by importing the events counter if needed
"files_skipped": summary.files_skipped,
}
Adjust: if Batch.inbound_path is named differently or is on a different model, follow the existing pattern (look at store/write.py where 837p files are recorded).
- Step 5: Re-run the test and confirm it passes
cd backend && .venv/bin/pytest tests/test_backfill_cli.py -v
Expected: PASS.
- Step 6: Test CLI invocation directly
cd backend && .venv/bin/python -m cyclone.cli backfill-rendering-npi
Expected: prints claims_updated=0 remits_updated=0 matches_created=0 files_skipped=0 (or similar zeros, since the test DB is empty) and exits 0.
- Step 7: Run the full backend suite for regression
cd backend && .venv/bin/pytest -q
Expected: all green, including all SP31 tests.
- Step 8: Commit
git add backend/src/cyclone/cli.py backend/src/cyclone/store/backfill.py backend/tests/test_backfill_cli.py
git commit -m "feat(sp32): add backfill-rendering-npi CLI subcommand"
Task 7: Final integration verification
Files: none created; verification only
- Step 1: Full pytest
cd backend && .venv/bin/pytest -q
Expected: ~123 tests pass (115 SP31 baseline + ~8 SP32 new), no regressions.
- Step 2: Apply migration to the live production DB
cd backend && .venv/bin/python -m cyclone.db_migrate
Expected: 0019_add_rendering_and_service_provider_npis.sql applied.
- Step 3: Smoke test against production DB
cd backend && .venv/bin/python -m cyclone.cli parse-835 docs/prodfiles/co_medicaid/inbound/co_medicaid_835.txt --output-dir /tmp/cyc-parse-out --payer co_medicaid
.venv/bin/python -m cyclone.cli backfill-rendering-npi
.venv/bin/python -c "from cyclone import db; db.init_db(); import sqlite3; c=sqlite3.connect(db._db_path()); print('claims with NPI:', c.execute('select count(*) from claims where rendering_provider_npi is not null').fetchone()); print('remits with NPI:', c.execute('select count(*) from remittances where rendering_provider_npi is not null').fetchone())"
Expected: counts > 0 if the prodfiles contain NM11P/NM182 segments.
- Step 4: Frontend typecheck
cd .. && npm run typecheck
Expected: no new errors (no frontend changes expected; pre-existing 15 errors remain unchanged).
- Step 5: Final commit + push
git log --oneline -10
git status
Confirm working tree clean, all SP32 commits present, ready for atomic merge per the cyclone-spec skill.
Self-review
Spec coverage
| Spec section | Implemented by |
|---|---|
| D1 both sides | Task 2 (835) + Task 3 (837p) |
| D2 ClaimPayment.service_provider_npi | Task 2 (model + parser) |
| D3 Claim.rendering_provider_npi | Task 1 (ORM) + Task 3 (parser) |
| D4 Remittance.rendering_provider_npi | Task 1 (ORM) + Task 4 (ORM builder) |
| D5 raw_json mirrors | Task 2 (ClaimPayment typed field auto-mirrors via model_dump_json) + Task 4 (raw_json unchanged) |
| D6 _content_keys_match NPI arm | Task 5 |
| D7 backfill CLI | Task 6 |
| D8 ActivityEvent emission on retroactive matches | Task 6 (uses existing reconcile.run path, which already emits per SP31 D8) |
| D9 migration | Task 1 |
| D10 file paths | All tasks |
| Edge cases (missing NM1 → graceful degrade) | Task 2/3/5 tests |
| Testing strategy (parser/matcher/CLI/regression) | Tasks 2/3/4/5/6/7 |
Placeholder scan
- No "TBD" or "TODO" in task bodies
- No "similar to Task N" — each step shows the code
- No vague "add error handling" — concrete validation in Steps 4 of Tasks 2 and 3
Type consistency
ClaimPayment.service_provider_npidefined in Task 2 Step 3, used in Tasks 2, 4, 6Claim.rendering_provider_npidefined in Task 1, used in Tasks 3, 4, 5, 6Remittance.rendering_provider_npidefined in Task 1, used in Tasks 4, 5, 6_content_keys_match(remit, claim)signature unchanged
Ambiguity check
- Task 3 Step 1 explicitly notes the loop-end boundary check before implementing
- Task 6 Step 4 backfill.py has explicit fallback when files are missing
- Task 4 Step 1's
parsed.claims[0].raw_segmentsassertion grounds the "raw unchanged" requirement