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.
38 KiB
Ack↔Claim Auto-Link Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Persist a durable link row (claim_acks) for every inbound 999 / 277CA / TA1 acknowledgment at parse time, so the operator can answer "which claims does this ack acknowledge?" and "which acks does this claim have?" without manual correlation, and surface those links in ClaimDrawer + AckDrawer + a new Inbox orphan lane.
Architecture: A new claim_acks join table (one row per AK2 set-response for 999, per ClaimStatus for 277CA, per TA1 envelope) is the durable record. Three pure helpers (apply_999_acceptances, apply_277ca_acks, apply_ta1_envelope_link) run inside the existing handler transactions alongside the existing rejection logic. The orchestrator (apply_claim_ack_links) is called from the same point in handle_999.handle / the 277CA handler / the TA1 handler. The auto-linker's per-AK2 join uses a two-pass lookup (D10 in the spec): primary is Batch.envelope.control_number (== 837 ST02) → claims in batch via batch_id; fallback is Claim.patient_control_number. A new claim_ack_written pubsub event drives two new stream endpoints (/api/claims/{id}/acks/stream, /api/acks/{kind}/{id}/claims/stream) so both drawers update in real time. Frontend mirrors the SP25 acks/ta1_acks live-tail extension pattern (useTailStore slice + useTailStream + useMergedTail) plus a manual-match dropdown for the orphan case.
Critical-path correction (added 2026-07-02): the original plan's _lookup closure queried Claim.patient_control_number == set_control_number. That join is empirically 0-effective on this codebase (1,398 acks, 0 matched) because Gainwell's 999 echoes the 837's ST02 not its CLM01. The corrected plan uses Batch.envelope.control_number (which holds the 837's ST02) as the primary join key, with Claim.patient_control_number as a fallback for the rare case where the sender filled CLM01 == ST02. The corrected join recovers 727 / 1,398 acks automatically (52%); the remaining 671 have ST02=0001 (no matching 837 batch) and remain real orphans surfaced by the Inbox ack-orphans lane.
Tech Stack: Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, Zustand, Radix UI primitives.
Spec: docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md
File structure
backend/
├── src/cyclone/
│ ├── db.py # + ClaimAck(Base) + import surface
│ ├── migrations/
│ │ └── 0018_claim_acks.sql # NEW: CREATE TABLE + indexes
│ ├── claim_acks.py # NEW: apply_999_acceptances,
│ │ # apply_277ca_acks,
│ │ # apply_ta1_envelope_link,
│ │ # link_manual
│ ├── store/
│ │ ├── ui.py # + to_ui_claim_ack
│ │ ├── claim_acks.py # NEW: add_claim_ack,
│ │ # list_acks_for_claim,
│ │ # list_claims_for_ack,
│ │ # find_ack_orphans,
│ │ # remove_claim_ack
│ │ └── __init__.py # + CycloneStore facade methods
│ ├── handlers/
│ │ ├── handle_999.py # ~ call apply_claim_ack_links
│ │ ├── handle_277ca.py # ~ same
│ │ └── handle_ta1.py # ~ same
│ ├── api.py # + extend /api/claims/{id}
│ │ # + extend /api/acks
│ │ # + extend /api/parse-{999,277ca,ta1}
│ │ # responses with claim_ack_links
│ └── api_routers/
│ └── claim_acks.py # NEW: GET /api/claims/{id}/acks,
│ # GET /api/acks/{kind}/{id}/claims,
│ # POST /match-claim,
│ # DELETE /match-claim/{cid},
│ # GET /api/inbox/ack-orphans
│ # + the two stream endpoints
├── tests/
│ ├── fixtures/
│ │ ├── minimal_999_2ak2.txt # NEW (derived from minimal_999.txt)
│ │ └── minimal_277ca_2status.txt # NEW (derived from minimal_277ca.txt)
│ ├── conftest.py # ~ add ClaimAck to _reset_for_tests
│ ├── test_apply_claim_ack_links.py # NEW (8 tests, see spec §6)
│ ├── test_api_claim_acks.py # NEW (3 tests, see spec §6)
│ └── test_e2e_999_to_claim_drawer.py # NEW (1 smoke test)
src/
├── types/index.ts # + ClaimAck interface
├── lib/api.ts # + listClaimAcks, listAckClaims,
│ # matchAckToClaim, unmatchAck,
│ # listAckOrphans
├── hooks/
│ ├── useClaimAcks.ts # NEW
│ ├── useClaimAcks.test.ts # NEW
│ ├── useAckClaims.ts # NEW
│ └── useAckClaims.test.ts # NEW
├── store/
│ └── tail-store.ts # + claimAcks slice
├── hooks/
│ ├── useTailStream.ts # + dispatch for claim_acks
│ └── useMergedTail.ts # + merge for claim_acks
├── components/
│ ├── ClaimDrawer/
│ │ ├── ClaimDrawer.tsx # + <Acknowledgments /> panel
│ │ └── ClaimDrawer.test.tsx # ~ extend with panel assertions
│ └── AckDrawer/
│ ├── AckDrawer.tsx # + <MatchedClaim /> panel
│ └── AckDrawer.test.tsx # ~ extend with panel assertions
├── pages/
│ ├── Acks.tsx # + "Claims" badge column
│ ├── Acks.test.tsx # ~ extend with column assertion
│ └── Inbox.tsx # + new "Ack orphans" lane
└── components/InboxLane/ # NEW (or reuse existing lane
# component if one exists)
Task 0: Branch + base
-
Step 0.1: Create the SP28 branch from main.
git checkout main git pull --ff-only git checkout -b sp28-ack-claim-auto-linkVerify with
git status(clean working tree, onsp28-ack-claim-auto-link). -
Step 0.2: Confirm the editable install + worktree path layout matches SP25. Per
CLAUDE.md, the editable install points at the main checkout, so when running tests from a worktree we prefix withPYTHONPATH=/home/tyler/dev/cyclone/.worktrees/sp28-ack-claim-auto-link/backend/src. Verify with:cd /home/tyler/dev/cyclone && git worktree add .worktrees/sp28-ack-claim-auto-link -b sp28-ack-claim-auto-link main cd .worktrees/sp28-ack-claim-auto-link && ls backend/src/cyclone/api.pyThe second command should print the file path; if it errors, re-run from the repo root.
Phase 1 — Data model
Task 1: Migration + ORM model
-
Step 1.1: Create the migration file
backend/src/cyclone/migrations/0018_claim_acks.sql.Mirror the shape of
0011_processed_inbound_files.sql(CREATE TABLE + CREATE INDEX statements). Schema (from spec §3.1):CREATE TABLE claim_acks ( id INTEGER PRIMARY KEY AUTOINCREMENT, claim_id TEXT REFERENCES claims(id) ON DELETE CASCADE, batch_id TEXT REFERENCES batches(id) ON DELETE CASCADE, ack_id INTEGER NOT NULL, ack_kind TEXT NOT NULL CHECK (ack_kind IN ('999', '277ca', 'ta1')), ak2_index INTEGER, set_control_number TEXT, set_accept_reject_code TEXT, linked_at DATETIME NOT NULL, linked_by TEXT NOT NULL CHECK (linked_by IN ('auto', 'manual')), CHECK ((claim_id IS NOT NULL) OR (batch_id IS NOT NULL)) ); CREATE INDEX ix_claim_acks_claim_id ON claim_acks(claim_id); CREATE INDEX ix_claim_acks_batch_id ON claim_acks(batch_id); CREATE INDEX ix_claim_acks_ack ON claim_acks(ack_kind, ack_id); -- Dedup: an auto-link of (claim, 999, ak2_index=3) is idempotent on re-ingest. CREATE UNIQUE INDEX ux_claim_acks_dedup ON claim_acks(claim_id, ack_kind, ack_id, ak2_index) WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL; -
Step 1.2: Add
ClaimAckORM model tobackend/src/cyclone/db.py.Insert after the
Two77caAckclass (~line 645). Mirror theCasAdjustmentshape (typed columns, norelationship(back_populates=...)). Columns + indexes match the migration. Add a docstring that references spec §3.2. -
Step 1.3: Update
backend/tests/conftest.py:_reset_for_tests()to dropClaimAcktoo.Add
ClaimAckto the list of tables truncated by the autouse fixture. Mirrors the existing entries forCasAdjustment/LineReconciliation. The order matters:claim_acksmust be truncated BEFOREclaimsandbatchesbecause of the FK cascade direction. -
Step 1.4: Sanity test —
pytest backend/tests/test_apply_claim_ack_links.pyshould FAIL withImportError(module doesn't exist yet).Expected output:
ModuleNotFoundError: No module named 'cyclone.claim_acks'. This is the red-green starting point for Phase 2.
Phase 2 — Pure helpers (orchestrator)
Task 2: apply_claim_ack_links orchestrator + per-edi helpers
-
Step 2.1: Create
backend/src/cyclone/claim_acks.pywith the public API.Four public functions + one dataclass result. The module is pure (no DB session ownership — callers pass in the session, just like
apply_999_rejections). The fourth function is the two-pass join helper from D10."""SP28: per-ACK auto-linker. Three helpers, one per ACK kind, all run inside the same DB transaction that persists the Ack row. Each returns a slice of ClaimAckLinkResult describing what was linked / orphaned / skipped. See docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md §3. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Callable from sqlalchemy.orm import Session from cyclone.db import Claim, Batch @dataclass class ClaimAckLinkResult: linked: list[tuple[str, int]] = field(default_factory=list) # (claim_id, ak2_index) — empty ak2_index means 277CA/TA1 (no per-segment index) orphans: list[str] = field(default_factory=list) # set_control_numbers we couldn't resolve to a claim def lookup_claims_for_ack_set_response( session: Session, set_control_number: str, *, batch_envelope_index: Callable[[str], str | None] | None = None, ) -> list[Claim]: """D10: two-pass join for a single AK2 set_response / 277CA claim_status. Returns 0..N matching claims (one-ack-to-many when one 837 batch shipped multiple claims under one ST02). Pass 1 (primary): ``Batch.envelope.control_number == set_control_number``. If the caller supplies a pre-built ``batch_envelope_index`` (a callable mapping ST02 -> batch.id, built once at the start of the ingest), we skip the per-set-response ``Batch`` scan entirely. If no index is supplied we fall back to a session.query() against ``Batch``. Pass 2 (fallback): ``Claim.patient_control_number == set_control_number``. Catches the case where the sender filled CLM01 == ST02 (rare; Gainwell's TOC batches do not). Pass 1 wins. The two paths cannot both fire — if Pass 1 returns one or more claims, Pass 2 is skipped. This is the false-positive guard from spec §7. """ ... def apply_999_acceptances( session: Session, parsed_999, *, ack_id: int, batch_envelope_index: Callable[[str], str | None] | None = None, pc_claim_lookup: Callable[[str], Claim | None] | None = None, ) -> ClaimAckLinkResult: """For every AK2 set-response, create a claim_acks row. Even rejected AK2s get a link row (so the ClaimDrawer panel can show the rejection inline). Orphans are returned but not linked. Idempotent: if (claim_id, '999', ack_id, ak2_index) already exists, skip silently. The two lookup arguments replace the single ``claim_lookup`` from the original spec — D10 split the join into a primary (ST02 via batch envelope index) and a fallback (PCN match). See spec §D10. """ ... def apply_277ca_acks( session: Session, parsed_277ca, *, ack_id: int, batch_envelope_index: Callable[[str], str | None] | None = None, pc_claim_lookup: Callable[[str], Claim | None] | None = None, ) -> ClaimAckLinkResult: """For every ClaimStatus with a payer_claim_control_number, create a claim_acks row. Accepted AND rejected both link. Idempotent: (claim_id, '277ca', ack_id, NULL) is the dedup key. Same two-pass lookup as ``apply_999_acceptances`` (D10). """ ... def apply_ta1_envelope_link( session: Session, parsed_ta1, *, ack_id: int, batch_lookup: Callable[[str, str], Batch | None], ) -> ClaimAckLinkResult: """Link a TA1 to the most-recent Batch whose sender_id/receiver_id matches. TA1 has no per-claim granularity — envelope level only. `batch_lookup` is (sender_id, receiver_id) -> Batch or None. Returns empty result if no batch matches. """ ... -
Step 2.2: Implement
apply_999_acceptances.Walk
parsed_999.set_responses(each has.set_control_number+.set_accept_reject.code). For each:- Call
lookup_claims_for_ack_set_response(session, sr.set_control_number, batch_envelope_index=batch_envelope_index)(D10 — two-pass join: ST02-via-batch primary, PCN fallback). If[]→result.orphans.append(sr.set_control_number), continue. - For each matched
claim:- Check for existing
claim_acksrow with(claim.id, '999', ack_id, ak2_index=i). If exists → skip silently (idempotent). - Insert
ClaimAck(claim_id=claim.id, ack_id=ack_id, ack_kind='999', ak2_index=i, set_control_number=sr.set_control_number, set_accept_reject_code=sr.set_accept_reject.code, linked_at=utcnow(), linked_by='auto'). - Append
(claim.id, i)toresult.linked.
- Check for existing
session.flush()at the end (do NOT commit — the caller owns the transaction).
Insert order matters: walk set_responses in order, capture
i = enumerate index. One AK2 can produce multipleclaim_acksrows when the 837 batch carried more than one claim under a shared ST02 (rare on this codebase but supported by the schema and the join). - Call
-
Step 2.3: Implement
apply_277ca_acks.Walk
parsed_277ca.claim_statuses. For each:- Get
pcn = status.payer_claim_control_number. If empty/None → orphan withstatus.status_code or "". - Call
lookup_claims_for_ack_set_response(session, pcn, batch_envelope_index=...)(D10 — same two-pass join as 999, except the lookup key ispayer_claim_control_numberfrom the 277CA instead ofset_control_numberfrom the 999). - For each matched
claim: check dedup(claim.id, '277ca', ack_id, NULL), insertClaimAck, append to result. - If
[]from the join → orphan. - Flush.
- Get
-
Step 2.4: Implement
apply_ta1_envelope_link.Walk is trivial: there's exactly one TA1 envelope per parsed file. The complexity is in
batch_lookup:- Query
Batchordered byparsed_at DESCwhere the most recent batch'ssender_id+receiver_idmatches the TA1'ssender_id+receiver_id. - If found: insert
ClaimAck(claim_id=NULL, batch_id=batch.id, ack_id=ack_id, ack_kind='ta1', ak2_index=NULL, set_control_number=NULL, set_accept_reject_code=parsed_ta1.ta1.ack_code, linked_at=utcnow(), linked_by='auto'). - If not found: orphan with
parsed_ta1.ta1.interchange_control_number or sender_id.
Helper signature for the test:
apply_ta1_envelope_link(session, parsed_ta1, ack_id=ack_id, batch_lookup=lambda s,r: session.query(Batch).filter_by(sender_id=s, receiver_id=r).order_by(Batch.parsed_at.desc()).first()). - Query
-
Step 2.5: Unit tests for the three helpers (without the DB layer yet).
In
backend/tests/test_apply_claim_ack_links.py:def test_999_linker_walks_set_responses(): # Build a stub parsed_999 with 2 set_responses, mock claim_lookup # that returns a claim for the first PCN and None for the second. # Assert: result.linked == [(claim.id, 0)], result.orphans == ['<second-pcn>']. def test_lookup_claims_two_pass_join(): # Seed two Claims, one with batch.envelope.control_number='991102989' # and patient_control_number='t991102989o1c120d', and a separate # claim with patient_control_number='991102987'. # Call lookup_claims_for_ack_set_response(session, '991102989') # — assert the first claim is returned via Pass 1 (batch). # Call with '991102987' — assert the second claim is returned via Pass 2 (PCN). # Call with '0001' (no match) — assert [] returned. def test_lookup_claims_pass1_wins_over_pass2(): # If a ST02 collides with a PCN from a different batch, Pass 1 # must win and Pass 2 must NOT also fire. Seed: # Claim A: batch_id=B1, patient_control_number='991102989' # Claim B: batch_id=B2 (with envelope.control_number='OTHER'), # patient_control_number='991102989' # Call lookup('991102989') — assert [Claim A] only (Pass 1 wins). def test_999_linker_emits_one_row_per_claim_in_multi_claim_batch(): # Seed Batch B with ST02='991102990' and TWO claims (batch_id=B). # Submit a 999 with one AK2 set_control_number='991102990'. # Assert result.linked == [(claim1.id, 0), (claim2.id, 0)].Run
pytest backend/tests/test_apply_claim_ack_links.py -v— should now PASS.
Phase 3 — Store facade
Task 3: store/claim_acks.py + facade re-exports + pubsub events
-
Step 3.1: Create
backend/src/cyclone/store/claim_acks.pywith the persistence layer.Mirrors
store/acks.pyshape:add_claim_ack,list_acks_for_claim,list_claims_for_ack,find_ack_orphans,remove_claim_ack, plusbatch_envelope_index()(the in-memory map for D10's primary join). Each mutating method opens its owndb.SessionLocal()(). Theadd_claim_ackpublishesclaim_ack_writtenvia the existing_safe_publishpattern;remove_claim_ackpublishesclaim_ack_dropped.The
batch_envelope_index()method walksBatchonce and returns adict[str, str]mappingenvelope.control_number → batch.id. Called once per ingest (current max 16 batches; trivial cost). Invalidated at module-import time (so a fresh ingest sees fresh state) AND explicitly after anyBatchwrite that touchesraw_result_json. -
Step 3.2: Add
to_ui_claim_ackserializer tobackend/src/cyclone/store/ui.py.Mirrors
to_ui_ackshape. Returns:{ "id": row.id, "claim_id": row.claim_id, "batch_id": row.batch_id, "ack_id": row.ack_id, "ack_kind": row.ack_kind, "ak2_index": row.ak2_index, "set_control_number": row.set_control_number, "set_accept_reject_code": row.set_accept_reject_code, "linked_at": row.linked_at.isoformat().replace("+00:00", "Z"), "linked_by": row.linked_by, "claim_state": <queried Claim.state or "n/a">, }The
claim_statelookup is a single join:SELECT state FROM claims WHERE id = :claim_id. For TA1 rows withclaim_id IS NULL, return"n/a". -
Step 3.3: Extend
CycloneStorefacade inbackend/src/cyclone/store/__init__.py.Add six methods that delegate to
store.claim_acks:def add_claim_ack(self, *, claim_id, batch_id, ack_id, ack_kind, ak2_index, set_control_number, set_accept_reject_code, linked_by, event_bus=None): ... def list_acks_for_claim(self, claim_id: str) -> list[db.ClaimAck]: ... def list_claims_for_ack(self, kind: str, ack_id: int) -> list[db.ClaimAck]: ... def find_ack_orphans(self, kind: str) -> list[dict]: ... def remove_claim_ack(self, link_id: int, event_bus=None) -> None: ... def batch_envelope_index(self) -> dict[str, str]: ... # D10 — ST02 -> batch.id -
Step 3.4: Verify facade wiring with a quick test.
In
backend/tests/test_apply_claim_ack_links.py, add:def test_store_facade_exposes_claim_ack_methods(): assert hasattr(store, "add_claim_ack") assert hasattr(store, "list_acks_for_claim") assert hasattr(store, "list_claims_for_ack") assert hasattr(store, "find_ack_orphans") assert hasattr(store, "remove_claim_ack")Run
pytest backend/tests/test_apply_claim_ack_links.py::test_store_facade_exposes_claim_ack_methods -v— should PASS.
Phase 4 — Handler integration
Task 4: Wire apply_claim_ack_links into the three handlers
-
Step 4.1: Extend
handle_999.handleto callapply_999_acceptances.After the existing
apply_999_rejectionscall (athandlers/handle_999.py:79-92), within the samewith db.SessionLocal()() as session:block, call:# Build the D10 batch envelope index once per ingest (cheap — ~16 batches today) batch_index = cycl_store.batch_envelope_index() def _pcn_lookup(pcn: str): # Fallback path (D10 Pass 2). Only fires when the ST02 lookup misses. return session.query(Claim).filter_by(patient_control_number=pcn).first() link_result = apply_999_acceptances( session, result, ack_id=row.id, batch_envelope_index=batch_index, pc_claim_lookup=_pcn_lookup, )The
row.idis the just-insertedAckrow fromcycl_store.add_ack(...). The_pcn_lookupclosure is the Pass-2 fallback (rarely hits). Thelink_result.orphansshould be logged but not surfaced as a rejection — orphan acks are normal (the 837 batch may not have been ingested yet, or the ST02 may be a0001placeholder from before the Gainwell MFT convention landed). -
Step 4.2: Same change for the 277CA handler.
Locate
handlers/handle_277ca.py(analogous shape tohandle_999.py). After the existingapply_277ca_rejectionscall, add:batch_index = cycl_store.batch_envelope_index() def _pcn_lookup(pcn: str): return session.query(Claim).filter_by(patient_control_number=pcn).first() link_result = apply_277ca_acks( session, parsed_277ca, ack_id=row.id, batch_envelope_index=batch_index, pc_claim_lookup=_pcn_lookup, ) -
Step 4.3: Same change for the TA1 handler.
Locate
handlers/handle_ta1.py. After the existingadd_ta1_ackcall (or in place of whereadd_ackis called), add:def _batch_lookup(sender_id, receiver_id): return session.query(Batch).filter_by(...).order_by(Batch.parsed_at.desc()).first() link_result = apply_ta1_envelope_link(session, parsed_ta1, ack_id=row.id, batch_lookup=_batch_lookup) -
Step 4.4: Extend the parse-endpoint responses in
backend/src/cyclone/api.pyto surfaceclaim_ack_links.The three
parse-999/parse-277ca/parse-ta1POST handlers gain a new field on their response (D4.2 in spec):return { "ack": <existing ack dict>, "claim_ack_links": { "linked": [{"claim_id": c, "ak2_index": i}, ...], "orphans": [...], } }Note: the field is a summary, not the full row list (the live-tail event has the full rows).
-
Step 4.5: Add end-to-end ingest tests.
In
backend/tests/test_e2e_999_to_claim_drawer.py:def test_ingest_999_creates_link_rows_via_st02_join(client, store): # Seed a Batch with raw_result_json.envelope.control_number='991102986' # and a Claim whose batch_id points to that Batch (the claim's # patient_control_number is set to the long form 't991102986o...' # to mirror the real-world t-prefix pattern from Gainwell/TOC). # POST /api/parse-999 with a 999 carrying AK2*837*991102986*... # Assert: GET /api/claims/{id}/acks returns 1 row. # Assert: GET /api/acks/999/{id}/claims returns 1 row. # Assert: the link row's set_control_number == '991102986' # (i.e. the AK2-2 value, not the claim's CLM01). # Assert: the two views agree on the link. def test_ingest_999_creates_link_rows_via_pcn_fallback(client, store): # Seed a Claim with patient_control_number='991102986' (no matching # Batch envelope — Pass 2 fallback path). # POST /api/parse-999 with AK2*837*991102986*... # Assert: GET /api/claims/{id}/acks returns 1 row (Pass 2 hit).Run both tests — should PASS.
Phase 5 — API endpoints
Task 5: api_routers/claim_acks.py + route registration
-
Step 5.1: Create
backend/src/cyclone/api_routers/claim_acks.py.Mirrors
api_routers/acks.pyshape. Five endpoints + two stream endpoints (see spec §4.1). Each read endpoint calls through theCycloneStorefacade; each write endpoint callslink_manualfromcyclone.claim_acks(which builds aClaimAckrow + delegates tostore.add_claim_ack). The stream endpoints follow the SP25 test pattern (syntheticRequest+ iteratebody_iteratordirectly +monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2")). -
Step 5.2: Register the router in
backend/src/cyclone/api.py.Insert near the existing
app.include_router(acks_router)line. The exact line depends on the current api.py layout — locate by grep foracks_router. -
Step 5.3: Extend
/api/claims/{id}to includeack_links.In the existing
GET /api/claims/{id}handler, add:ack_links = [ to_ui_claim_ack(link) for link in store.list_acks_for_claim(claim_id) if link.claim_id is not None # exclude TA1 batch-level rows ] return {**existing_response, "ack_links": ack_links} -
Step 5.4: Extend
/api/acks//api/ta1-acks//api/277ca-acksto includelinked_claim_ids.For each list endpoint, augment the
to_ui_*payload with:links = store.list_claims_for_ack("999", row.id) body["linked_claim_ids"] = [l.claim_id for l in links if l.claim_id]Same shape for the TA1 / 277CA variants (using
kind="ta1"/"277ca"). -
Step 5.5: Add the API tests in
backend/tests/test_api_claim_acks.py.Three tests (spec §6). Use the existing
TestClient(app)pattern. Run — should PASS. -
Step 5.6: Sanity test —
pytest backend/tests/test_api_claim_acks.py -v.All three tests should PASS. The full backend suite should show 0 new failures (the 1 pre-existing pollution in
test_provider_detail_includes_orphan_remit_receivedis documented in the baseline).
Phase 6 — Frontend
Task 6: Hooks + store slice + drawer panels + Inbox lane
-
Step 6.1: Add
ClaimAckinterface tosrc/types/index.ts.Mirrors the
Ackinterface shape. Add akind: "999" | "277ca" | "ta1"discriminator. -
Step 6.2: Extend
src/lib/api.tswith the new methods.export function listClaimAcks(claimId: string): Promise<ClaimAck[]> export function listAckClaims(kind: "999" | "277ca" | "ta1", ackId: number): Promise<ClaimAck[]> export function matchAckToClaim(kind: ..., ackId: number, claimId: string): Promise<ClaimAck> export function unmatchAck(kind: ..., ackId: number, claimId: string): Promise<void> export function listAckOrphans(kind: ...): Promise<Array<...>>Each uses
apiFetchwith the existingenvelopeunwrap pattern. -
Step 6.3: Create
src/hooks/useClaimAcks.ts+src/hooks/useClaimAcks.test.ts.TanStack Query, key
["claim-acks", claimId]. Returns{ data, isLoading, isError, error, refetch }. The test stubsvi.mock("@/lib/api", ...)and asserts the hook fires. -
Step 6.4: Create
src/hooks/useAckClaims.ts+src/hooks/useAckClaims.test.ts.Mirror of
useClaimAcksscoped to a single ack. The key is["ack-claims", kind, ackId]. -
Step 6.5: Extend
src/store/tail-store.tswith aclaimAcksslice.Add
claimAcks: Record<string, ClaimAck>+claimAckOrder: number[]. ImplementaddClaimAck,reset("claimAcks"), and the FIFO eviction inevictOldest. Mirrors the SP25 acks/ta1_acks slice pattern atsrc/store/tail-store.ts. -
Step 6.6: Extend
src/hooks/useTailStream.tsdispatch forclaim_acks.Add a case in the switch:
case "claim_ack_written": store.addClaimAck(event.data); break;Also extend
TailResource(or whichever enum/union lists the resource names) to include"claim-acks". -
Step 6.7: Extend
src/hooks/useMergedTail.tsforclaim-acks.Add a case that merges the snapshot + tail into the returned list. Mirrors the existing
claims/remittances/acks/ta1_acksbranches. -
Step 6.8: Extend
ClaimDrawer.tsxwith the new<Acknowledgments />panel.Insert between
<ServiceLines />and<Reconciliation />. The panel:- Calls
useClaimAcks(claimId)for the initial fetch. - Calls
useTailStream("claim-acks")+useMergedTail("claim-acks", data ?? [], (link) => link.claim_id === claimId)for the live update. - Renders a compact list of rows (one per ack link) with: code badge, ack kind, parsed_at,
→link toAckDrawerviaopenAck(link.ack_id, link.ack_kind). - Renders
<EmptyState>when the list is empty.
- Calls
-
Step 6.9: Extend
AckDrawer.tsxwith the new<MatchedClaim />panel.Insert as the FIRST panel (before "Set responses"). The panel:
- Calls
useAckClaims(kind, ackId)for the initial fetch. - Calls
useTailStream("claim-acks")+useMergedTail("claim-acks", data ?? [], (link) => link.ack_id === ackId). - For 999 / 277CA: renders one card per AK2 / ClaimStatus with PCN + ClaimStateBadge + charge +
→link to ClaimDrawer. - For TA1: renders the originating Batch (batch_id + filename + parsed_at) instead of a Claim.
- When the list is empty: renders a "Link to claim…" dropdown that calls
api.matchAckToClaim(...)and refetches on success.
- Calls
-
Step 6.10: Extend
pages/Acks.tsxwith a new "Claims" badge column.Add a new
<TableHead>Claims</TableHead>between "Accepted" and "Rejected". The cell renders a<Badge>{linkedClaimCount}</Badge>(mirroring the existing accepted/rejected count badges). When count > 0, the badge is clickable and navigates to the first linked claim's ClaimDrawer (deep-link viauseDrawerUrlState). -
Step 6.11: Extend
pages/Acks.test.tsxwith a column assertion.Existing test stubs
vi.mock("@/lib/api", ...). Add an assertion that the table row renders the "Claims" badge with the count from the mocked list response. -
Step 6.12: Extend
pages/Inbox.tsxwith a new "Ack orphans" lane.The Inbox currently has 5 lanes (per the skill text). Add a 6th lane:
- Header: "Ack orphans (N)" with a sub-tab for
999/277ca/ta1. - Body: a compact table of orphan acks fetched from
api.listAckOrphans(kind). - Per-row "Dismiss" button (no-op — orphans just stay visible until auto-link succeeds on a later 837 ingest).
- Per-row "Link to claim…" dropdown that calls
api.matchAckToClaim(...)and refreshes the lane.
- Header: "Ack orphans (N)" with a sub-tab for
-
Step 6.13: Extend
pages/Inbox.test.tsxwith the new lane assertion.This may incidentally fix some of the 10 pre-existing test failures if those tests were asserting on lane count or shape. Note any new failures in the merge commit body.
-
Step 6.14: Run
npm run typecheck+npm test+npm run lint.All three commands should exit 0. The 10 pre-existing frontend failures should remain unchanged (or fewer if Task 6.13 incidentally fixed any).
Phase 7 — Final verification
Task 7: Pre-merge checks
-
Step 7.1: Full backend suite.
cd /home/tyler/dev/cyclone/.worktrees/sp28-ack-claim-auto-link/backend && PYTHONPATH=$(pwd)/src .venv/bin/pytestExpected: 1 pre-existing pollution failure (
test_provider_detail_includes_orphan_remit_received), zero new failures. -
Step 7.2: Full frontend suite.
cd /home/tyler/dev/cyclone/.worktrees/sp28-ack-claim-auto-link && npm testExpected: 10 pre-existing failures (unchanged from baseline), zero new failures.
-
Step 7.3: Typecheck + lint.
npm run typecheck && npm run lintBoth should exit 0.
-
Step 7.4: Build.
npm run buildShould exit 0.
-
Step 7.5: Smoke the dev environment.
cd backend && .venv/bin/python -m cyclone serve & npm run dev & # In a separate terminal, log in as admin and: # 1. POST /api/parse-999 with backend/tests/fixtures/minimal_999.txt # 2. Open the Acks page → the row shows "Claims: 1" badge # 3. Click the badge → opens the linked claim's ClaimDrawer # 4. The Acknowledgments panel in ClaimDrawer shows the 999 row # 5. POST /api/parse-999 with a fixture that has no matching claim # 6. Open Inbox → "Ack orphans" lane shows the rowAll six steps should complete without error.
-
Step 7.6: Write the merge commit body.
After PR approval:
git checkout main git merge --no-ff sp28-ack-claim-auto-link -m "merge: SP28 ack-claim auto-link into main Closes the operator gap where 999 / 277CA / TA1 acks were persisted but never linked back to the claims they acknowledged. New claim_acks join table, auto-linker running at parse time, two new stream endpoints, ClaimDrawer + AckDrawer surface panels, and a new Inbox lane for orphan handling. Auth posture: any logged-in user can run the manual-match endpoint (deviation from the admin-only remit-orphans posture — documented in spec §D5/D9). Pre-existing baseline: 1 backend pollution + 10 frontend failures, both unchanged."DO NOT SQUASH. DO NOT REBASE. The per-commit history IS the audit trail.
Notes for the implementer
- Idempotency is non-negotiable. Every helper must be safe to re-run on the same input. The
ux_claim_acks_dedupunique index enforces this at the DB layer; the helpers also pre-check before insert to avoid the IntegrityError log noise. - Don't re-parse raw_json. The link row stores
set_control_number+set_accept_reject_codeprecisely so the UI doesn't need to re-parse. If you find yourself reaching forjson.loads(row.raw_json)in a serializer or endpoint, STOP — the link row has what you need. - The
claim_statefield onto_ui_claim_ackis a join, not a relationship. Arelationship()would eager-load all the claims; the join is cheaper and only runs when the serializer is called. - Test names must match the spec. The test file
test_apply_claim_ack_links.pylists 8 named tests in spec §6 — use those exact names so reviewers can grep from the spec to the diff. - The TA1 batch-link is a stretch goal. If
apply_ta1_envelope_linkends up too complex for SP28 (the sender/receiver matching is fuzzy), it's acceptable to ship without it and leave TA1 ack-row-only (no claim_acks row for TA1). The spec §D4 marks it as "envelope-level link to Batch"; if the implementer can't get the matching right in time, defer to a follow-up SP and note the deviation in the merge commit body.
Deviations log
2026-07-02 — D10 added, two-pass join replaces single PCN lookup
Finding: Empirically measured against prod (1,398 acks, 530 claims), the join Claim.patient_control_number == 999.set_control_number matched 0 acks. Gainwell's 999 AK2-2 echoes the source 837's ST02 (batch control number), not its CLM01 (patient control number). TOC's billing software fills CLM01 with the full claim id (t991102989o1c120d) and ST02 with the batch control number (991102989), so the two values never collide.
Fix: Auto-linker join rewritten as a two-pass lookup — primary is Batch.envelope.control_number (== 837 ST02) → claims via batch_id, fallback is Claim.patient_control_number. New pure helper lookup_claims_for_ack_set_response encapsulates both passes. New store method batch_envelope_index() builds an in-memory ST02 → batch.id map at the start of each ingest.
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 that surface in the Inbox ack-orphans lane.
Spec change: New decision D10 in spec. Plan changes: Step 2.1 grew by one helper; Step 2.2 rewritten to walk a list of matched claims per AK2 (one-ack-to-many); Step 2.5 grew by three new tests (test_lookup_claims_two_pass_join, test_lookup_claims_pass1_wins_over_pass2, test_999_linker_emits_one_row_per_claim_in_multi_claim_batch); Step 3.1 grew by batch_envelope_index(); Step 3.3 grew by one facade method; Step 4.1 / 4.2 rewritten to pass the index; Step 4.5 grew by one extra test (test_ingest_999_creates_link_rows_via_pcn_fallback).
(Add any further deviations from this plan to the merge commit body. Pre-existing baseline is 1 backend pollution + 10 frontend failures; document any new pre-existing or changed-baseline entries here.)