# 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`.