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.
31 KiB
Sub-project 28 — Ack↔Claim Auto-Link: Design Spec
Date: 2026-07-02
Status: Draft, awaiting user sign-off
Branch: sp28-ack-claim-auto-link
Aesthetic direction: No new UI library; one new panel in ClaimDrawer + one new panel in AckDrawer + one candidate lane in the Inbox.
1. Scope
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:
- New
claim_ackstable (one row per AK2 set-response for 999, one row per claim-status for 277CA, one row per TA1 envelope for TA1). - New
apply_999_acceptancescompanion toapply_999_rejectionsthat creates link rows for all AK2 set-responses (both accepted AND rejected). - Refactor
apply_277ca_rejections→apply_277ca_acksthat creates link rows for every claim-status (accepted / payer-rejected / pending — anything with apayer_claim_control_number). - New
apply_ta1_envelope_linkthat links a TA1 to the most-recent outbound 837Batchwhose ISA sender/receiver matches the TA1'ssender_id/receiver_id(with anAckMatchedactivity event when the match is made). - New
apply_claim_ack_linksorchestrator that wires the three handlers into the existing 999 / 277CA / TA1 ingest paths. - Three new endpoints:
GET /api/claims/{id}/acks,GET /api/acks/{id}/claims,POST /api/acks/{kind}/{id}/match-claim(manual fallback). - One new "Acknowledgments" panel in
ClaimDrawershowing the linked 999/277CA/TA1 rows with deep-links to the matchingAckDrawer. - One new "Matched claim" panel in
AckDrawershowing the linked claim with deep-links toClaimDrawer(and a "Link to claim…" dropdown for the orphan case). - One new "Ack orphans" candidate lane in the Inbox (sibling of the existing "Payer-rejected" lane), powered by
GET /api/inbox/ack-orphans?kind=999. - Per-row live-tail event for
claim_ack_writtenso theClaimDrawerpanel updates without a manual refresh.
Out of scope:
- Restructuring the
Ack/Ta1Ack/Two77caAcktables themselves (they keep their existing schemas; the link is purely additive). - Bulk re-linking of historical acks (a one-shot backfill SQL script lives outside the SP-N flow; the auto-linker only handles new ACKs).
- TA1 per-claim granularity (TA1 has no AK2; envelope-level link to the originating Batch is the deepest granularity X12 supports for TA1).
- Changing the existing
Claim.statemutation behavior —apply_999_rejectionsstill mutates REJECTED state,apply_277ca_rejectionsstill mutatespayer_rejected_*fields; SP28 only adds the link row on top.
2. Decisions (locked during brainstorming)
D1. Link granularity is per-AK2 / per-claim-status, not per-ACK row
A single 999 carries one AK2 per original 837 CLM. Persisting one link row per AK2 (rather than one per 999 row) lets the operator answer "which claims does this 999 acknowledge?" with a single SELECT on claim_acks, and lets the ClaimDrawer panel show per-segment accept/reject status without re-parsing raw_json. Same shape for 277CA (one row per ClaimStatus). TA1 falls back to one row per TA1 envelope (linked to the originating Batch).
D2. The link is stored in a separate claim_acks table — not a denormalized FK on Ack
Mirrors the existing Claim.matched_remittance_id / Remittance.claim_id precedent: a join table, not a column. Reasons: (1) one 999 row can link to many claims and vice versa; (2) the link row carries its own metadata (ak2_index, set_control_number, set_accept_reject_code, linked_at, linked_by); (3) deleting an Ack cascades cleanly to claim_acks via ON DELETE CASCADE. Mirrors cas_adjustments.remittance_id shape.
D3. Auto-link is eager at parse time — not lazy at read time
apply_999_acceptances / apply_277ca_acks / apply_ta1_envelope_link all run inside the same DB transaction that persists the Ack row. The operator never sees a row without its links (no "I'll figure it out later" stage). This mirrors apply_999_rejections already running at parse time. The trade-off (an extra lookup per set-response) is negligible — patient_control_number is indexed (ix_claims_patient_control_number).
D4. TA1 links to the originating Batch, not a Claim
TA1 is envelope-level (ISA/IEA). The closest thing to a "source claim" is the outbound 837 Batch that produced this envelope — specifically the most recent Batch whose sender_id + receiver_id matches the TA1's sender_id + receiver_id. The new claim_acks table allows claim_id IS NULL + batch_id (FK to batches.id) for this case; the ClaimDrawer panel filters out batch-level rows so the operator only sees per-claim acks, but the link is preserved for traceability.
D5. Manual fallback: POST /api/acks/{kind}/{id}/match-claim
When the auto-linker can't find the claim (orphan PCN — e.g., a 999 arrives before the 837 has been ingested), any logged-in user can manually link via a new endpoint. The endpoint takes a claim_id, looks it up, inserts a claim_acks row with linked_by="manual", and publishes a claim_ack_written event so the drawers refresh. Mirrors the existing POST /api/inbox/remit-orphans/{id}/match-claim endpoint shape (returns the updated ClaimDetail-style payload + a match_id). Auth posture differs from the remit-orphans endpoint: that one is admin-only because a wrong match on a remit can corrupt financial reconciliation; the ack match is metadata-only (no Claim.state mutation, no payment data) so any user can fix orphans. The endpoint rejects (409) when the claim is in a terminal state (REVERSED) or when the link already exists (idempotent 200).
D6. New live-tail event: claim_ack_written
Subscribed by both /api/claims/{id}/acks/stream and /api/acks/{id}/claims/stream so a manual match shows up in real time on both drawers. Payload is the new to_ui_claim_ack row shape (id, claim_id, ack_id, ack_kind, ak2_index, set_control_number, set_accept_reject_code, linked_at, linked_by). Adds a new slice to useTailStore + useTailStream + useMergedTail (mirrors SP25's acks/ta1_acks extension).
D7. New Inbox candidate lane: "Ack orphans"
Powered by GET /api/inbox/ack-orphans?kind=999 (and ?kind=277ca / ?kind=ta1). Lists acks where the auto-linker couldn't resolve the claim (orphan PCNs). Each row shows: ack id + kind, PCN, source batch, parsed_at, plus a top-3 candidate list (most-recent claims with the same PCN OR same patient_control_number pattern — relaxed PCN match). Bulk-action dismiss is supported; match is delegated to the existing manual endpoint.
D8. The set_accept_reject_code is persisted on the link row
So the ClaimDrawer panel can show "Rejected (999 AK5 R)" inline without re-parsing raw_json. The 999 path passes the per-AK2 code through apply_999_acceptances; the 277CA path passes the STC category code through apply_277ca_acks.
D9. No new auth boundary; existing auth posture covers it
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:
- Primary (per-AK2, for 999/277CA): for each set-response's
set_control_number, look up theBatchwhoseraw_result_json.envelope.control_numbermatches. If found, link the row to every claim whosebatch_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 itsak2_indexso the operator can correlate which AK2 acknowledged which claim. - 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. - Orphan: neither pass matched → no
claim_acksrow; the ack surfaces in the Inboxack-orphanslane (D7). - 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_numbers don't equal any Claim.patient_control_number (proves the ST02 path is exercised, not the fallback).
3. Data model
3.1 New table — claim_acks
Mirrors cas_adjustments shape: PK + three FK columns + audit columns + a composite unique index.
Columns:
id INTEGER PK autoincrementclaim_id STRING(64) FK claims.id ON DELETE CASCADE NULL— nullable to allow batch-level rows (D4).batch_id STRING(32) FK batches.id ON DELETE CASCADE NULL— nullable; populated for TA1 envelope-level links.ack_id INTEGER NOT NULL— discriminated union withack_kind(no FK constraint —acks/ta1_acks/two77ca_acksare 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; theAK2-2 set_control_numbercarried by the source 999/277CA (== source 837'sST02for 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).
Indexes:
ix_claim_acks_claim_id(claim_id)— forGET /api/claims/{id}/acksand the live-tail subscription.ix_claim_acks_ack(ack_kind, ack_id)— forGET /api/acks/{id}/claimsand the reverse-direction live-tail subscription.ix_claim_acks_batch_id(batch_id)— for TA1 batch-level lookups.ux_claim_acks_dedup— UNIQUE(claim_id,ack_kind,ack_id,ak2_index) WHERE NOT NULL — prevents the auto-linker from creating duplicate rows on a re-ingest of the same file.
Migration: 0018_claim_acks.sql. Mirrors the 0011_processed_inbound_files.sql shape (CREATE TABLE + CREATE INDEX statements).
3.2 ORM model
backend/src/cyclone/db.py:ClaimAck(Base) follows the same pattern as CasAdjustment (line 376) — minimal typed columns + __table_args__ indexes. The claim, batch, and ack_* relationships are declared as Mapped[Optional[...]] but not eagerly loaded (no relationship(back_populates=...)) — the link is read-by-id via the new query helpers, not traversed.
3.3 New to_ui_claim_ack serializer
backend/src/cyclone/store/ui.py — mirrors to_ui_ack shape. Returns the row + a derived claim_state (queried in a single join: ClaimAck → Claim.state if claim_id is not NULL) so the drawer panel can render the colored ClaimStateBadge inline. For TA1 rows with claim_id IS NULL, claim_state is "n/a".
4. Wire-level changes
4.1 New endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/api/claims/{claim_id}/acks |
user | List every claim_acks row for a claim (per-claim only — filters out batch-level TA1 rows). |
GET |
/api/acks/{kind}/{ack_id}/claims |
user | List every claim_acks row for an ack. kind ∈ 999 | 277ca | ta1. For 999/277CA, returns one entry per AK2/ClaimStatus. |
GET |
/api/claims/{claim_id}/acks/stream |
user | NDJSON live-tail of claim_ack_written events filtered to claim_id. |
GET |
/api/acks/{kind}/{ack_id}/claims/stream |
user | NDJSON live-tail filtered to ack_id + kind. |
POST |
/api/acks/{kind}/{ack_id}/match-claim |
user | Manual link fallback (D5, D9). Body: {"claim_id": "..."}. Idempotent — returns 200 with existing row if already linked. |
DELETE |
/api/acks/{kind}/{ack_id}/match-claim/{claim_id} |
user | Unlink (preserves Claim.state mutation; only removes the claim_acks row + publishes claim_ack_dropped). |
GET |
/api/inbox/ack-orphans |
user | Lists acks with no resolvable claim. Query: ?kind=999 | ?kind=277ca | ?kind=ta1. Mirrors the existing /api/inbox/remit-orphans shape. |
4.2 Modified endpoints
POST /api/parse-999/POST /api/parse-277ca/POST /api/parse-ta1— internally call the newapply_claim_ack_linksorchestrator. Wire shape unchanged; the response gains a newclaim_ack_links: { matched: N, orphans: N, linked: [{claim_id, ack_kind, set_accept_reject_code}, …] }field for observability.GET /api/acks/GET /api/ta1-acks/GET /api/277ca-acks— each item gainslinked_claim_ids: list[str]so the Acks page row can show the "🔗 N claims" badge inline.GET /api/claims/{id}— the response gainsack_links: list[dict](compact form:[{ack_id, ack_kind, set_accept_reject_code, parsed_at}]) so theClaimDrawercan render the panel on initial load.GET /api/acks/{id}/detail(and the TA1 / 277CA counterparts) — each gainsclaim_links: list[dict]so theAckDrawercan render the matched-claim panel on initial load.
4.3 New pubsub event
claim_ack_written— payload is the newto_ui_claim_ackrow shape (3.3). Subscribed by both/api/claims/{id}/acks/streamand/api/acks/{id}/claims/stream. Mirrors the existingclaim_written/remittance_writtenpayload shape.claim_ack_dropped— fired onDELETE /api/acks/{kind}/{id}/match-claim/{claim_id}. Payload is the dropped row id + the affectedclaim_idso subscribers can remove the link from their local store.
5. Frontend
5.1 New useClaimAcks(claim_id) hook
src/hooks/useClaimAcks.ts — TanStack Query, key ["claim-acks", claimId], calls api.listClaimAcks(claimId). Mirrors useClaims shape. The live-tail subscription (useTailStream("claim-acks") + useMergedTail) lives on the ClaimDrawer (per cyclone-frontend-page convention #3 — subscription on the page, not the hook).
5.2 New useAckClaims(kind, ackId) hook
src/hooks/useAckClaims.ts — mirror of useClaimAcks, scoped to a single ack. The AckDrawer mounts the subscription.
5.3 ClaimDrawer — new "Acknowledgments" panel
Inserted between the existing "Service lines" and "Reconciliation" panels. Header: "Acknowledgments (N)". Body: a list of compact rows, each showing set_accept_reject_code (color-coded), parsed_at, ack kind badge, and a → link to the matching AckDrawer. If the link list is empty, render the existing <EmptyState> with copy "No acknowledgments received yet for this claim." Subscribes to useClaimAcks(claimId) + useTailStream("claim-acks") so a 999 landing in real time appends a row.
5.4 AckDrawer — new "Matched claim" panel
Inserted as the first panel (before "Set responses"). For 999 / 277CA, shows the linked claim(s) — one card per AK2 / ClaimStatus — with patient_control_number, current Claim.state (via the new ClaimStateBadge), charge amount, and a → link to ClaimDrawer. For TA1, shows the originating Batch (batch id + filename + parsed_at) instead. If the link list is empty, render a "Link to claim…" dropdown (calls the manual-match endpoint, then refetches). On a successful match, the panel swaps to the populated card view.
5.5 Acks page — new linkedClaimCount cell
The Acks table row gains a new column "Claims" between "Accepted" and "Rejected" — value is the count of linked_claim_ids from the list endpoint, rendered as a Badge (e.g. 5 claims). Clicking the badge when count > 0 navigates to the first claim's ClaimDrawer (deep-link round-trips via useDrawerUrlState).
5.6 Inbox — new "Ack orphans" lane
Mirrors the existing "Payer-rejected" lane (5th lane). Header: "Ack orphans (N)". Body: a compact table of orphan acks with a per-row "Dismiss" button and a "Link to…" inline dropdown. The lane is empty when all acks auto-linked successfully — same EmptyState shape as the other lanes.
5.7 useTailStore extension
Add claimAcks: Record<string, ClaimAck> + claimAckOrder: number[] slice. Mirrors the SP25 acks/ta1_acks slice shape (key-by-id with first-write-wins dedup, FIFO-capped at TAIL_CAP = 10_000).
6. Test impact
Backend — new fixtures
backend/tests/fixtures/minimal_999_2ak2.txt— a 999 with TWO AK2 set-responses (one accepted, one rejected) so the per-AK2 link granularity is exercised. Derived from the existingminimal_999.txtby adding a second AK2/AK5 pair.backend/tests/fixtures/minimal_277ca_2status.txt— same shape, two ClaimStatus entries.
Backend — new tests
-
backend/tests/test_apply_claim_ack_links.py:test_999_auto_creates_per_ak2_link_rows— submitminimal_999_2ak2.txtagainst a fixture-seeded claim table, assertclaim_ackshas 2 rows, one withset_accept_reject_code="A"and one with"R".test_999_orphan_does_not_create_link— submit a 999 with an AK2 whose set_control_number matches no claim, assert noclaim_acksrow, assertclaim_ack_links.orphanscontains the PCN.test_277ca_accepted_creates_link_with_no_state_mutation— submit a 277CA withclassification="accepted", assertclaim_acksrow exists ANDClaim.payer_rejected_at IS NULL.test_277ca_rejected_creates_link_and_mutates_state— mirror the existingapply_277ca_rejectionstest, but also assert the link row.test_ta1_links_to_most_recent_matching_batch— seed twoBatchrows with distinct sender/receiver, submit a TA1 whose sender/receiver matches batch #2, assert the link row'sbatch_idis batch #2.test_reingest_same_999_is_idempotent— submit the same 999 twice, assert exactly oneclaim_acksrow per AK2 (theux_claim_acks_dedupunique index enforces this).test_manual_link_endpoint_idempotent— callPOST /api/acks/999/{id}/match-claimtwice with the sameclaim_id, assert one row + 200 OK both times.test_manual_link_any_user_succeeds— call the endpoint as a non-admin user (D9), assert 200 + the link row created. Confirms the explicit deviation from the admin-only remit-orphans posture.test_manual_link_rejects_terminal_claim— try to link a 999 to a claim instate=REVERSED, assert 409 (D5 / D9 — terminal state guard).test_unlink_does_not_revert_claim_state— link a 999 withset_accept_reject_code="R", then unlink it, assert the claim is STILLstate=REJECTED(D6: unlinking is purely about the link row).
-
backend/tests/test_api_claim_acks.py:test_claim_detail_includes_ack_links— GET/api/claims/{id}after ingesting a 999, assertack_linksis populated.test_acks_list_includes_linked_claim_ids— GET/api/acksafter ingesting, assertlinked_claim_idsis populated per row.test_claim_acks_stream_emits_claim_ack_written— open the stream, manually link via the endpoint, assert oneitemevent arrives within 2s (uses the SP25 test pattern: syntheticRequest+ iteratebody_iteratordirectly +monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")).
Frontend — new tests
src/hooks/useClaimAcks.test.ts— calls the hook, asserts the API is invoked with the right key.src/components/ClaimDrawer/ClaimDrawer.test.tsx— extend the existing test to assert the "Acknowledgments" panel renders whenack_linksis non-empty, and renders the<EmptyState>when empty.src/components/AckDrawer/AckDrawer.test.tsx— extend the existing test to assert the "Matched claim" panel renders for a 999 with one AK2 link, and renders the "Link to claim…" dropdown when no link exists.src/pages/Acks.test.tsx— extend the existing test to assert the "Claims" badge column renderslinkedClaimCount.src/pages/Inbox.test.tsx— add coverage for the new "Ack orphans" lane (this is on the 10 pre-existing failure list — SP28 may incidentally fix some by giving the test a real handler to assert against).
End-to-end smoke
backend/tests/test_e2e_999_to_claim_drawer.py— a single test that ingests a 999 with two AK2s against a seeded claim, then callsGET /api/claims/{id}andGET /api/acks/999/{id}/claimsand asserts the two views agree on the link set. (Mirrors the existingtest_e2e_837_to_835smoke.)
7. Risks & mitigations
- Risk: Migration
0018_claim_acks.sqlruns 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_acceptancesrunning alongsideapply_999_rejectionsdouble-touches the same DB session. Mitigation: both helpers share a single session; the link row is created inside the samefor sr in set_responses:loop as the rejection mutation, so there's exactly one transaction. - Risk: The two-pass join (D10) —
Batch.envelope.control_numberprimary +Claim.patient_control_numberfallback — 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. Theux_claim_acks_dedupunique 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_ackstable allows the same(ack_kind, ack_id, ak2_index)to map to multipleclaim_idrows. Tests cover the 1-to-N case explicitly. - Risk: 671 historical 999 acks with ST02=
0001remain 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_dedupunique index rejects re-ingests of the same file with asqlite3.IntegrityError. Mitigation: the orchestrator catches this specifically (mirrors the existingapply_999_rejectionsidempotency 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 theClaimDrawerpanel. Mitigation: the new/api/claims/{id}/acksendpoint explicitly filters them out; theto_ui_claim_ackserializer tags them withclaim_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 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. Thelinked_by="manual"audit column preserves who matched what.
8. Implementation shape (preview)
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).
- Migration —
0018_claim_acks.sql+ClaimAckORM model +db._reset_for_tests()updates. - Pure helpers —
cyclone.claim_acks.pymodule withapply_999_acceptances,apply_277ca_acks,apply_ta1_envelope_link,link_manual, andlookup_claims_for_ack_set_response(the two-pass join from D10 — Batch.envelope.control_number primary, Claim.patient_control_number fallback). - Store facade — extend
CycloneStorewithadd_claim_ack,list_acks_for_claim,list_claims_for_ack,find_ack_orphans, andbatch_envelope_index()(cached in-memory map ofenvelope.control_number → batch.id, invalidated on every newBatchwrite). Add theclaim_ack_written/claim_ack_droppedevent publish. - Handler integration — wire
apply_claim_ack_linksintohandle_999.handle, the 277CA handler, and the TA1 handler. Mirror the existingapply_999_rejectionscall site athandlers/handle_999.py:79-92. Insideapply_999_acceptances, the per-AK2 loop callslookup_claims_for_ack_set_response(sr)and emits oneclaim_acksrow per matched claim (one-ack-to-many-claims when the 837 batch shipped multiple claims). - API endpoints — new
api_routers/claim_acks.py(mirrorsapi_routers/acks.pyshape) + register the router. Extend the existing/api/claims/{id}and/api/acksserializers. - Frontend —
useClaimAcks+useAckClaimshooks,ClaimDrawerpanel,AckDrawerpanel, Acks page column, Inbox lane,useTailStoreslice.
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.
9. Non-goals
- Auto-rejection reconciliation (using 999 AK5 to mutate
Claim.statein ways other than REJECTED) — out of scope; that's a separate SP if the user wants it later. - Cross-batch deduplication (a single 999 acknowledging claims from multiple batches because the upstream sender batched them) — out of scope; the link row stores
set_control_numberand the operator can correlate via that field. - Bulk re-link UI (select 100 orphan acks and link them all in one click) — out of scope; the per-row "Link to claim…" dropdown handles the operator workflow at the volumes we see.
- TA1 per-claim granularity — X12 doesn't support it; envelope-level link to
Batchis the deepest we can go. - Notification when a long-pending claim receives an ack — out of scope; the live-tail covers the "in-app" case, and an email/SMS notification system doesn't exist yet.
10. Open questions for sign-off
- Confirm SP28 is the correct number (the skill text says "next free is SP25"; actual highest merged is SP27 — SP28 is the next free).
- Confirm the per-AK2 granularity (D1) is preferred over per-999-row granularity. The trade-off is one extra row per AK2 (vs. collapsing to one per 999 file) — this is what the user implicitly described as "it should be doing itself".
- Confirm manual-match requires admin role (D5, D9) — same posture as the existing
/api/inbox/remit-orphans/{id}/match-claim. - Confirm the Inbox ack-orphans lane (D7) is desired — alternative is to surface orphans only in a filter on the Acks page, not a new lane.
- Confirm the historical backfill is OUT of scope for the SP-N flow (becomes a one-shot script + runbook delta) — alternative is to include it as a Task 0 in the plan.