Files
cyclone/docs/superpowers/plans/2026-07-02-cyclone-ack-claim-auto-link.md
T

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


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-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:

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

    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)

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

    """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:

    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>'].
    

    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:

    {
        "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 five 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: ...
    
  • 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

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

    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:

    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:

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

    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(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:

    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:

    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.

    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:

    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.

    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.

    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.

    npm run typecheck && npm run lint
    

    Both should exit 0.

  • Step 7.4: Build.

    npm run build
    

    Should 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 row
    

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