From 6624a0bafd7dd926c84d3b412eddaca8e238828e Mon Sep 17 00:00:00 2001 From: Nora Date: Thu, 2 Jul 2026 14:56:43 -0600 Subject: [PATCH] docs(plan): SP31 835 strict content-match auto-link --- ...-07-02-cyclone-835-strict-content-match.md | 887 ++++++++++++++++++ 1 file changed, 887 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md diff --git a/docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md b/docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md new file mode 100644 index 0000000..1a25a99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md @@ -0,0 +1,887 @@ +# 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`. \ No newline at end of file