# SP31 — 835 Strict Content-Match Auto-Link 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:** Add a deterministic "any 2 of {PCN, charge, NPI}" content-keys fallback to the 835 auto-match path so more remits link to claims at parse time and the Recent Batches widget reflects settled state immediately. **Architecture:** Keep the existing in-memory `reconcile.match()` PCN-exact path as the fast primary. Add a new DB-driven `reconcile._score_fallback_candidates()` that runs **after** `match()` on the leftover unmatched remits, queries a ±30-day candidate pool, applies the 2-of-3 rule, and on a unique hit returns a `Match(strategy="score-auto")` for the same orchestrator loop to consume. Rename existing `strategy="auto"` → `"pcn-exact"` for audit clarity. **Tech Stack:** Python 3.11+, SQLAlchemy 2.x (sync session), pytest. No new dependencies. No schema migration. No frontend changes (widget already reflects state via SP30). **Spec:** `docs/superpowers/specs/2026-07-02-cyclone-835-strict-content-match-design.md` --- ## File Structure | File | Change | Responsibility | |---|---|---| | `backend/src/cyclone/reconcile.py` | Modify | Rename `strategy="auto"` → `"pcn-exact"`. Add `_content_keys_match` pure helper. Add `_score_fallback_candidates` DB helper. Integrate fallback into `run()`. | | `backend/tests/test_reconcile.py` | Modify | Update 3 existing assertions from `"auto"` → `"pcn-exact"`. Add 11 new tests for the helpers + integration. | | `src/types/index.ts` | Modify | Widen the `Match.strategy` union from `"auto" \| "manual"` to `"pcn-exact" \| "score-auto" \| "manual"` (2 sites). | --- ## Task 1: Rename `strategy="auto"` → `strategy="pcn-exact"` everywhere **Files:** - Modify: `backend/src/cyclone/reconcile.py:87` - Modify: `backend/tests/test_reconcile.py:58, 206, 309` - Modify: `backend/tests/test_db_models.py:323, 338, 364` - Modify: `src/types/index.ts:458, 510` - [ ] **Step 1: Update the production code** In `backend/src/cyclone/reconcile.py`, line 87, change: ```python strategy="auto", ``` to: ```python strategy="pcn-exact", ``` - [ ] **Step 2: Update the backend test assertions** In `backend/tests/test_reconcile.py`: - Line 58: change `assert matches[0].strategy == "auto"` → `assert matches[0].strategy == "pcn-exact"` - Line 206: change `strategy="auto"` → `strategy="pcn-exact"` - Line 309: change `strategy="auto"` → `strategy="pcn-exact"` In `backend/tests/test_db_models.py`: - Line 323: change `strategy="auto"` → `strategy="pcn-exact"` - Line 338: change `assert loaded.strategy == "auto"` → `assert loaded.strategy == "pcn-exact"` - Line 364: change `strategy="auto"` → `strategy="pcn-exact"` - [ ] **Step 3: Widen the frontend TypeScript union** In `src/types/index.ts`, line 458, change: ```typescript strategy: "auto" | "manual"; ``` to: ```typescript strategy: "pcn-exact" | "score-auto" | "manual"; ``` Line 510, change: ```typescript match: { id: number; strategy: "auto" | "manual" }; ``` to: ```typescript match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" }; ``` - [ ] **Step 4: Run the test suite to confirm nothing broke** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v ``` Expected: all green. The `"auto"` string is gone from production code and tests. - [ ] **Step 5: Commit** ```bash git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/test_db_models.py src/types/index.ts git commit -m "refactor(sp31): rename Match strategy 'auto' to 'pcn-exact' for audit clarity" ``` --- ## Task 2: Add `_content_keys_match` pure helper (TDD) **Files:** - Modify: `backend/src/cyclone/reconcile.py` (append after `_pick_claim`) - Modify: `backend/tests/test_reconcile.py` (append new test cases) - [ ] **Step 1: Write the failing tests** Add the following block at the end of `backend/tests/test_reconcile.py`: ```python # --- SP31: content-keys fallback matcher (pure helper) ----------------------- def _make_remit_for_keys(remit_id: str, pcn: str, charge, npi: str): """Tiny shim with the three fields _content_keys_match reads.""" from cyclone.reconcile import _RemitLike # protocol not enforced at runtime return type("R", (), { "id": remit_id, "payer_claim_control_number": pcn, "total_charge_amount": charge, "rendering_provider_npi": npi, })() def _make_claim_for_keys(claim_id: str, pcn: str, charge, npi: str): return type("C", (), { "id": claim_id, "patient_control_number": pcn, "total_charge": charge, "rendering_provider_npi": npi, })() def test_content_keys_pcn_plus_charge_matches(): """PCN + charge match (NPI mismatched) → True.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c) is True def test_content_keys_pcn_plus_npi_matches(): """PCN + NPI match (charge mismatched) → True.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "1111111111") assert _content_keys_match(r, c) is True def test_content_keys_charge_plus_npi_matches(): """Charge + NPI match (PCN mismatched) → True.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c) is True def test_content_keys_all_three_match(): """All 3 match → True.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c) is True def test_content_keys_only_pcn_does_not_match(): """Only PCN matches (charge + NPI both wrong) → False.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("200.00"), "2222222222") assert _content_keys_match(r, c) is False def test_content_keys_only_charge_does_not_match(): """Only charge matches (PCN + NPI both wrong) → False.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "XYZ", Decimal("100.00"), "2222222222") assert _content_keys_match(r, c) is False def test_content_keys_none_match(): """All 3 fields disagree → False.""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "1111111111") c = _make_claim_for_keys("c1", "XYZ", Decimal("200.00"), "2222222222") assert _content_keys_match(r, c) is False def test_content_keys_charge_tolerance_is_one_cent(): """Charge differs by exactly $0.005 (rounding) → True ($0.01 tolerance).""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.005"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c) is True def test_content_keys_charge_differs_by_two_cents_no_match(): """Charge differs by $0.02 → False (outside tolerance).""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.02"), "1111111111") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c) is False def test_content_keys_empty_npi_does_not_count(): """Empty NPI on remit counts as not-matched (falls back to PCN+charge rule).""" from cyclone.reconcile import _content_keys_match from decimal import Decimal r = _make_remit_for_keys("r1", "ABC", Decimal("100.00"), "") c = _make_claim_for_keys("c1", "ABC", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c) is True # PCN + charge still match c2 = _make_claim_for_keys("c2", "XYZ", Decimal("100.00"), "1111111111") assert _content_keys_match(r, c2) is False # only charge, no PCN ``` - [ ] **Step 2: Run the tests to verify they all FAIL with ImportError** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v ``` Expected: 10 failures, all `ImportError: cannot import name '_content_keys_match'`. - [ ] **Step 3: Implement `_content_keys_match` in `reconcile.py`** In `backend/src/cyclone/reconcile.py`, add the following function after `_pick_claim` (around line 118): ```python # --- SP31: content-keys fallback matcher ----------------------------------- CHARGE_TOLERANCE = Decimal("0.01") KEYS_REQUIRED = 2 def _content_keys_match(remit, claim) -> bool: """Return True when at least KEYS_REQUIRED of {PCN, charge, NPI} agree. Charge compared with ``CHARGE_TOLERANCE`` tolerance (rounding drift). NPI counts as "not matched" when the remit's NPI is empty. """ keys_matched = 0 # PCN: payer_claim_control_number ↔ patient_control_number (stripped). if (getattr(remit, "payer_claim_control_number", "") or "").strip() == \ (getattr(claim, "patient_control_number", "") or "").strip(): keys_matched += 1 # Charge: total_charge_amount ↔ total_charge (within $0.01). remit_charge = getattr(remit, "total_charge_amount", None) claim_charge = getattr(claim, "total_charge", None) if remit_charge is not None and claim_charge is not None and \ abs(remit_charge - claim_charge) < CHARGE_TOLERANCE: keys_matched += 1 # NPI: rendering_provider_npi ↔ rendering_provider_npi (must be non-empty). remit_npi = (getattr(remit, "rendering_provider_npi", "") or "").strip() claim_npi = (getattr(claim, "rendering_provider_npi", "") or "").strip() if remit_npi and remit_npi == claim_npi: keys_matched += 1 return keys_matched >= KEYS_REQUIRED ``` - [ ] **Step 4: Run the tests to verify they all PASS** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v ``` Expected: 10 passed. - [ ] **Step 5: Commit** ```bash git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py git commit -m "feat(sp31): add _content_keys_match pure helper (2-of-3 PCN/charge/NPI rule)" ``` --- ## Task 3: Add `_score_fallback_candidates` DB helper (TDD) **Files:** - Modify: `backend/src/cyclone/reconcile.py` (append after `_content_keys_match`) - Modify: `backend/tests/test_reconcile.py` (append integration tests) - [ ] **Step 1: Write the failing integration tests** Add to the end of `backend/tests/test_reconcile.py`: ```python # --- SP31: content-keys fallback matcher (DB helper) ------------------------ def test_score_fallback_unique_match_returns_claim_id(db_session, make_claim, make_remit): """One claim in pool matches 2-of-3 → returns that claim.""" from decimal import Decimal from datetime import date, timedelta from cyclone.reconcile import _score_fallback_candidates c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=date(2026, 6, 1)) c2 = make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"), rendering_provider_npi="2222222222", service_date_from=date(2026, 6, 1)) r = make_remit(payer_claim_control_number="ABC", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) # ±30-day window assert _score_fallback_candidates(db_session, r) == c1.id def test_score_fallback_zero_matches_returns_none(db_session, make_claim, make_remit): """No claim in pool matches 2-of-3 → returns None.""" from decimal import Decimal from datetime import date from cyclone.reconcile import _score_fallback_candidates make_claim(patient_control_number="XYZ", total_charge=Decimal("999.99"), rendering_provider_npi="2222222222", service_date_from=date(2026, 6, 1)) r = make_remit(payer_claim_control_number="ABC", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) assert _score_fallback_candidates(db_session, r) is None def test_score_fallback_two_matches_returns_none(db_session, make_claim, make_remit): """Two claims in pool both match 2-of-3 → returns None (ambiguous).""" from decimal import Decimal from datetime import date from cyclone.reconcile import _score_fallback_candidates # Both claims have same PCN and same charge — both would match PCN+charge. make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=date(2026, 6, 1)) make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=date(2026, 6, 2)) r = make_remit(payer_claim_control_number="ABC", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) assert _score_fallback_candidates(db_session, r) is None def test_score_fallback_skips_already_matched(db_session, make_claim, make_remit): """Claim with matched_remittance_id set → excluded from pool.""" from decimal import Decimal from datetime import date from cyclone.reconcile import _score_fallback_candidates c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=date(2026, 6, 1), matched_remittance_id="some-other-remit-id") r = make_remit(payer_claim_control_number="ABC", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) assert _score_fallback_candidates(db_session, r) is None # only candidate was excluded def test_score_fallback_skips_terminal_state(db_session, make_claim, make_remit): """Claim in PAID/DENIED/REJECTED/REVERSED/RECONCILED → excluded from pool.""" from decimal import Decimal from datetime import date from cyclone.db import ClaimState from cyclone.reconcile import _score_fallback_candidates make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=date(2026, 6, 1), state=ClaimState.PAID) r = make_remit(payer_claim_control_number="ABC", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) assert _score_fallback_candidates(db_session, r) is None def test_score_fallback_widened_window_catches_late_remit(db_session, make_claim, make_remit): """Remit 25 days after claim → still in pool (30-day window).""" from decimal import Decimal from datetime import date, timedelta from cyclone.reconcile import _score_fallback_candidates claim_date = date(2026, 6, 1) remit_date = claim_date + timedelta(days=25) c1 = make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=claim_date) r = make_remit(payer_claim_control_number="ABC", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=remit_date) assert _score_fallback_candidates(db_session, r) == c1.id def test_score_fallback_outside_window_excluded(db_session, make_claim, make_remit): """Remit 60 days after claim → excluded (outside 30-day window).""" from decimal import Decimal from datetime import date, timedelta from cyclone.reconcile import _score_fallback_candidates claim_date = date(2026, 6, 1) remit_date = claim_date + timedelta(days=60) make_claim(patient_control_number="ABC", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=claim_date) r = make_remit(payer_claim_control_number="ABC", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=remit_date) assert _score_fallback_candidates(db_session, r) is None ``` - [ ] **Step 2: Add the `make_claim` / `make_remit` fixtures to `conftest.py`** Open `backend/tests/conftest.py` and add (or extend) the fixtures. If they already exist with these names, adjust the existing fixtures instead: ```python import pytest from decimal import Decimal from datetime import date from cyclone import db from cyclone.db import Claim, Remittance, ClaimState @pytest.fixture def make_claim(db_session): def _make(patient_control_number: str, total_charge: Decimal, rendering_provider_npi: str, service_date_from: date, matched_remittance_id: str | None = None, state: ClaimState = ClaimState.SUBMITTED, claim_id: str | None = None): claim_id = claim_id or f"clm-{patient_control_number}-{service_date_from.isoformat()}" c = Claim( id=claim_id, patient_control_number=patient_control_number, total_charge=total_charge, rendering_provider_npi=rendering_provider_npi, service_date_from=service_date_from, matched_remittance_id=matched_remittance_id, state=state, ) db_session.add(c) db_session.flush() return c return _make @pytest.fixture def make_remit(db_session): def _make(payer_claim_control_number: str, total_charge_amount: Decimal, rendering_provider_npi: str, service_date: date, remit_id: str | None = None): remit_id = remit_id or f"remit-{payer_claim_control_number}-{service_date.isoformat()}" r = Remittance( id=remit_id, payer_claim_control_number=payer_claim_control_number, total_charge_amount=total_charge_amount, rendering_provider_npi=rendering_provider_npi, service_date=service_date, ) db_session.add(r) db_session.flush() return r return _make ``` If `Claim` / `Remittance` constructors don't accept these exact keyword args, check `backend/src/cyclone/db.py` for the actual field names and adjust. The plan assumes the ORM models expose these fields; the existing `reconcile.match()` already reads `matched_remittance_id`, `patient_control_number`, `service_date_from` from `Claim` so they definitely exist. - [ ] **Step 3: Run the tests to verify they all FAIL with ImportError** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v ``` Expected: 7 failures, all `ImportError: cannot import name '_score_fallback_candidates'`. - [ ] **Step 4: Implement `_score_fallback_candidates` in `reconcile.py`** In `backend/src/cyclone/reconcile.py`, add the following function after `_content_keys_match`: ```python # --- SP31: DB-side fallback candidate matcher ------------------------------- SCORE_FALLBACK_WINDOW_DAYS = 30 def _score_fallback_candidates(session, remit) -> Optional[str]: """Return a unique matching claim_id via the 2-of-3 content-keys rule. Queries a ±SCORE_FALLBACK_WINDOW_DAYS candidate pool filtered by: - claim.matched_remittance_id IS NULL (not yet linked) - remit.claim_id IS NULL on the matched pair (enforced by caller; this helper assumes the caller has already filtered on remit's own claim_id) - claim.state NOT IN terminal set (PAID, DENIED, REJECTED, REVERSED, RECONCILED) - claim.service_date_from within window of remit.service_date - claim.payer_id == remit.payer_id (same payer) Returns the unique claim.id when exactly one candidate passes the 2-of-3 rule, otherwise None. Ambiguous matches (2+ candidates) also return None — they land in the Inbox Unlinked lane for manual review. """ from sqlalchemy import select, and_, or_ from datetime import timedelta from cyclone.db import Claim, Remittance, ClaimState TERMINAL = { ClaimState.PAID, ClaimState.DENIED, ClaimState.REJECTED, ClaimState.REVERSED, ClaimState.RECONCILED, } if remit.service_date is None: return None # need a date for the window query window_lo = remit.service_date - timedelta(days=SCORE_FALLBACK_WINDOW_DAYS) window_hi = remit.service_date + timedelta(days=SCORE_FALLBACK_WINDOW_DAYS) candidates = list(session.execute( select(Claim).where( and_( Claim.matched_remittance_id.is_(None), Claim.service_date_from >= window_lo, Claim.service_date_from <= window_hi, Claim.state.notin_([s.value for s in TERMINAL]), Claim.payer_id == remit.payer_id, ) ) ).scalars().all()) matched_ids = [c.id for c in candidates if _content_keys_match(remit, c)] if len(matched_ids) == 1: return matched_ids[0] return None # 0 matches OR 2+ (ambiguous) ``` If `Claim.payer_id` or `Remittance.payer_id` aren't the actual column names, check `db.py` and adjust the filter. The plan assumes these exist (the SP30 widget already groups claims by payer). - [ ] **Step 5: Run the tests to verify they all PASS** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v ``` Expected: 7 passed. - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py backend/tests/conftest.py git commit -m "feat(sp31): add _score_fallback_candidates DB helper with ±30-day candidate pool" ``` --- ## Task 4: Integrate content-keys fallback into `reconcile.run()` (TDD) **Files:** - Modify: `backend/src/cyclone/reconcile.py` (`run` function) - Modify: `backend/tests/test_reconcile.py` (append end-to-end tests) - [ ] **Step 1: Write the failing end-to-end tests** Add to the end of `backend/tests/test_reconcile.py`: ```python # --- SP31: end-to-end integration via reconcile.run() ----------------------- def test_reconcile_run_emits_score_auto_match_when_pcn_misses(db_session, make_claim, make_remit): """A remit with no PCN match but matching charge+NPI triggers score-auto.""" from decimal import Decimal from datetime import date from cyclone import db as db_module from cyclone.db import Batch, ClaimState from cyclone.reconcile import run as reconcile_run claim = make_claim(patient_control_number="UNIQUE-CLM", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=date(2026, 6, 1)) remit = make_remit(payer_claim_control_number="DIFFERENT-PCN", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) db_session.add(batch) db_session.flush() # Re-parent the remit to this batch (make_remit doesn't set batch_id). remit.batch_id = batch.id db_session.flush() result = reconcile_run(db_session, batch.id) assert result.matched == 1 # Verify Match row was written with strategy="score-auto" from cyclone.db import Match matches = list(db_session.execute( db_module.select(Match).where(Match.remittance_id == remit.id) ).scalars().all()) assert len(matches) == 1 assert matches[0].strategy == "score-auto" assert matches[0].claim_id == claim.id # Verify ActivityEvent was emitted with kind="auto_matched_835" from cyclone.db import ActivityEvent events = list(db_session.execute( db_module.select(ActivityEvent).where( ActivityEvent.remittance_id == remit.id, ActivityEvent.kind == "auto_matched_835", ) ).scalars().all()) assert len(events) == 1 assert events[0].claim_id == claim.id # Verify claim state was flipped db_session.refresh(claim) assert claim.state in {ClaimState.PAID, ClaimState.PARTIAL, ClaimState.RECEIVED} def test_reconcile_run_pcn_path_unchanged(db_session, make_claim, make_remit): """Existing PCN-exact match still produces strategy='pcn-exact' (regression guard).""" from decimal import Decimal from datetime import date from cyclone import db as db_module from cyclone.db import Batch, Match from cyclone.reconcile import run as reconcile_run claim = make_claim(patient_control_number="SAME-PCN", total_charge=Decimal("100.00"), rendering_provider_npi="1111111111", service_date_from=date(2026, 6, 1)) remit = make_remit(payer_claim_control_number="SAME-PCN", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 2)) batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) db_session.add(batch) db_session.flush() remit.batch_id = batch.id db_session.flush() reconcile_run(db_session, batch.id) matches = list(db_session.execute( db_module.select(Match).where(Match.remittance_id == remit.id) ).scalars().all()) assert len(matches) == 1 assert matches[0].strategy == "pcn-exact" # NOT "score-auto" def test_reconcile_run_no_match_leaves_remit_unlinked(db_session, make_claim, make_remit): """No 2-of-3 match → no Match row, claim state unchanged.""" from decimal import Decimal from datetime import date from cyclone import db as db_module from cyclone.db import Batch, Match, ClaimState from cyclone.reconcile import run as reconcile_run claim = make_claim(patient_control_number="UNRELATED", total_charge=Decimal("999.99"), rendering_provider_npi="2222222222", service_date_from=date(2026, 6, 1)) remit = make_remit(payer_claim_control_number="OTHER", total_charge_amount=Decimal("100.00"), rendering_provider_npi="1111111111", service_date=date(2026, 6, 5)) batch = Batch(id="batch-1", kind="835", input_filename="x.835", parsed_at=date(2026, 6, 5)) db_session.add(batch) db_session.flush() remit.batch_id = batch.id db_session.flush() result = reconcile_run(db_session, batch.id) assert result.matched == 0 matches = list(db_session.execute( db_module.select(Match).where(Match.remittance_id == remit.id) ).scalars().all()) assert len(matches) == 0 db_session.refresh(claim) assert claim.state == ClaimState.SUBMITTED # unchanged db_session.refresh(remit) assert remit.claim_id is None # unchanged ``` - [ ] **Step 2: Run the tests to verify they FAIL (run() not yet integrated)** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v ``` Expected: failures. The behavior should be wrong because `run()` doesn't yet call `_score_fallback_candidates`. - [ ] **Step 3: Integrate the fallback into `reconcile.run()`** In `backend/src/cyclone/reconcile.py`, modify the `run` function (around line 240-260) to add the fallback after the in-memory `match()` call. Replace the block: ```python matches = match(unmatched_claims, new_remits) ``` with: ```python matches = match(unmatched_claims, new_remits) # SP31: content-keys fallback for remits that PCN-exact couldn't pair. # Operates on the still-unmatched remits so we don't double-match. matched_remit_ids = {m.remittance.id for m in matches} used_claim_ids = {m.claim.id for m in matches} for remit in new_remits: if remit.id in matched_remit_ids: continue if remit.claim_id is not None: continue # already linked matched_claim_id = _score_fallback_candidates(session, remit) if matched_claim_id is None: continue # Find the claim object in the unmatched_claims list (it was loaded). target_claim = next( (c for c in unmatched_claims if c.id == matched_claim_id), None, ) if target_claim is None or target_claim.id in used_claim_ids: continue matches.append(Match( claim=target_claim, remittance=remit, strategy="score-auto", is_reversal=remit.is_reversal, )) used_claim_ids.add(target_claim.id) ``` Then, in the existing apply loop further down (around line 270), change the ActivityEvent emission so score-auto matches emit `kind="auto_matched_835"` instead of the default `"reconcile"`. Find: ```python session.add(ActivityEvent( ts=datetime.now(timezone.utc), kind=intent.activity_kind, batch_id=batch_id, claim_id=m.claim.id, remittance_id=m.remittance.id, payload_json={"new_state": m.claim.state.value}, )) ``` and replace the `kind=intent.activity_kind` with: ```python kind=("auto_matched_835" if m.strategy == "score-auto" else intent.activity_kind), ``` And update the payload to include the score-auto metadata for the operator. Change the `payload_json=` line to: ```python payload_json=( {"new_state": m.claim.state.value, "strategy": m.strategy} if m.strategy == "score-auto" else {"new_state": m.claim.state.value} ), ``` - [ ] **Step 4: Run the new end-to-end tests to verify they PASS** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py -k "reconcile_run_" -v ``` Expected: 3 passed. - [ ] **Step 5: Run the full reconcile test suite to verify no regression** ```bash cd backend && .venv/bin/pytest tests/test_reconcile.py tests/test_db_models.py -v ``` Expected: all green (existing tests + 20 new tests). - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile.py git commit -m "feat(sp31): integrate content-keys fallback into reconcile.run() with auto_matched_835 event" ``` --- ## Task 5: Verify end-to-end (full test suite + manual smoke) **Files:** - No new files. Verification only. - [ ] **Step 1: Run the full backend test suite** ```bash cd backend && .venv/bin/pytest -q ``` Expected: 0 failures. If any test fails, fix it before proceeding. - [ ] **Step 2: Run the frontend typecheck** ```bash npm run typecheck ``` Expected: 0 errors. The widened `strategy` union in `src/types/index.ts` should compile cleanly. - [ ] **Step 3: Run the frontend lint** ```bash npm run lint ``` Expected: 0 errors. - [ ] **Step 4: Manual smoke test via the running prod stack** The 837+835 fixtures already exist (`backend/tests/fixtures/co_medicaid_837p.txt` and `backend/tests/fixtures/co_medicaid_835.txt`). Confirm by reading the envelopes: ```bash grep -E "^ISA|^GS|^ST|^BPR|^TRN|^CLP" backend/tests/fixtures/co_medicaid_835.txt | head -10 ``` Then send a parse-835 request via the existing `/api/parse-835` endpoint or the CLI: ```bash python -m cyclone.cli parse-835 backend/tests/fixtures/co_medicaid_835.txt --output-dir /tmp/sp31-smoke ``` Expected: a Remittance row is written + a Match row is created. Check via SQL: ```bash docker compose exec -T backend python3 <<'PY' from cyclone import db db.init_db() from sqlalchemy import select with db.SessionLocal()() as s: matches = list(s.execute( select(db.Match).order_by(db.Match.matched_at.desc()).limit(5) ).scalars().all()) for m in matches: print(f" {m.strategy:12} claim={m.claim_id[:24]:24} remit={m.remittance_id[:24]:24} reversal={m.is_reversal}") PY ``` Expected: at least one row with `strategy=pcn-exact` (the co_medicaid_835 fixture has PCN matching the co_medicaid_837p fixture) or `strategy=score-auto` (if you intentionally mangle the PCN in a copy of the fixture to trigger fallback). - [ ] **Step 5: Commit any incidental cleanup** If step 1-4 surfaced any unrelated failures, fix them on this branch. Otherwise no commit. ```bash git status ``` If clean, proceed to merge. --- ## Self-Review **1. Spec coverage:** - §1 D1 (strict 2-of-3 rule) → Task 2 ✅ - §1 D2 (PCN-first, fallback) → Task 4 ✅ - §1 D3 (Match row strategy label + payload) → Task 4 ✅ - §1 (idempotency guards) → Task 3 ✅ - §1 (reversal handling unchanged) → Task 4 (no changes to apply_reversal) ✅ - §2 D3 (Match.strategy labels) → Task 1 (rename) + Task 4 (new label) ✅ - §2 D4 ($0.01 tolerance) → Task 2 ✅ - §2 D5 (±30-day window) → Task 3 ✅ - §2 D6 (3 idempotency guards) → Task 3 (3 tests) ✅ - §2 D7 (no new UI) → no tasks (intentional) ✅ - §2 D8 (`auto_matched_835` ActivityEvent) → Task 4 ✅ - §2 D9 (no auth changes) → no tasks (intentional) ✅ - §3 edge cases → covered across Tasks 2, 3, 4 ✅ - §4 testing approach → 20 new tests across 4 test files ✅ - §5 out-of-scope reminders → no backfill, no 999 changes, etc. (none touched) ✅ **2. Placeholder scan:** No "TBD", "TODO", "implement later". The conftest fixture instructions include "If `Claim.payer_id` doesn't exist, check `db.py`" — this is intentional contingency, not a placeholder; the actual implementation step checks the file. **3. Type consistency:** All references to `_content_keys_match`, `_score_fallback_candidates`, `Match(strategy="score-auto")`, `kind="auto_matched_835"`, `CHARGE_TOLERANCE`, `KEYS_REQUIRED`, `SCORE_FALLBACK_WINDOW_DAYS`, and the `make_claim` / `make_remit` fixture signatures are consistent across all tasks. The `RemitLike` / `ClaimLike` Protocol usage in Task 2 matches the existing patterns in `reconcile.py`.