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

46 KiB

Ack↔Claim Auto-Link Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Persist a durable link row (claim_acks) for every inbound 999 / 277CA / TA1 acknowledgment at parse time, so the operator can answer "which claims does this ack acknowledge?" and "which acks does this claim have?" without manual correlation, and surface those links in ClaimDrawer + AckDrawer + a new Inbox orphan lane.

Architecture: A new claim_acks join table (one row per AK2 set-response for 999, per ClaimStatus for 277CA, per TA1 envelope) is the durable record. Three pure helpers (apply_999_acceptances, apply_277ca_acks, apply_ta1_envelope_link) run inside the existing handler transactions alongside the existing rejection logic. The orchestrator (apply_claim_ack_links) is called from the same point in handle_999.handle / the 277CA handler / the TA1 handler. The auto-linker's per-AK2 join uses a two-pass lookup (D10 in the spec): primary is Batch.envelope.control_number (== 837 ST02) → claims in batch via batch_id; fallback is Claim.patient_control_number. A new claim_ack_written pubsub event drives two new stream endpoints (/api/claims/{id}/acks/stream, /api/acks/{kind}/{id}/claims/stream) so both drawers update in real time. Frontend mirrors the SP25 acks/ta1_acks live-tail extension pattern (useTailStore slice + useTailStream + useMergedTail) plus a manual-match dropdown for the orphan case.

Critical-path correction (added 2026-07-02): the original plan's _lookup closure queried Claim.patient_control_number == set_control_number. That join is empirically 0-effective on this codebase (1,398 acks, 0 matched) because Gainwell's 999 echoes the 837's ST02 not its CLM01. The corrected plan uses Batch.envelope.control_number (which holds the 837's ST02) as the primary join key, with Claim.patient_control_number as a fallback for the rare case where the sender filled CLM01 == ST02. The corrected join recovers 727 / 1,398 acks automatically (52%); the remaining 671 have ST02=0001 (no matching 837 batch) and remain real orphans surfaced by the Inbox ack-orphans lane.

Tech Stack: Python 3.11+, FastAPI, SQLAlchemy 2.x, SQLite (encrypted via SQLCipher), React 18 + TypeScript + Vite, TanStack Query, Zustand, Radix UI primitives.

Spec: docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md


File structure

backend/
├── src/cyclone/
│   ├── db.py                              # + ClaimAck(Base) + import surface
│   ├── migrations/
│   │   └── 0018_claim_acks.sql            # NEW: CREATE TABLE + indexes
│   ├── claim_acks.py                      # NEW: apply_999_acceptances,
│   │                                       #      apply_277ca_acks,
│   │                                       #      apply_ta1_envelope_link,
│   │                                       #      link_manual
│   ├── store/
│   │   ├── ui.py                          # + to_ui_claim_ack
│   │   ├── claim_acks.py                  # NEW: add_claim_ack,
│   │                                       #      list_acks_for_claim,
│   │                                       #      list_claims_for_ack,
│   │                                       #      find_ack_orphans,
│   │                                       #      remove_claim_ack
│   │   └── __init__.py                    # + CycloneStore facade methods
│   ├── handlers/
│   │   ├── handle_999.py                  # ~ call apply_claim_ack_links
│   │   ├── handle_277ca.py                # ~ same
│   │   └── handle_ta1.py                  # ~ same
│   ├── api.py                             # + extend /api/claims/{id}
│   │                                       #      + extend /api/acks
│   │                                       #      + extend /api/parse-{999,277ca,ta1}
│   │                                       #      responses with claim_ack_links
│   └── api_routers/
│       └── claim_acks.py                  # NEW: GET /api/claims/{id}/acks,
│                                         #      GET /api/acks/{kind}/{id}/claims,
│                                         #      POST /match-claim,
│                                         #      DELETE /match-claim/{cid},
│                                         #      GET /api/inbox/ack-orphans
│                                         #      + the two stream endpoints
├── tests/
│   ├── fixtures/
│   │   ├── minimal_999_2ak2.txt           # NEW (derived from minimal_999.txt)
│   │   └── minimal_277ca_2status.txt      # NEW (derived from minimal_277ca.txt)
│   ├── conftest.py                        # ~ add ClaimAck to _reset_for_tests
│   ├── test_apply_claim_ack_links.py      # NEW (8 tests, see spec §6)
│   ├── test_api_claim_acks.py             # NEW (3 tests, see spec §6)
│   └── test_e2e_999_to_claim_drawer.py    # NEW (1 smoke test)

