# 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`](../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.**
  ```bash
  git checkout main
  git pull --ff-only
  git checkout -b sp28-ack-claim-auto-link
  ```
  Verify with `git status` (clean working tree, on `sp28-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 with `PYTHONPATH=/home/tyler/dev/cyclone/.worktrees/sp28-ack-claim-auto-link/backend/src`. Verify with:
  ```bash
  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.py
  ```
  The 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):

  ```sql
  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 `ClaimAck` ORM model to `backend/src/cyclone/db.py`.**

  Insert after the `Two77caAck` class (~line 645). Mirror the `CasAdjustment` shape (typed columns, no `relationship(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 drop `ClaimAck` too.**

  Add `ClaimAck` to the list of tables truncated by the autouse fixture. Mirrors the existing entries for `CasAdjustment` / `LineReconciliation`. The order matters: `claim_acks` must be truncated BEFORE `claims` and `batches` because of the FK cascade direction.

- [ ] **Step 1.4: Sanity test — `pytest backend/tests/test_apply_claim_ack_links.py` should FAIL with `ImportError` (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.py` with 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.

  ```python
  """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:
  1. 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.
  2. For each matched `claim`:
     - Check for existing `claim_acks` row 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)` to `result.linked`.
  3. `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 **multiple** `claim_acks` rows 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).

- [ ] **Step 2.3: Implement `apply_277ca_acks`.**

  Walk `parsed_277ca.claim_statuses`. For each:
  1. Get `pcn = status.payer_claim_control_number`. If empty/None → orphan with `status.status_code or ""`.
  2. Call `lookup_claims_for_ack_set_response(session, pcn, batch_envelope_index=...)` (D10 — same two-pass join as 999, except the lookup key is `payer_claim_control_number` from the 277CA instead of `set_control_number` from the 999).
  3. For each matched `claim`: check dedup `(claim.id, '277ca', ack_id, NULL)`, insert `ClaimAck`, append to result.
  4. If `[]` from the join → orphan.
  5. Flush.

- [ ] **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 `Batch` ordered by `parsed_at DESC` where the most recent batch's `sender_id` + `receiver_id` matches the TA1's `sender_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())`.

- [ ] **Step 2.5: Unit tests for the three helpers (without the DB layer yet).**

  In `backend/tests/test_apply_claim_ack_links.py`:
  ```python
  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.py` with the persistence layer.**

  Mirrors `store/acks.py` shape: `add_claim_ack`, `list_acks_for_claim`, `list_claims_for_ack`, `find_ack_orphans`, `remove_claim_ack`, **plus** `batch_envelope_index()` (the in-memory map for D10's primary join). Each mutating method opens its own `db.SessionLocal()()`. The `add_claim_ack` publishes `claim_ack_written` via the existing `_safe_publish` pattern; `remove_claim_ack` publishes `claim_ack_dropped`.

  The `batch_envelope_index()` method walks `Batch` once and returns a `dict[str, str]` mapping `envelope.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 any `Batch` write that touches `raw_result_json`.

- [ ] **Step 3.2: Add `to_ui_claim_ack` serializer to `backend/src/cyclone/store/ui.py`.**

  Mirrors `to_ui_ack` shape. Returns:
  ```python
  {
      "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_state` lookup is a single join: `SELECT state FROM claims WHERE id = :claim_id`. For TA1 rows with `claim_id IS NULL`, return `"n/a"`.

- [ ] **Step 3.3: Extend `CycloneStore` facade in `backend/src/cyclone/store/__init__.py`.**

  Add six methods that delegate to `store.claim_acks`:
  ```python
  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:
  ```python
  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.handle` to call `apply_999_acceptances`.**

  After the existing `apply_999_rejections` call (at `handlers/handle_999.py:79-92`), within the same `with db.SessionLocal()() as session:` block, call:
  ```python
  # 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.id` is the just-inserted `Ack` row from `cycl_store.add_ack(...)`. The `_pcn_lookup` closure is the Pass-2 fallback (rarely hits). The `link_result.orphans` should 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 a `0001` placeholder from before the Gainwell MFT convention landed).

- [ ] **Step 4.2: Same change for the 277CA handler.**

  Locate `handlers/handle_277ca.py` (analogous shape to `handle_999.py`). After the existing `apply_277ca_rejections` call, add:
  ```python
  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 existing `add_ta1_ack` call (or in place of where `add_ack` is called), add:
  ```python
  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.py` to surface `claim_ack_links`.**

  The three `parse-999` / `parse-277ca` / `parse-ta1` POST handlers gain a new field on their response (D4.2 in spec):
  ```python
  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`:
  ```python
  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.py` shape. Five endpoints + two stream endpoints (see spec §4.1). Each read endpoint calls through the `CycloneStore` facade; each write endpoint calls `link_manual` from `cyclone.claim_acks` (which builds a `ClaimAck` row + delegates to `store.add_claim_ack`). The stream endpoints follow the SP25 test pattern (synthetic `Request` + iterate `body_iterator` directly + `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 for `acks_router`.

- [ ] **Step 5.3: Extend `/api/claims/{id}` to include `ack_links`.**

  In the existing `GET /api/claims/{id}` handler, add:
  ```python
  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-acks` to include `linked_claim_ids`.**

  For each list endpoint, augment the `to_ui_*` payload with:
  ```python
  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_received` is documented in the baseline).

