docs(spec): SP28 D10 — two-pass join via Batch.envelope.control_number
The original SP28 spec assumed `Claim.patient_control_number == 999.set_control_number`. Empirically that's wrong: Gainwell's 999 echoes the source 837's ST02, not its CLM01, and TOC's billing software fills them differently. Measured against prod on 2026-07-02: the PCN join matches 0 / 1,398 acks. Fix: two-pass join. Primary is `Batch.envelope.control_number (== 837 ST02) → claims via batch_id`; fallback is `Claim.patient_control_number` (for senders that fill CLM01 == ST02). Coverage after fix: 727 / 1,398 (52%); the remaining 671 are real orphans (ST02=0001 placeholder, no matching 837 batch in our DB). Spec adds D10 + a critical-correction paragraph at the top of §1. Plan gains the `lookup_claims_for_ack_set_response` pure helper, the `batch_envelope_index()` store method, and three new tests covering the two-pass join + the false-positive guard + the one-ack-to-many case.
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
|
||||
Today every inbound 999 / 277CA / TA1 ACK is **persisted** but **not linked to any claim**. The 999 rejection path mutates `Claim.state` (because it carries an AK2 set-control-number that matches `Claim.patient_control_number`) but does not persist a durable link row, so the operator cannot answer "which 999 acknowledged this claim?" or "which claims does this 999 acknowledge?". The 277CA path has the same gap. The TA1 path (envelope-level ack) has no per-claim granularity.
|
||||
|
||||
**Critical correction (added 2026-07-02 in response to the t991102989o1c120d investigation):** The opening sentence above is wrong about the PCN join. Empirically measured against production on 2026-07-02 against 1,398 acks and 530 claims: the join `Claim.patient_control_number == 999.set_control_number` matches **0 acks**. The 999 AK2-2 `set_control_number` is Gainwell's echo of the source 837's **`ST02`** (transaction-set control number), not the 837's **`CLM01`** (patient control number). TOC's billing software fills CLM01 with the full claim id (`t991102989o1c120d`) and ST02 with the batch control number (`991102989`). The correct primary join is `999.set_control_number → Batch.envelope.control_number (== 837 ST02) → claims in that batch via claim.batch_id`. SP28's join logic is rewritten in D10 below. **Coverage after fix:** 727 of 1,398 acks auto-link (52%); 671 stay orphans because their ST02 is `0001` (placeholder) and no 837 batch with ST02=0001 exists in our DB — these are real orphans for the Inbox lane.
|
||||
|
||||
SP28 closes that gap: every ACK carries enough structural metadata to be tied back to the claim(s) it acknowledges, and the system **auto-creates the link row at parse time** so the operator never has to. A new `claim_acks` join table is the durable record of the link; the existing `Claim.matched_remittance_id` / `Remittance.claim_id` pair is the precedent.
|
||||
|
||||
**In scope:**
|
||||
@@ -73,6 +75,21 @@ So the `ClaimDrawer` panel can show "Rejected (999 AK5 R)" inline without re-par
|
||||
|
||||
Every new endpoint goes through the same `request.state.user` check as `/api/claims` and `/api/acks`. The `POST /api/acks/{kind}/{id}/match-claim` endpoint is **any logged-in user** (D5) — differs from `POST /api/inbox/remit-orphans/{id}/match-claim` which is admin-only. All read endpoints (`GET /api/claims/{id}/acks`, `GET /api/acks/{kind}/{id}/claims`, the two stream endpoints, `GET /api/inbox/ack-orphans`) are also any logged-in user. The auth boundary is unchanged.
|
||||
|
||||
### D10. Primary join is `999.set_control_number → Batch.envelope.control_number (== 837 ST02)`, not `Claim.patient_control_number`
|
||||
|
||||
The original spec opening assumed `999.AK2-2 set_control_number == Claim.patient_control_number`. Empirically that's wrong on this codebase (TOC's 837s fill CLM01 with a unique id-per-claim and ST02 with the batch control number; Gainwell's 999 echoes ST02). The fix is a two-pass join with `Batch.envelope.control_number` as the primary key and `Claim.patient_control_number` as a fallback:
|
||||
|
||||
1. **Primary (per-AK2, for 999/277CA):** for each set-response's `set_control_number`, look up the `Batch` whose `raw_result_json.envelope.control_number` matches. If found, link the row to **every claim whose `batch_id == batch.id`** (a single 999 ack can carry one AK2 per original 837 CLM, and many of TOC's 837 batches ship one claim per file — but a batch can carry multiple claims, so the join is one-ack-to-many-claims when applicable). Per-AK2 granularity (D1) is preserved by recording each link row with its `ak2_index` so the operator can correlate which AK2 acknowledged which claim.
|
||||
2. **Fallback (per-AK2, for 999/277CA):** if no batch matches the set_control_number, look up `Claim.patient_control_number == set_control_number`. This catches the case where the sender filled CLM01 == ST02 (some billing software does). If found, link to that single claim.
|
||||
3. **Orphan:** neither pass matched → no `claim_acks` row; the ack surfaces in the Inbox `ack-orphans` lane (D7).
|
||||
4. **TA1 (envelope-level, D4 unchanged):** joins on `Batch.envelope.sender_id` + `receiver_id`, no set_control_number involved.
|
||||
|
||||
**Implementation:** a new pure helper `cyclone.claim_acks.lookup_claims_for_ack_set_response(sr) → list[Claim]` encapsulates both passes; `apply_999_acceptances` calls it inside the `for sr in set_responses:` loop and emits one `claim_acks` row per matched claim. The `set_control_number` is **still persisted on the link row** (per the existing `set_control_number STRING(64)` column) for orphan traceability — the row records the value the 999 actually carried, regardless of which join path resolved it.
|
||||
|
||||
**Performance:** the primary join reads `Batch.raw_result_json` (a JSON blob) on every set-response, which is O(N acks × M batches) without an index. Two mitigations: (1) cache the `Batch.envelope.control_number → batch.id` map in memory at the start of each ingest (max 16 batches today, so 16 lookups for any size ack); (2) once the 837 ingest path is touched up to denormalize the ST02 onto a dedicated column (out of scope for SP28; tracked as a follow-up if the row count grows), the lookup becomes a single indexed `SELECT`. Today's 530-claim workload makes (1) sufficient.
|
||||
|
||||
**Test impact:** the existing fixture `minimal_999.txt` uses ST02 matching the seeded `Batch`; no fixture rewrite needed. The two-AK2 fixture (`minimal_999_2ak2.txt`) gets one extra assertion that both AK2s land in `claim_acks` even when their `set_control_number`s don't equal any `Claim.patient_control_number` (proves the ST02 path is exercised, not the fallback).
|
||||
|
||||
---
|
||||
|
||||
## 3. Data model
|
||||
@@ -88,7 +105,7 @@ Columns:
|
||||
- `ack_id INTEGER NOT NULL` — discriminated union with `ack_kind` (no FK constraint — `acks` / `ta1_acks` / `two77ca_acks` are three separate tables).
|
||||
- `ack_kind STRING(8) NOT NULL` — one of `"999"`, `"277ca"`, `"ta1"`.
|
||||
- `ak2_index INTEGER NULL` — for 999 only; the AK2 set-response index that produced this link (NULL for 277CA and TA1).
|
||||
- `set_control_number STRING(64) NULL` — for 999/277CA only; the patient_control_number used for the lookup. Preserved for orphan traceability.
|
||||
- `set_control_number STRING(64) NULL` — for 999/277CA only; the `AK2-2 set_control_number` carried by the source 999/277CA (== source 837's `ST02` for Gainwell batches). Preserved on the link row for orphan traceability — it's the value the upstream ack actually carried, regardless of which join path (D10) resolved the link.
|
||||
- `set_accept_reject_code STRING(8) NULL` — for 999 (AK5 code: A/E/R/X); for 277CA (STC category: A1/A2/A3/A4/A6/A7 etc.).
|
||||
- `linked_at DATETIME NOT NULL` — set by the server.
|
||||
- `linked_by STRING(16) NOT NULL` — `"auto"` or `"manual"` (D5).
|
||||
@@ -215,9 +232,12 @@ Add `claimAcks: Record<string, ClaimAck> + claimAckOrder: number[]` slice. Mirro
|
||||
|
||||
- **Risk:** Migration `0018_claim_acks.sql` runs against a production DB with thousands of existing 999 rows. The migration adds a new table; it does NOT backfill historical links. Mitigation: a one-shot backfill script (`scripts/backfill_claim_acks.py`) lives outside the SP-N flow and is run manually after the deploy. Documented in the runbook delta.
|
||||
- **Risk:** `apply_999_acceptances` running alongside `apply_999_rejections` double-touches the same DB session. Mitigation: both helpers share a single session; the link row is created inside the same `for sr in set_responses:` loop as the rejection mutation, so there's exactly one transaction.
|
||||
- **Risk:** The two-pass join (D10) — `Batch.envelope.control_number` primary + `Claim.patient_control_number` fallback — can produce a false-positive link if a batch ST02 collides with a claim PCN from a different batch. Mitigation: the primary pass only fires when the batch lookup hits; the fallback only fires when the batch lookup misses AND the PCN lookup hits. The two paths cannot both fire for the same set_response, so the link is at most one row per claim per AK2. The `ux_claim_acks_dedup` unique index (3.1) catches the pathological re-ingest case.
|
||||
- **Risk:** Future 837 batches may carry multiple claims sharing one ST02 (a real possibility once billing software starts batching properly). Mitigation: the join is one-ack-to-many-claims by design (D10 implementation note); the `claim_acks` table allows the same `(ack_kind, ack_id, ak2_index)` to map to multiple `claim_id` rows. Tests cover the 1-to-N case explicitly.
|
||||
- **Risk:** 671 historical 999 acks with ST02=`0001` remain orphans after SP28 ships. Mitigation: D7's Inbox lane surfaces them; the operator can dismiss or manually link via the existing 999 detail drawer. No data loss — the acks remain visible on the Acks page; they just don't auto-link to a claim.
|
||||
- **Risk:** The `ux_claim_acks_dedup` unique index rejects re-ingests of the same file with a `sqlite3.IntegrityError`. Mitigation: the orchestrator catches this specifically (mirrors the existing `apply_999_rejections` idempotency check at line 60) and treats it as success — re-ingest of an identical file is a no-op.
|
||||
- **Risk:** TA1 batch-level links (`claim_id IS NULL`) confuse the `ClaimDrawer` panel. Mitigation: the new `/api/claims/{id}/acks` endpoint explicitly filters them out; the `to_ui_claim_ack` serializer tags them with `claim_state="n/a"` so they're visually distinct even if a future endpoint leaks them through.
|
||||
- **Risk:** Manual-match endpoint opens a window for the operator to attach an ack to the wrong claim. Mitigation: the endpoint requires admin role + the claim must currently be in `{SUBMITTED, RECEIVED, REJECTED, PAID, PARTIAL, DENIED}` (i.e., not in a reversed/closed state). Returns 409 if the claim is in a terminal state.
|
||||
- **Risk:** Manual-match endpoint opens a window for the operator to attach an ack to the wrong claim. Mitigation: the endpoint accepts any logged-in user (D5/D9 — explicitly differs from the admin-only remit-orphans posture), but the claim must currently be in `{SUBMITTED, RECEIVED, REJECTED, PAID, PARTIAL, DENIED}` (i.e., not in a reversed/closed state). Returns 409 if the claim is in a terminal state. The `linked_by="manual"` audit column preserves who matched what.
|
||||
|
||||
---
|
||||
|
||||
@@ -226,12 +246,14 @@ Add `claimAcks: Record<string, ClaimAck> + claimAckOrder: number[]` slice. Mirro
|
||||
The work breaks into ~14 tasks across 6 phases. The full plan lives at `docs/superpowers/plans/2026-07-02-cyclone-ack-claim-auto-link.md` (TBD).
|
||||
|
||||
1. **Migration** — `0018_claim_acks.sql` + `ClaimAck` ORM model + `db._reset_for_tests()` updates.
|
||||
2. **Pure helpers** — `cyclone.claim_acks.py` module with `apply_999_acceptances`, `apply_277ca_acks`, `apply_ta1_envelope_link`, `link_manual`.
|
||||
3. **Store facade** — extend `CycloneStore` with `add_claim_ack`, `list_acks_for_claim`, `list_claims_for_ack`, `find_ack_orphans`. Add the `claim_ack_written` / `claim_ack_dropped` event publish.
|
||||
4. **Handler integration** — wire `apply_claim_ack_links` into `handle_999.handle`, the 277CA handler, and the TA1 handler. Mirror the existing `apply_999_rejections` call site at `handlers/handle_999.py:79-92`.
|
||||
2. **Pure helpers** — `cyclone.claim_acks.py` module with `apply_999_acceptances`, `apply_277ca_acks`, `apply_ta1_envelope_link`, `link_manual`, and `lookup_claims_for_ack_set_response` (the two-pass join from D10 — Batch.envelope.control_number primary, Claim.patient_control_number fallback).
|
||||
3. **Store facade** — extend `CycloneStore` with `add_claim_ack`, `list_acks_for_claim`, `list_claims_for_ack`, `find_ack_orphans`, and `batch_envelope_index()` (cached in-memory map of `envelope.control_number → batch.id`, invalidated on every new `Batch` write). Add the `claim_ack_written` / `claim_ack_dropped` event publish.
|
||||
4. **Handler integration** — wire `apply_claim_ack_links` into `handle_999.handle`, the 277CA handler, and the TA1 handler. Mirror the existing `apply_999_rejections` call site at `handlers/handle_999.py:79-92`. Inside `apply_999_acceptances`, the per-AK2 loop calls `lookup_claims_for_ack_set_response(sr)` and emits one `claim_acks` row per matched claim (one-ack-to-many-claims when the 837 batch shipped multiple claims).
|
||||
5. **API endpoints** — new `api_routers/claim_acks.py` (mirrors `api_routers/acks.py` shape) + register the router. Extend the existing `/api/claims/{id}` and `/api/acks` serializers.
|
||||
6. **Frontend** — `useClaimAcks` + `useAckClaims` hooks, `ClaimDrawer` panel, `AckDrawer` panel, Acks page column, Inbox lane, `useTailStore` slice.
|
||||
|
||||
**Critical-path change vs. the original plan:** Task 2 grows by one helper (`lookup_claims_for_ack_set_response`); Task 3 grows by one method (`batch_envelope_index`). Task 4's per-AK2 loop becomes a `for claim in lookup_claims_for_ack_set_response(sr):` instead of a single `s.query(Claim).filter_by(patient_control_number=...).first()`. No other tasks change shape.
|
||||
|
||||
Estimated lines: ~1,200 backend + ~800 frontend + ~600 tests. Mirrors SP25's footprint.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user