130 lines
12 KiB
Markdown
130 lines
12 KiB
Markdown
# Sub-project 31 — 835 Strict Content-Match Auto-Link: Design Spec
|
||
|
||
**Date:** 2026-07-02
|
||
**Status:** Draft, awaiting user sign-off
|
||
**Branch:** `sp31-835-strict-content-match`
|
||
**Aesthetic direction:** No UI changes. State flips driven by backend logic surface immediately via the SP30 Recent Batches widget.
|
||
|
||
---
|
||
|
||
## 1. Scope
|
||
|
||
Today when an 835 (ERA remittance advice) arrives, the existing matcher in `cyclone.reconcile.run` only auto-links a remit to a claim when **`payer_claim_control_number == claim.patient_control_number` AND service dates are within ±7 days**. That single-key match misses most real-world remits: Gainwell's 835 carries its own independent control numbers (M-prefix filenames, ISA13 assigned by Gainwell, no echo of the 837's ST02), so the only reliable join lives in the 835's **content fields** — CLP01 (PCN), CLP03 (total charge), CLP07 (rendering provider NPI). The operator can see in the Recent Batches widget that a batch has unresolved claims, but until a manual match is performed (admin-only endpoint `POST /api/reconciliation/match`) those remits sit in the Inbox Unlinked lane with no `Claim.state` flip to PAID / DENIED / PARTIAL.
|
||
|
||
SP31 closes that gap by adding a **deterministic content-key fallback matcher** to the 835 ingest path. When the existing PCN-exact path misses, a strict **"any 2 of {PCN, charge, NPI}" rule** runs against a wider candidate pool (±30 days) and auto-links on a hit. The result: more remits get linked at parse time, more claims get state-flipped to a terminal/settled state, and the Recent Batches widget reflects the truth immediately (no manual intervention needed).
|
||
|
||
**In scope:**
|
||
|
||
- One new helper `_content_keys_match(remittance, claim)` in `cyclone.reconcile` that returns True when at least 2 of {CLP01↔CLM01 PCN, CLP03↔CLM02 charge, CLP07↔NM1*82 NPI} agree exactly (charge compared with a $0.01 tolerance).
|
||
- One new helper `_score_fallback_candidates(session, remittance)` that queries the candidate pool (same payer_id, billed_within ±30 days, claim.state ∈ {SUBMITTED, RECEIVED, PARTIAL}, no existing match) and returns the unique match when one exists, or None when zero or multiple claims pass.
|
||
- One new branch inside `cyclone.reconcile.run` that invokes the content-keys fallback **after** the existing PCN-exact path misses. On a hit, calls `apply_payment` / `apply_reversal`, writes a `Match(strategy="score-auto")` row with `score=100` and a payload describing which keys matched, and emits an `auto_matched_835` `ActivityEvent`.
|
||
- Idempotency guards: skip scoring when `claim.matched_remittance_id` is set, when `remittance.claim_id` is set, or when `claim.state` is already terminal (PAID / DENIED / REJECTED / REVERSED / RECONCILED). Re-ingesting the same 835 is a no-op for already-linked pairs.
|
||
- Reversal handling unchanged: status_code 21/22 always goes through `apply_reversal`, creating a 2nd `Match` row per existing SP27 convention. The strategy label (`pcn-exact` vs `score-auto`) reflects which path made the link.
|
||
|
||
**Out of scope:**
|
||
|
||
- No schema migration. `Match` and `ActivityEvent` already exist; the new strategy label `"score-auto"` is a string value within the existing column.
|
||
- No backfill. The 672 currently-unlinked claims in production stay unlinked; SP31 only affects new 835 ingests. An operator can still run the existing manual-match endpoints to backfill at their own pace.
|
||
- No changes to the existing PCN-exact matcher. It stays as the primary (fast) path. SP31 only adds a fallback branch.
|
||
- No changes to the Inbox Unlinked lane UI, the Recent Batches widget, or the ClaimDrawer / RemitDrawer. State flips already surface through the existing live-tail path (SP25) and the SP30 widget.
|
||
- No new endpoints. The existing `POST /api/reconciliation/match` and `POST /api/reconciliation/unmatch` remain the manual-fallback surfaces.
|
||
- No 999 / 277CA / TA1 changes. The ack↔claim auto-linker from SP28 stays untouched (those use the 837 filename / ST02 echo, not content keys).
|
||
|
||
---
|
||
|
||
## 2. Decisions (locked during brainstorming)
|
||
|
||
### D1. Match rule is strict "any 2 of {PCN, charge, NPI} match exactly" — no weighted scoring, no thresholds, no ambiguity tiers
|
||
|
||
The user's instinct that "we might be over-building this" led to dropping the original 4-field weighted score with a 90-point threshold. Instead, SP31 uses a **deterministic three-key rule**:
|
||
|
||
- **PCN match:** `remittance.payer_claim_control_number == claim.patient_control_number` (after `.strip()` on both).
|
||
- **Charge match:** `|remittance.total_charge_amount − claim.total_charge| < $0.01`.
|
||
- **NPI match:** `remittance.rendering_provider_npi == claim.rendering_provider_npi` AND the remit's NPI is non-empty.
|
||
|
||
Auto-link when `keys_matched ≥ 2`. **No weighted score, no 90/75 threshold, no "runner-up within 10 points = ambiguous" rule.** The decision is reproducible by inspecting the three fields; "score was 91" is no longer in the operator's vocabulary. The existing scoring module (`cyclone.scoring.score_pair`) is preserved for the Inbox display lane but is no longer invoked from the auto-match path.
|
||
|
||
### D2. Content-key fallback runs *after* the existing PCN-exact path misses — not as a replacement
|
||
|
||
`reconcile.run` already PCN-matches today. SP31 leaves that path untouched and inserts the content-keys fallback as a second branch. Rationale: when PCN matches today, the match is rock-solid (no charge comparison, no NPI check needed). When PCN misses, content-keys is the next-best signal. Stacking keeps the fast path fast and the audit trail obvious (Match.strategy reveals which branch fired).
|
||
|
||
### D3. The Match row gets `strategy="score-auto"` and a payload describing which keys matched
|
||
|
||
This differentiates from the existing `strategy="pcn-exact"` (renamed from today's `strategy="auto"`). The Match row payload stores `{"keys_matched": ["pcn", "charge"], "candidate_count": 7}` so the operator can see exactly why a link was made. The Inbox Unlinked lane will surface these labels too (existing display layer reads `Match.strategy`). The existing `score` column is set to `100` for content-keys matches (placeholder for the legacy display layer; not used by the new logic).
|
||
|
||
### D4. Charge tolerance is $0.01, not exact-zero
|
||
|
||
CO Medicaid and most payers carry charge to 2 decimal places; rounding can introduce ±$0.005 of drift. $0.01 is the conservative tolerance that still catches real mismatches (a $50 charge vs a $100 charge differs by $50, not $0.01).
|
||
|
||
### D5. Candidate pool is widened to ±30 days (was ±7 for PCN)
|
||
|
||
Strict keys (≥ 2 of 3) are a much stronger signal than PCN-alone, so the wider window is safe. The 30-day window captures remits that arrive late (e.g., week-long payer processing delays plus weekends plus holidays) without pulling in unrelated claims from prior months.
|
||
|
||
### D6. Idempotency is enforced by three guards, not by deduplication
|
||
|
||
Before scoring a candidate, the matcher checks:
|
||
|
||
1. `claim.matched_remittance_id IS NULL` (claim hasn't been linked yet)
|
||
2. `remittance.claim_id IS NULL` (remit hasn't been linked yet)
|
||
3. `claim.state NOT IN {PAID, DENIED, REJECTED, REVERSED, RECONCILED}` (terminal-state guard)
|
||
|
||
If any guard fails, the candidate is excluded from the pool. If no candidate remains after filtering, no match is made and the remit lands in the Inbox Unlinked lane (existing behavior).
|
||
|
||
### D7. No operator-facing UI for content-keys matches — the existing live-tail + Recent Batches widget surface them
|
||
|
||
The `Match` row (D3) and the `auto_matched_835` ActivityEvent (D8) are the operator-visible artifacts. The Recent Batches widget from SP30 already reflects `Claim.state` changes within one re-fetch cycle (TanStack Query interval). No new "auto-matched by score" badge anywhere — keeps the UI surface minimal and lets the widget do its job.
|
||
|
||
### D8. An `auto_matched_835` ActivityEvent is emitted on every successful content-keys match
|
||
|
||
Payload: `{claim_id, remittance_id, strategy: "score-auto", keys_matched: [...], candidate_count: N}`. Visible in the Activity page alongside existing `reconcile` events. Lets the operator answer "why did this 835 flip a claim?" without inspecting the Match table.
|
||
|
||
### D9. Reversals (status_code 21/22) keep the existing 2-row Match audit trail
|
||
|
||
When a reversal 835 arrives and matches via either path, `apply_reversal` flips the claim state (PAID/PARTIAL → REVERSED) and writes a **second** `Match` row (per SP27 convention). The strategy label on the new row reflects which branch matched the reversal. This preserves the audit trail: the first Match row says "paid", the second says "reversed".
|
||
|
||
### D10. No changes to auth posture, no new endpoints, no CLI changes
|
||
|
||
Every existing endpoint's auth boundary is preserved. The 835 ingest path runs server-side; no operator action is required to trigger a content-keys match. The existing manual-match endpoints (`POST /api/reconciliation/match`, `POST /api/reconciliation/unmatch`) remain the escape hatch for edge cases.
|
||
|
||
---
|
||
|
||
## 3. Edge cases & how they're handled
|
||
|
||
| Scenario | Behavior |
|
||
|---|---|
|
||
| PCN matches (existing path) | Unchanged. `apply_payment` / `apply_reversal`, `Match(strategy="pcn-exact")`. |
|
||
| PCN misses, content-keys 2-of-3 matches, exactly 1 candidate | `apply_payment`, `Match(strategy="score-auto", score=100)`, Activity event. |
|
||
| PCN misses, content-keys 2-of-3 matches, 2+ candidates | No match. Remit lands in Inbox Unlinked. (Deterministic rule — duplicates would be ambiguous.) |
|
||
| PCN misses, content-keys 0-of-3 or 1-of-3 | No match. Inbox Unlinked. |
|
||
| Reversal (status_code 21/22) | Always `apply_reversal`. 2nd Match row. Strategy label per which path matched. |
|
||
| `claim.matched_remittance_id` set | Skip scoring entirely. Idempotent. |
|
||
| `remittance.claim_id` set | Skip scoring entirely. Idempotent. |
|
||
| `claim.state` in terminal set | Skip scoring. State already settled. |
|
||
| Remit has empty CLP07 (no rendering NPI) | NPI key counted as "not matched" — falls back to PCN + charge rule. |
|
||
| Charge differs by exactly $0.01 | Counts as matched (D4 tolerance). |
|
||
| Same PCN across two claims in the pool (legitimate re-submit) | Both candidates count toward the pool; if only one also matches charge or NPI, that one wins. |
|
||
|
||
---
|
||
|
||
## 4. Testing approach
|
||
|
||
14 new tests in `backend/tests/test_reconcile.py`, modeled on the existing `reconcile` test patterns. Coverage targets:
|
||
|
||
- **Happy paths** (4): PCN+charge match, PCN+NPI match, charge+NPI match (no PCN), all 3 match.
|
||
- **Negative paths** (3): only-PCN-match doesn't auto-link, only-charge-match doesn't auto-link, none-match doesn't auto-link.
|
||
- **Idempotency** (3): claim already matched skips, remit already linked skips, terminal claim state skips.
|
||
- **State transitions** (2): reversal uses `apply_reversal`, paid remittance flips claim to PAID.
|
||
- **Audit trail** (2): Match row strategy label is `score-auto`, Activity event `auto_matched_835` emitted with correct payload.
|
||
- **Regression guard** (1): PCN-exact path unchanged — when PCN matches, behavior is identical to today.
|
||
|
||
No frontend tests (no UI changes). Manual smoke via Playwright: ingest an 835 fixture against a synthetic 837 batch and assert the claim state flips in the Recent Batches widget within one re-fetch.
|
||
|
||
---
|
||
|
||
## 5. Out-of-scope reminders (explicit)
|
||
|
||
- **Backfilling the 672 unmatched claims already in production.** Operator-driven, not auto.
|
||
- **Changing the existing 999 / 277CA / TA1 auto-linker.** Those use the 837 filename / ST02 echo (SP28), a different signal than content keys.
|
||
- **Changing the Inbox Unlinked lane UI.** The lane already exists; SP31 only changes which remits end up in it (fewer).
|
||
- **Tuning charge tolerance.** $0.01 is the conservative default; revisit only if real production data shows false-negatives.
|
||
- **Weighted scoring from `cyclone.scoring` is preserved for display only.** The auto-match path does not invoke `score_pair`. |