Files
cyclone/docs/superpowers/plans/2026-07-02-cyclone-835-strict-content-match.md

34 KiB

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:

            strategy="auto",

to:

            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:

  strategy: "auto" | "manual";

to:

  strategy: "pcn-exact" | "score-auto" | "manual";

Line 510, change:

  match: { id: number; strategy: "auto" | "manual" };

to:

  match: { id: number; strategy: "pcn-exact" | "score-auto" | "manual" };
  • Step 4: Run the test suite to confirm nothing broke
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
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:

# --- 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
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):

# --- 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
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "content_keys" -v

Expected: 10 passed.

  • Step 5: Commit
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:

# --- 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:

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
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:

# --- 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
cd backend && .venv/bin/pytest tests/test_reconcile.py -k "score_fallback" -v

Expected: 7 passed.

  • Step 6: Commit
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:

# --- 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)
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:

    matches = match(unmatched_claims, new_remits)

with:

    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:

        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:

            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:

            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
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
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
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

cd backend && .venv/bin/pytest -q

Expected: 0 failures. If any test fails, fix it before proceeding.

  • Step 2: Run the frontend typecheck
npm run typecheck

Expected: 0 errors. The widened strategy union in src/types/index.ts should compile cleanly.

  • Step 3: Run the frontend lint
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:

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:

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:

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.

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.