src/
├── types/index.ts                         # + ClaimAck interface
├── lib/api.ts                             # + listClaimAcks, listAckClaims,
│                                          #      matchAckToClaim, unmatchAck,
│                                          #      listAckOrphans
├── hooks/
│   ├── useClaimAcks.ts                    # NEW
│   ├── useClaimAcks.test.ts               # NEW
│   ├── useAckClaims.ts                    # NEW
│   └── useAckClaims.test.ts               # NEW
├── store/
│   └── tail-store.ts                      # + claimAcks slice
├── hooks/
│   ├── useTailStream.ts                   # + dispatch for claim_acks
│   └── useMergedTail.ts                   # + merge for claim_acks
├── components/
│   ├── ClaimDrawer/
│   │   ├── ClaimDrawer.tsx                # + <Acknowledgments /> panel
│   │   └── ClaimDrawer.test.tsx           # ~ extend with panel assertions
│   └── AckDrawer/
│       ├── AckDrawer.tsx                  # + <MatchedClaim /> panel
│       └── AckDrawer.test.tsx             # ~ extend with panel assertions
├── pages/
│   ├── Acks.tsx                           # + "Claims" badge column
│   ├── Acks.test.tsx                      # ~ extend with column assertion
│   └── Inbox.tsx                          # + new "Ack orphans" lane
└── components/InboxLane/                  # NEW (or reuse existing lane
                                           #  component if one exists)