---

## Phase 6 — Frontend

### Task 6: Hooks + store slice + drawer panels + Inbox lane

- [ ] **Step 6.1: Add `ClaimAck` interface to `src/types/index.ts`.**

  Mirrors the `Ack` interface shape. Add a `kind: "999" | "277ca" | "ta1"` discriminator.

- [ ] **Step 6.2: Extend `src/lib/api.ts` with the new methods.**

  ```typescript
  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 `apiFetch` with the existing `envelope` unwrap 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 stubs `vi.mock("@/lib/api", ...)` and asserts the hook fires.

- [ ] **Step 6.4: Create `src/hooks/useAckClaims.ts` + `src/hooks/useAckClaims.test.ts`.**

  Mirror of `useClaimAcks` scoped to a single ack. The key is `["ack-claims", kind, ackId]`.

- [ ] **Step 6.5: Extend `src/store/tail-store.ts` with a `claimAcks` slice.**

  Add `claimAcks: Record<string, ClaimAck>` + `claimAckOrder: number[]`. Implement `addClaimAck`, `reset("claimAcks")`, and the FIFO eviction in `evictOldest`. Mirrors the SP25 acks/ta1_acks slice pattern at `src/store/tail-store.ts`.

- [ ] **Step 6.6: Extend `src/hooks/useTailStream.ts` dispatch for `claim_acks`.**

  Add a case in the switch:
  ```typescript
  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.ts` for `claim-acks`.**

  Add a case that merges the snapshot + tail into the returned list. Mirrors the existing `claims` / `remittances` / `acks` / `ta1_acks` branches.

- [ ] **Step 6.8: Extend `ClaimDrawer.tsx` with 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 to `AckDrawer` via `openAck(link.ack_id, link.ack_kind)`.
  - Renders `<EmptyState>` when the list is empty.

- [ ] **Step 6.9: Extend `AckDrawer.tsx` with 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.

- [ ] **Step 6.10: Extend `pages/Acks.tsx` with 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 via `useDrawerUrlState`).

- [ ] **Step 6.11: Extend `pages/Acks.test.tsx` with 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.tsx` with 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.

- [ ] **Step 6.13: Extend `pages/Inbox.test.tsx` with 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.**

  ```bash
  cd /home/tyler/dev/cyclone/.worktrees/sp28-ack-claim-auto-link/backend && PYTHONPATH=$(pwd)/src .venv/bin/pytest
  ```
  Expected: 1 pre-existing pollution failure (`test_provider_detail_includes_orphan_remit_received`), zero new failures.

- [ ] **Step 7.2: Full frontend suite.**

  ```bash
  cd /home/tyler/dev/cyclone/.worktrees/sp28-ack-claim-auto-link && npm test
  ```
  Expected: 10 pre-existing failures (unchanged from baseline), zero new failures.

- [ ] **Step 7.3: Typecheck + lint.**

  ```bash
  npm run typecheck && npm run lint
  ```
  Both should exit 0.

- [ ] **Step 7.4: Build.**

  ```bash
  npm run build
  ```
  Should exit 0.

- [ ] **Step 7.5: Smoke the dev environment.**

  ```bash
  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 row
  ```
  All six steps should complete without error.

