Files
cyclone/docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md
Nora ec87f98f02 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.
2026-07-02 11:16:53 -06:00

31 KiB
Raw Permalink Blame History

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_acks table (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_acceptances companion to apply_999_rejections that creates link rows for all AK2 set-responses (both accepted AND rejected).
  • Refactor apply_277ca_rejectionsapply_277ca_acks that creates link rows for every claim-status (accepted / payer-rejected / pending — anything with a payer_claim_control_number).
  • New apply_ta1_envelope_link that links a TA1 to the most-recent outbound 837 Batch whose ISA sender/receiver matches the TA1's sender_id / receiver_id (with an AckMatched activity event when the match is made).
  • New apply_claim_ack_links orchestrator 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 ClaimDrawer showing the linked 999/277CA/TA1 rows with deep-links to the matching AckDrawer.
  • One new "Matched claim" panel in AckDrawer showing the linked claim with deep-links to ClaimDrawer (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_written so the ClaimDrawer panel updates without a manual refresh.

Out of scope:

  • Restructuring the Ack / Ta1Ack / Two77caAck tables 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.state mutation behavior — apply_999_rejections still mutates REJECTED state, apply_277ca_rejections still mutates payer_rejected_* fields; SP28 only adds the link row on top.

2. Decisions (locked during brainstorming)

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

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.

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

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.

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:

  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_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 autoincrement
  • claim_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 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 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).

Indexes:

  • ix_claim_acks_claim_id(claim_id) — for GET /api/claims/{id}/acks and the live-tail subscription.
  • ix_claim_acks_ack(ack_kind, ack_id) — for GET /api/acks/{id}/claims and 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. kind999 | 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 new apply_claim_ack_links orchestrator. Wire shape unchanged; the response gains a new claim_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 gains linked_claim_ids: list[str] so the Acks page row can show the "🔗 N claims" badge inline.
  • GET /api/claims/{id} — the response gains ack_links: list[dict] (compact form: [{ack_id, ack_kind, set_accept_reject_code, parsed_at}]) so the ClaimDrawer can render the panel on initial load.
  • GET /api/acks/{id}/detail (and the TA1 / 277CA counterparts) — each gains claim_links: list[dict] so the AckDrawer can render the matched-claim panel on initial load.

4.3 New pubsub event

  • claim_ack_written — payload is the new to_ui_claim_ack row shape (3.3). Subscribed by both /api/claims/{id}/acks/stream and /api/acks/{id}/claims/stream. Mirrors the existing claim_written / remittance_written payload shape.
  • claim_ack_dropped — fired on DELETE /api/acks/{kind}/{id}/match-claim/{claim_id}. Payload is the dropped row id + the affected claim_id so 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 existing minimal_999.txt by 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 — submit minimal_999_2ak2.txt against a fixture-seeded claim table, assert claim_acks has 2 rows, one with set_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 no claim_acks row, assert claim_ack_links.orphans contains the PCN.
    • test_277ca_accepted_creates_link_with_no_state_mutation — submit a 277CA with classification="accepted", assert claim_acks row exists AND Claim.payer_rejected_at IS NULL.
    • test_277ca_rejected_creates_link_and_mutates_state — mirror the existing apply_277ca_rejections test, but also assert the link row.
    • test_ta1_links_to_most_recent_matching_batch — seed two Batch rows with distinct sender/receiver, submit a TA1 whose sender/receiver matches batch #2, assert the link row's batch_id is batch #2.
    • test_reingest_same_999_is_idempotent — submit the same 999 twice, assert exactly one claim_acks row per AK2 (the ux_claim_acks_dedup unique index enforces this).
    • test_manual_link_endpoint_idempotent — call POST /api/acks/999/{id}/match-claim twice with the same claim_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 in state=REVERSED, assert 409 (D5 / D9 — terminal state guard).
    • test_unlink_does_not_revert_claim_state — link a 999 with set_accept_reject_code="R", then unlink it, assert the claim is STILL state=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, assert ack_links is populated.
    • test_acks_list_includes_linked_claim_ids — GET /api/acks after ingesting, assert linked_claim_ids is populated per row.
    • test_claim_acks_stream_emits_claim_ack_written — open the stream, manually link via the endpoint, assert one item event arrives within 2s (uses the SP25 test pattern: synthetic Request + iterate body_iterator directly + 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 when ack_links is 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 renders linkedClaimCount.
  • 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 calls GET /api/claims/{id} and GET /api/acks/999/{id}/claims and asserts the two views agree on the link set. (Mirrors the existing test_e2e_837_to_835 smoke.)

7. Risks & mitigations

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

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

  1. Migration0018_claim_acks.sql + ClaimAck ORM model + db._reset_for_tests() updates.
  2. Pure helperscyclone.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. FrontenduseClaimAcks + 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.


9. Non-goals

  • Auto-rejection reconciliation (using 999 AK5 to mutate Claim.state in 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_number and 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 Batch is 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

  1. Confirm SP28 is the correct number (the skill text says "next free is SP25"; actual highest merged is SP27 — SP28 is the next free).
  2. 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".
  3. Confirm manual-match requires admin role (D5, D9) — same posture as the existing /api/inbox/remit-orphans/{id}/match-claim.
  4. 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.
  5. 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.