24 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.
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.
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; the patient_control_number used for the lookup. Preserved for orphan traceability.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
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 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.
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. - Store facade — extend
CycloneStorewithadd_claim_ack,list_acks_for_claim,list_claims_for_ack,find_ack_orphans. 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. - 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.
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.