- [ ] **Step 7.6: Write the merge commit body.**

  After PR approval:
  ```bash
  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_dedup` unique 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_code` precisely so the UI doesn't need to re-parse. If you find yourself reaching for `json.loads(row.raw_json)` in a serializer or endpoint, STOP — the link row has what you need.
- **The `claim_state` field on `to_ui_claim_ack` is a join, not a relationship.** A `relationship()` 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.py` lists 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_link` ends 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.)

### 2026-07-02 (post-implementation) — Backend architecture deviations

**Finding:** During implementation, four architectural refinements were needed to honor the existing store/pubsub contract and SQLite's single-writer-at-a-time constraint. All four preserve the spec's behavioral contract; they only reshape the implementation seams.

1. **Helpers return dataclasses instead of inserting directly.** The plan specified that `apply_999_acceptances` / `apply_277ca_acks` / `apply_ta1_envelope_link` would call `session.add(ClaimAck(...))` themselves. To preserve the publish-from-store contract (`claim_ack_written` fires only from `CycloneStore.add_claim_ack`), the helpers now **return** `ClaimAckLinkRow` dataclasses. The handler is responsible for persisting each row via `cycl_store.add_claim_ack(...)`. End behavior is identical: exactly one `claim_acks` row per AK2-to-claim match, with the live-tail event fired on persist. **Affected tests:** the test file's mock of `ClaimAck` insert was rewritten to assert against the returned dataclasses (15 tests still pass).

2. **Handlers commit the work session before calling `cycl_store.add_claim_ack`.** `CycloneStore` methods open their own SQLAlchemy sessions; SQLite raises `database is locked` when multiple sessions from the same thread write concurrently. The pattern is: build the `batch_envelope_index` outside the work session, run all helper work inside the work session, snapshot `result.linked` to a list, commit, then call `cycl_store.add_claim_ack` per row in fresh sessions. The TA1 handler follows the same pattern for symmetry even though it makes only one store call.

3. **`batch_envelope_index` accepts dict OR callable.** The store returns a plain `dict[str, str]`; tests passed closures. `lookup_claims_for_ack_set_response` normalizes both shapes at the top so callers don't have to wrap.

4. **`link_manual` returns a `ClaimAckLinkRow`** (not a `(row, created)` tuple). Same publish-from-store rationale as (1). Idempotency is enforced by the dedup pre-check in the API endpoint + the partial unique index at the DB layer.

### 2026-07-02 (post-implementation) — Frontend deviations

1. **TA1 orphan row shape in Inbox lane.** The spec said "TA1 batch-level rows have `claim_id == null`" and the `AckDrawer` panel should render them as originating-Batch cards (per D4). The Inbox lane's TA1 row shape was not enumerated; the orphan lane renders TA1 rows as `kind="ta1"` rows with `linkedClaimIds: []` and the `batchId` shown inline.

2. **ClaimDrawer → /acks cross-page nav.** `useAckDrawerUrlState` only updates URL on the `/acks` page, not from `/claims`. The `AcknowledgmentsPanel` uses `useNavigate()` to navigate to `/acks?ack=<id>` (mirrors the existing Inbox → RemitDrawer cross-page nav pattern).

3. **Acks page TA1 column header.** The TA1 register's column header reads "Batches" instead of "Claims" since TA1 rows link to originating Batches (D4), not claims. The 999 register uses "Claims". Cosmetic deviation to keep the TA1 surface self-documenting.

4. **Inbox ack-orphans lane position.** Placed between `payer_rejected` and `candidates` rather than as a trailing 6th lane. Matches the spec's "Mirrors the existing 'Payer-rejected' lane shape" guidance (operator eye-flow groups all rejection-class triage together before reconciliation opportunities).

5. **`fmt.usd` instead of `fmt.money`** in `MatchedClaimPanel` (commit `3fd2c44`). The codebase has both helpers; the `fmt.usd` formatter is the canonical claim-money formatter per `src/lib/format.ts`. Cosmetic fix caught in the typecheck pass.

### 2026-07-02 (post-implementation) — Test baseline confirmed

- **Backend:** 25/25 SP28 tests pass. Full backend suite: 1210 passed, 10 skipped, 6 errors + 1 failed — matches pre-SP28 baseline (the 1+6 are the documented `test_payer_summary.py` + `test_provider_extended_response.py` pollution; all 7 pass in isolation).
- **Frontend:** 36 new SP28 tests pass. Full frontend suite: 580 passed, 5 failed — matches pre-SP28 baseline failures (`api.test.ts` getBatchDiff, `tail-stream.test.ts` acks+ta1_acks targeting, `Inbox.test.tsx` SP14 payer-rejected, `InboxHeader.test.tsx`). No new frontend failures.
- **Typecheck (frontend):** 17 pre-existing errors remain; zero in SP28-introduced files.
- **Lint:** `eslint` is missing from `devDependencies` (pre-existing); the `npm run lint` script is non-functional. Not in scope for SP28.
- **Build:** `tsc -b` fails on pre-existing errors in `ClaimCard837.test.tsx`, `Upload.tsx`, `Lane.tsx`, etc. — none in SP28-introduced files. `vite build` would succeed if `tsc -b` were clean; tracked as follow-up.

### 2026-07-02 (post-implementation) — File footprint

**46 files changed, 6,467 insertions(+), 22 deletions(-).** Per-tree breakdown:

- **Backend created** (7): `migrations/0018_claim_acks.sql`, `claim_acks.py`, `store/claim_acks.py`, `api_routers/claim_acks.py`, `tests/test_apply_claim_ack_links.py`, `tests/test_api_claim_acks.py`, `tests/test_e2e_999_to_claim_drawer.py`.
- **Backend modified** (10): `db.py`, `store/ui.py`, `store/__init__.py`, `api.py`, `api_routers/acks.py`, `api_routers/ta1_acks.py`, `handlers/handle_999.py`, `handlers/handle_277ca.py`, `handlers/handle_ta1.py`, plus test assertion bumps in `tests/test_acks.py` + `tests/test_db_migrate.py`.
- **Frontend created** (15): `useClaimAcks` + `useAckClaims` + `useAckOrphans` hooks (+ tests), `AcknowledgmentsPanel` + `MatchedClaimPanel` + `AckOrphansLane` components, type definitions in `src/types/index.ts`.
- **Frontend modified** (11): `ClaimDrawer.tsx` + `AckDrawer.tsx` + `pages/Acks.tsx` + `pages/Inbox.tsx` (panel/lane mounting), `tail-store.ts` + `useTailStream.ts` + `useMergedTail.ts` (live-tail extension), `lib/api.ts` (claim_ack methods), `auth/api.ts` (joinUrl refactor), plus 4 test file expansions.
---

## SP28 follow-ups (post-merge, 2026-07-02)

### Bug fix — `Batch.kind` filter mismatch (shipped as merge `02b879a`)

The original SP28 helpers (`batch_envelope_index()` + `lookup_claims_for_ack_set_response()` fallback + the two `_batch_lookup` closures in `handle_ta1.py` / `api.py`) all filter `Batch.kind == "837"`. But production `Batch` rows are written with `kind="837p"` (lowercase `p`, per `api.py:443` and `store/records.py:58`). Result on prod: the batch envelope index was empty, every Pass-1 (ST02 via batch) join missed, and the user's claim `t991102989o1c120d` (batch `0e2726b3...`, envelope.control_number=`991102989`) had zero claim_acks rows despite having 334 incoming 999 AK2s with set_control_number=`991102989`.

**Why tests passed:** the SP28 test files seeded `Batch(kind="837", ...)` — internally consistent with the wrong helper filter, but never exercised the prod value. The 4 production sites + 3 test files + 1 test closure were all updated to `kind="837p"`. 25 SP28 tests + 20 handler tests pass post-fix. Branch + atomic merge: `sp28-fix-batch-kind-mismatch` → `02b879a`.

**Files touched (7):**
- `backend/src/cyclone/api.py` (1 line)
- `backend/src/cyclone/claim_acks.py` (1 line)
- `backend/src/cyclone/handlers/handle_ta1.py` (1 line)
- `backend/src/cyclone/store/claim_acks.py` (1 line)
- `backend/tests/test_apply_claim_ack_links.py` (2 lines — seed + test closure)
- `backend/tests/test_api_claim_acks.py` (1 line)
- `backend/tests/test_e2e_999_to_claim_drawer.py` (1 line)

### One-time backfill script (`/tmp/backfill_claim_acks.py`, in-container)

The 1,398 existing 999 acks (and the 1 277CA + 1 TA1) pre-date the auto-linker. The bug fix above makes NEW acks auto-link correctly, but the existing rows need a one-time pass to populate `claim_acks`. The script walks the SP28 helpers against the live DB (no re-ingest required) and persists via `CycloneStore.add_claim_ack(...)` so live-tail events fire normally. Idempotent via the partial unique index + Python pre-check in each helper.

**Run from the prod container:**
```bash
docker exec -e CYCLONE_DB_URL=sqlite:////var/lib/cyclone/db/cyclone.db \
  cyclone-backend-1 python /tmp/backfill_claim_acks.py
```

**Result on prod (2026-07-02 18:30 UTC):**
- 999: 1,398 acks → 66,559 link rows inserted → 867 set_control_numbers orphaned (mostly the 671 with ST02=`0001` + a few rare mismatches)
- 277CA: 1 ack → 0 links (sample data, PCNs don't match real claims)
- TA1: 1 ack → 0 links (sender/receiver mismatch)
- User's claim `t991102989o1c120d`: 334 claim_acks rows (all 999 type, batch=`0e2726b3...`, ack_kind=999, all `set_accept_reject_code='A'`)
- API verification: `GET /api/claims/t991102989o1c120d/acks` → 334 rows; `GET /api/claims/t991102989o1c120d` now includes `ack_links: [...334 rows...]`

**Note on the script:** it's a one-shot in-container tool, not part of the shipped codebase. The 671 ST02=`0001` orphans + 196 other orphans will surface in the new Inbox `ack-orphans` lane as designed — operator can clear them via the manual-match dropdown (D4 + D5 in spec).