Task 0: Branch + base

  • Step 0.1: Create the SP28 branch from main.

    git checkout main
    git pull --ff-only
    git checkout -b sp28-ack-claim-auto-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.

    Four public functions + one dataclass result. The module is pure (no DB session ownership — callers pass in the session, just like apply_999_rejections). The fourth function is the two-pass join helper from D10.

    """SP28: per-ACK auto-linker.
    
    Three helpers, one per ACK kind, all run inside the same DB
    transaction that persists the Ack row. Each returns a slice of
    ClaimAckLinkResult describing what was linked / orphaned / skipped.
    
    See docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md §3.
    """
    from __future__ import annotations
    from dataclasses import dataclass, field
    from typing import Callable
    from sqlalchemy.orm import Session
    from cyclone.db import Claim, Batch
    
    
    @dataclass
    class ClaimAckLinkResult:
        linked: list[tuple[str, int]] = field(default_factory=list)
        # (claim_id, ak2_index) — empty ak2_index means 277CA/TA1 (no per-segment index)
    
        orphans: list[str] = field(default_factory=list)
        # set_control_numbers we couldn't resolve to a claim
    
    
    def lookup_claims_for_ack_set_response(
        session: Session,
        set_control_number: str,
        *,
        batch_envelope_index: Callable[[str], str | None] | None = None,
    ) -> list[Claim]:
        """D10: two-pass join for a single AK2 set_response / 277CA claim_status.
    
        Returns 0..N matching claims (one-ack-to-many when one 837 batch
        shipped multiple claims under one ST02).
    
        Pass 1 (primary): ``Batch.envelope.control_number == set_control_number``.
        If the caller supplies a pre-built ``batch_envelope_index`` (a callable
        mapping ST02 -> batch.id, built once at the start of the ingest),
        we skip the per-set-response ``Batch`` scan entirely. If no index
        is supplied we fall back to a session.query() against ``Batch``.
    
        Pass 2 (fallback): ``Claim.patient_control_number == set_control_number``.
        Catches the case where the sender filled CLM01 == ST02 (rare;
        Gainwell's TOC batches do not).
    
        Pass 1 wins. The two paths cannot both fire — if Pass 1 returns
        one or more claims, Pass 2 is skipped. This is the false-positive
        guard from spec §7.
        """
        ...
    
    
    def apply_999_acceptances(
        session: Session,
        parsed_999,
        *,
        ack_id: int,
        batch_envelope_index: Callable[[str], str | None] | None = None,
        pc_claim_lookup: Callable[[str], Claim | None] | None = None,
    ) -> ClaimAckLinkResult:
        """For every AK2 set-response, create a claim_acks row.
    
        Even rejected AK2s get a link row (so the ClaimDrawer panel can
        show the rejection inline). Orphans are returned but not linked.
        Idempotent: if (claim_id, '999', ack_id, ak2_index) already exists,
        skip silently.
    
        The two lookup arguments replace the single ``claim_lookup`` from
        the original spec — D10 split the join into a primary (ST02 via
        batch envelope index) and a fallback (PCN match). See spec §D10.
        """
        ...
    
    def apply_277ca_acks(
        session: Session,
        parsed_277ca,
        *,
        ack_id: int,
        batch_envelope_index: Callable[[str], str | None] | None = None,
        pc_claim_lookup: Callable[[str], Claim | None] | None = None,
    ) -> ClaimAckLinkResult:
        """For every ClaimStatus with a payer_claim_control_number,
        create a claim_acks row. Accepted AND rejected both link.
        Idempotent: (claim_id, '277ca', ack_id, NULL) is the dedup key.
    
        Same two-pass lookup as ``apply_999_acceptances`` (D10).
        """
        ...
    
    def apply_ta1_envelope_link(
        session: Session,
        parsed_ta1,
        *,
        ack_id: int,
        batch_lookup: Callable[[str, str], Batch | None],
    ) -> ClaimAckLinkResult:
        """Link a TA1 to the most-recent Batch whose sender_id/receiver_id
        matches. TA1 has no per-claim granularity — envelope level only.
    
        `batch_lookup` is (sender_id, receiver_id) -> Batch or None.
        Returns empty result if no batch matches.
        """
        ...
    
  • Step 2.2: Implement apply_999_acceptances.

    Walk parsed_999.set_responses (each has .set_control_number + .set_accept_reject.code). For each:

    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:

    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:

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

    def add_claim_ack(self, *, claim_id, batch_id, ack_id, ack_kind, ak2_index, set_control_number, set_accept_reject_code, linked_by, event_bus=None): ...
    def list_acks_for_claim(self, claim_id: str) -> list[db.ClaimAck]: ...
    def list_claims_for_ack(self, kind: str, ack_id: int) -> list[db.ClaimAck]: ...
    def find_ack_orphans(self, kind: str) -> list[dict]: ...
    def remove_claim_ack(self, link_id: int, event_bus=None) -> None: ...
    def batch_envelope_index(self) -> dict[str, str]: ...  # D10 — ST02 -> batch.id
    
  • Step 3.4: Verify facade wiring with a quick test.

    In backend/tests/test_apply_claim_ack_links.py, add:

    def test_store_facade_exposes_claim_ack_methods():
        assert hasattr(store, "add_claim_ack")
        assert hasattr(store, "list_acks_for_claim")
        assert hasattr(store, "list_claims_for_ack")
        assert hasattr(store, "find_ack_orphans")
        assert hasattr(store, "remove_claim_ack")
    

    Run pytest backend/tests/test_apply_claim_ack_links.py::test_store_facade_exposes_claim_ack_methods -v — should PASS.


Phase 4 — Handler integration

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

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

    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:

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

    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

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

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:

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