diff --git a/docs/superpowers/plans/2026-07-02-cyclone-ack-claim-auto-link.md b/docs/superpowers/plans/2026-07-02-cyclone-ack-claim-auto-link.md
new file mode 100644
index 0000000..d5ffd07
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-02-cyclone-ack-claim-auto-link.md
@@ -0,0 +1,606 @@
+# 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. 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.
+
+**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 # + panel
+│ │ └── ClaimDrawer.test.tsx # ~ extend with panel assertions
+│ └── AckDrawer/
+│ ├── AckDrawer.tsx # + 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.**
+
+ Three public functions + one dataclass result. The module is pure (no DB session ownership — callers pass in the session, just like `apply_999_rejections`).
+
+ ```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 apply_999_acceptances(
+ session: Session,
+ parsed_999,
+ *,
+ ack_id: int,
+ claim_lookup: Callable[[str], Claim | 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.
+ """
+ ...
+
+ def apply_277ca_acks(
+ session: Session,
+ parsed_277ca,
+ *,
+ ack_id: int,
+ claim_lookup: Callable[[str], Claim | 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.
+ """
+ ...
+
+ 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 `claim_lookup(set_control_number)`. If None → `result.orphans.append(set_control_number)`, continue.
+ 2. Check for existing `claim_acks` row with `(claim_id, '999', ack_id, ak2_index=i)`. If exists → skip silently (idempotent).
+ 3. Insert `ClaimAck(claim_id=claim.id, ack_id=ack_id, ack_kind='999', ak2_index=i, set_control_number=pcn, set_accept_reject_code=code, linked_at=utcnow(), linked_by='auto')`.
+ 4. Append `(claim.id, i)` to `result.linked`.
+ 5. `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`.
+
+- [ ] **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 `claim_lookup(pcn)`. If None → orphan.
+ 3. Check dedup: `(claim_id, '277ca', ack_id, NULL)`.
+ 4. Insert `ClaimAck(claim_id=claim.id, ack_id=ack_id, ack_kind='277ca', ak2_index=NULL, set_control_number=pcn, set_accept_reject_code=status.status_code, linked_at=utcnow(), linked_by='auto')`.
+ 5. Append to result. 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 == [''].
+ ```
+
+ 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`. Each 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`.
+
+- [ ] **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": ,
+ }
+ ```
+ 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 five 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: ...
+ ```
+
+- [ ] **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
+ link_result = apply_999_acceptances(
+ session, result, ack_id=row.id, claim_lookup=_lookup,
+ )
+ ```
+ The `row.id` is the just-inserted `Ack` row from `cycl_store.add_ack(...)`. The `_lookup` closure is already defined. 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).
+
+- [ ] **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
+ link_result = apply_277ca_acks(session, parsed_277ca, ack_id=row.id, claim_lookup=_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": ,
+ "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(client, store):
+ # Seed a claim with patient_control_number='991102986'
+ # POST /api/parse-999 with the 999 that has AK2*837*991102986*...
+ # Assert: GET /api/claims/{id}/acks returns 1 row.
+ # Assert: GET /api/acks/{id}/claims returns 1 row.
+ # Assert: the two views agree on the link.
+ ```
+ Run the new test — 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
+ export function listAckClaims(kind: "999" | "277ca" | "ta1", ackId: number): Promise
+ export function matchAckToClaim(kind: ..., ackId: number, claimId: string): Promise
+ export function unmatchAck(kind: ..., ackId: number, claimId: string): Promise
+ export function listAckOrphans(kind: ...): Promise>
+ ```
+ 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` + `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 `` panel.**
+
+ Insert between `` and ``. 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 `` when the list is empty.
+
+- [ ] **Step 6.9: Extend `AckDrawer.tsx` with the new `` 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 `Claims` between "Accepted" and "Rejected". The cell renders a `{linkedClaimCount}` (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
+
+(Add any 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.)
\ No newline at end of file