Files
cyclone/docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md
T

18 KiB

SP29 — Inbox 999-rejected claim drill 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: Surface the SP28-linkable 999 AK2 set-response evidence inline on each rejected-lane row in the Inbox and add a per-row Resubmit button that downloads the single-claim corrected 837. The operator can identify and act on each rejected claim without opening the drawer or running the bulk modal.

Architecture: Add a claim_acks summary field to the rejected-lane row payload from /api/inbox/lanes (one batched query for the whole lane — no N+1). Extend InboxClaimRow in inbox-api.ts with the new field. Extend InboxRow to render up to 3 AK2 chips inline plus a per-row Resubmit button that calls the existing serializeClaim837 client and writes via downloadTextFile. No new endpoints, no new dependencies.

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-999-rejected-drill-design.md


File structure

backend/
├── src/cyclone/
│   └── inbox_lanes.py                     # ~ attach claim_acks summary to rejected rows
└── tests/
    └── test_inbox_lanes.py                # + 1 test for the new field (extend if file exists)

src/
├── lib/
│   └── inbox-api.ts                       # + InboxClaimRow.claim_acks type
├── components/inbox/
│   ├── InboxRow.tsx                       # + AK2 chip sub-row + per-row Resubmit button
│   └── InboxRow.test.tsx                  # + 1 test for chips
└── pages/
    ├── Inbox.tsx                          # + onResubmitOne handler threading
    └── Inbox.test.tsx                     # + 1 test for per-row download

Phase 1 — Backend: lane row payload

Task 1: Attach claim_acks summary to each rejected-lane row

  • Step 1.1: Read backend/src/cyclone/inbox_lanes.py and locate the rejected-lane loop.

    Around inbox_lanes.py:180-195 — the rejected_claims = (...) query, the _line_count_lookup(...) call, and the _claim_to_row(...) loop. The new claim_acks attach should sit AFTER the loop (so it operates on the dict rows), pulling from a single batched SQL query keyed by claim_id IN (...).

  • Step 1.2: Add a batched query helper inside compute_lanes.

    After the rejected loop, before the function returns, add:

    rejected_ids = [r["id"] for r in lanes.rejected]
    rejected_ack_summary = _ack_summary_for_claims(session, rejected_ids)
    for row in lanes.rejected:
        row["claim_acks"] = rejected_ack_summary.get(row["id"])  # None when no 999 acks linked
    

    The helper lives at module scope (above compute_lanes):

    def _ack_summary_for_claims(session: Session, claim_ids: list[str]) -> dict[str, dict]:
        """Attach a `claim_acks` summary to every rejected-lane row.
    
        SP29: per rejected claim, summarize the linked 999 acks so the
        Inbox row can render AK2 chips + a per-row Resubmit button
        without an extra fetch. Newest 5 acks + total + rejected count.
    
        Returns:
            {claim_id: {"total": int, "rejected": int, "items": [...]}, ...}
            (claims with zero 999 acks are NOT in the returned dict, so
            the caller can map.get(...) and treat absence as null.)
        """
        if not claim_ids:
            return {}
        from cyclone.db import ClaimAck  # late import — mirrors DB module shape
        # Fetch newest 5 linked 999 acks per claim in one round-trip
        from sqlalchemy import select
        # Subquery to pick the 5 newest per claim
        # Simpler: fetch ALL then trim in Python (claim_id count is low — lanes page
        # only carries rejected claims, typically a few dozen)
        rows = (
            session.query(
                ClaimAck.claim_id,
                ClaimAck.ack_id,
                ClaimAck.set_control_number,
                ClaimAck.set_accept_reject_code,
                ClaimAck.ak2_index,
                ClaimAck.linked_at,
            )
            .filter(
                ClaimAck.claim_id.in_(claim_ids),
                ClaimAck.ack_kind == "999",
            )
            .order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc())
            .all()
        )
        grouped: dict[str, list[tuple]] = {}
        for cid, aid, scn, code, ak2i, lat in rows:
            grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat))
    
        rejected_codes = {"R", "E", "X"}
        out: dict[str, dict] = {}
        for cid, items in grouped.items():
            total = len(items)
            rejected_count = sum(1 for it in items if it[2] in rejected_codes)
            trimmed = items[:5]
            out[cid] = {
                "total": total,
                "rejected": rejected_count,
                "items": [
                    {
                        "ack_id": aid,
                        "set_control_number": scn,
                        "set_accept_reject_code": code or "",
                        "ak2_index": ak2i,
                        "linked_at": _isoformat(lat),
                    }
                    for (aid, scn, code, ak2i, lat) in trimmed
                ],
            }
        return out
    

    Performance note: the lanes page only carries rejected claims (a few dozen at most on this codebase; the prod count of rejected lane rows can be confirmed with a count query but is small by construction). A bounded fetch + Python trim is fine for v1. If a future SP finds lane payloads blowing up, add claim_acks(claim_id, ack_kind, linked_at) composite index in a follow-up migration.

  • Step 1.3: Wire the helper into compute_lanes.

    Place the two lines from Step 1.2 immediately after the rejected-lane loop, before any return statement. Do not touch the payer_rejected loop — that lane has its own payer_rejected_* fields and doesn't need claim_acks (its rejections come from 277CA, not 999).

  • Step 1.4: Add a test in backend/tests/test_inbox_lanes.py.

    def test_inbox_lanes_attaches_claim_acks_summary_for_rejected():
        # Seed: 1 rejected claim, with 3 linked 999 acks (2 accepted, 1 rejected)
        # Call compute_lanes (or /api/inbox/lanes via TestClient)
        # Assert: rejected[0]["claim_acks"]["total"] == 3
        # Assert: rejected[0]["claim_acks"]["rejected"] == 1
        # Assert: items has len <= 5 and is sorted by linked_at DESC
        # Assert: items contain ack_id + set_control_number + set_accept_reject_code
    

    If test_inbox_lanes.py doesn't exist, create it and put the test there. The test should:

    • Create a Claim with state=ClaimState.REJECTED
    • Create 3 Ack rows (no specific kind — they're stored in the acks table)
    • Create 3 ClaimAck rows linking the claim to those acks via the claim_acks table
    • Set 2 of the claim_acks rows' set_accept_reject_code to A and 1 to R
    • Call /api/inbox/lanes via TestClient
    • Assert the rejected[0].claim_acks shape matches
  • Step 1.5: Run the new test — should PASS.

    cd backend && .venv/bin/pytest tests/test_inbox_lanes.py -v
    

Phase 2 — Frontend types + render

Task 2: Extend InboxClaimRow type

  • Step 2.1: Edit src/lib/inbox-api.ts (around line 54 where InboxClaimRow is declared).

    Add the new optional field:

    export type InboxClaimAckItem = {
      ack_id: number;
      set_control_number: string;
      set_accept_reject_code: string;
      ak2_index: number;
      linked_at: string;
    };
    
    export type InboxClaimRow = {
      // ... existing fields ...
      /**
       * SP29: present on `rejected`-lane rows (999 envelope rejects).
       * Newest 5 AK2 set-responses for this claim plus a `total` /
       * `rejected` summary. `null` when the claim has zero linked
       * 999 acks.
       */
      claim_acks?: {
        total: number;
        rejected: number;
        items: InboxClaimAckItem[];
      } | null;
    };
    

Task 3: Extend InboxRow with chip column + per-row Resubmit button

  • Step 3.1: Locate the row render in src/components/inbox/InboxRow.tsx.

    Around the 7-column flexbox (lines 73-119). The new chips live in a sub-row beneath the existing row content; the per-row Resubmit button slots in at the right edge of the row.

  • Step 3.2: Add an onResubmitOne?: (claimId: string) => void prop.

    Update the function signature to accept the new optional callback.

  • Step 3.3: Render the chips sub-row for rejected claim rows.

    Pseudocode:

    {row.kind === "claim" && row.state === "rejected" && (
      <div className="flex items-center gap-1 ml-[...px] mt-1">
        {row.claim_acks && row.claim_acks.items.length > 0 ? (
          <>
            {row.claim_acks.items.slice(0, 3).map((it) => (
              <Chip key={`${it.ack_id}-${it.ak2_index}`}
                    code={it.set_accept_reject_code}
                    scn={it.set_control_number} />
            ))}
            {row.claim_acks.total > 3 && (
              <ChipOverflow total={row.claim_acks.total - 3}
                             onClick={() => onOpenClaim?.(row.id)} />
            )}
          </>
        ) : (
          <span className="text-[10px] muted">999 not linked</span>
        )}
      </div>
    )}
    

    Chip styling mirrors the existing AcksPage AckCodeBadge:

    • A → muted check tint (muted foreground, transparent bg)
    • R/E/X → oxblood tint
    • Display: ST02 · AK2_CODE (e.g. 991102989 · A)
  • Step 3.4: Add the per-row Resubmit button.

    At the right edge of the row, after the existing payer cell:

    {row.kind === "claim" && row.state === "rejected" && onResubmitOne && (
      <button
        type="button"
        className="mono text-[10px] px-2 py-1 rounded-sm uppercase tracking-[0.12em] hover:bg-[color:var(--tt-amber)]/10 focus-visible:outline-none focus-visible:bg-[color:var(--tt-amber)]/10"
        style={{ color: "var(--tt-amber)" }}
        onClick={(e) => { e.stopPropagation(); onResubmitOne(row.id); }}
        data-testid={`resubmit-${row.id}`}
      >
        Resubmit
      </button>
    )}
    

    e.stopPropagation() is critical — the row is currently wired to navigate to /claims?claim=ID on click. Without stopPropagation, both gestures would fire.

Task 4: Wire onResubmitOne through the page

  • Step 4.1: Add the handler in src/pages/Inbox.tsx.

    Near the existing performResubmit(...) helper (~line 87):

    const performResubmitOne = useCallback(async (claimId: string) => {
      try {
        const { text, filename } = await api.serializeClaim837(claimId);
        downloadTextFile(filename, "text/x12", text);
      } catch (e) {
        toast.error(`Resubmit failed: ${String(e)}`);
      }
    }, []);
    

    Import serializeClaim837 from @/lib/api and downloadTextFile from @/lib/download.

  • Step 4.2: Thread the prop through <Lane><InboxRow>.

    Lane.tsx needs a new optional onResubmitOne prop passed through to InboxRow. InboxRow already accepts it (Step 3.2). Wire in pages/Inbox.tsx at the existing <Lane key="rejected" ... onResubmit={onResubmit} ...> call site.

  • Step 4.3: Gate on RoleGate for admin + user.

    Mirror the existing bulk Resubmit gating (pages/Inbox.tsx:529-538<RoleGate allow={["admin", "user"]}>). The per-row button can live inside the same RoleGate wrapper. (If RoleGate only wraps <BulkBar> but not <Lane>, restructure to wrap the whole rejected lane block.)


Phase 3 — Frontend tests

Task 5: Test the chip rendering + per-row Resubmit

  • Step 5.1: src/components/inbox/InboxRow.test.tsx — add 1 test.

    it("SP29: rejected-lane row renders inline 999 ack evidence chips", () => {
      const row: InboxClaimRow = {
        id: "REJ1", kind: "claim", state: "rejected",
        patient_control_number: "991102989o...",
        charge_amount: 100, payer_id: "CO_MEDICAID",
        provider_npi: "1234567893",
        rejection_reason: "999 AK5 set-level R; 1 segment error(s)",
        rejected_at: new Date().toISOString(),
        service_date_from: null,
        claim_acks: {
          total: 4,
          rejected: 2,
          items: [
            { ack_id: 1, set_control_number: "991102989", set_accept_reject_code: "A", ak2_index: 0, linked_at: "..." },
            { ack_id: 2, set_control_number: "991102989", set_accept_reject_code: "R", ak2_index: 1, linked_at: "..." },
            { ack_id: 3, set_control_number: "991102989", set_accept_reject_code: "E", ak2_index: 2, linked_at: "..." },
          ],
        },
      };
      const onResubmitOne = vi.fn();
      render(<InboxRow row={row} onResubmitOne={onResubmitOne} />);
      // Assert 3 chips render with codes A/R/E
      expect(screen.getByText(/A/)).toBeInTheDocument();
      expect(screen.getByText(/R/)).toBeInTheDocument();
      expect(screen.getByText(/E/)).toBeInTheDocument();
      // Assert +1 more chip (total 4, only 3 rendered, +1 indicator)
      expect(screen.getByText(/\+1/)).toBeInTheDocument();
      // Click the Resubmit button — assert onResubmitOne fires
      fireEvent.click(screen.getByTestId("resubmit-REJ1"));
      expect(onResubmitOne).toHaveBeenCalledWith("REJ1");
    });
    
  • Step 5.2: src/pages/Inbox.test.tsx — add 1 test.

    it("SP29: per-row Resubmit button downloads the single-claim 837", async () => {
      // Mock fetchInboxLanes to return 1 rejected row REJ1 with claim_acks
      // Mock api.serializeClaim837 to return { text: "ISA*...", filename: "claim-REJ1.x12" }
      // Mock downloadTextFile
      // Render <Inbox />
      // Click the per-row Resubmit button
      // Assert serializeClaim837 was called with "REJ1"
      // Assert downloadTextFile was called with ("claim-REJ1.x12", "text/x12", "ISA*...")
    });
    

    Follow the existing vi.mock("@/lib/api", ...) + vi.mock("@/lib/download", ...) pattern in the file. Use the same vi.mocked(...) harness style.

  • Step 5.3: Run the new tests — should PASS.

    npx vitest run src/components/inbox/InboxRow.test.tsx src/pages/Inbox.test.tsx -v
    

Phase 4 — Verification

Task 6: Run full verification

  • Step 6.1: Backend pytest (one new file or extension, no others touched).

    cd backend && .venv/bin/pytest tests/test_inbox_lanes.py -v
    

    Pass criteria: new test passes; no new regressions elsewhere (run the full backend suite once).

  • Step 6.2: Frontend vitest.

    npx vitest run src/components/inbox/InboxRow.test.tsx src/pages/Inbox.test.tsx -v
    

    Pass criteria: 2 new tests pass; no new regressions (run full vitest once to confirm the pre-existing baseline unchanged).

  • Step 6.3: Typecheck.

    npm run typecheck
    

    Pass criteria: 0 new errors (the pre-existing 17 errors documented in SP28 deviations log remain; if any new errors are introduced they're a deviation to log).

  • Step 6.4: Lint.

    npm run lint
    

    Pass criteria: same pre-existing posture (eslint not installed — non-functional).

  • Step 6.5: Build (vite build via tsc -b).

    npm run build
    

    Pass criteria: same as SP28 build baseline.


Phase 5 — Merge + deploy

Task 7: Atomic merge + prod deploy

  • Step 7.1: Commit on the SP29 branch.

    Separate commits per concern:

    • feat(sp29): attach 999 ack summary to rejected-lane rows
    • feat(sp29): per-row Resubmit button + ack-evidence chips in Inbox
    • test(sp29): inbox row + page tests
    • docs(plan): SP29 deviations log (if any)
  • Step 7.2: Atomic merge into main.

    git checkout main
    git merge --no-ff sp29-rejected-row-ack-drill -m "merge: SP29 Inbox 999-rejected drill into main
    
    ..."
    

    DO NOT SQUASH. DO NOT REBASE.

  • Step 7.3: Rebuild + restart prod containers.

    docker compose build backend frontend
    docker compose up -d
    
  • Step 7.4: Smoke test on prod.

    1. Hit /api/inbox/lanes as admin; confirm at least one rejected row has a non-null claim_acks field (if any rejected claims exist on prod).
    2. Open /inbox in the browser; confirm the rejected lane rows now show the AK2 chips + a Resubmit button per row.
    3. Click the Resubmit button; confirm a claim-{id}.x12 file downloads.
  • Step 7.5: Push to origin.

    git push origin main
    

Decisions

  • D1 — per-row Resubmit is download-only (not state-flipping). Mirrors the operator's actual workflow (download, fix in their editor, resubmit via the bulk path). v1 small. v2 may add state-flipping variant if needed.
  • D2 — lane surface, not a new page. Reuse existing chrome. Cheaper.
  • D3 — claim_acks.items is 5 most recent, sorted DESC. Matches eye-flow (newest first). total + rejected cover the rest.
  • D4 — per-row Resubmit visible to admin AND user roles. Mirrors bulk Resubmit gating.

Out of scope (deferred)

  • payer_rejected lane (277CA STC A4/A6/A7) — distinct drill.
  • Missing-999 alarm surfacing (claims SUBMITTED >24h with zero 999 acks).
  • Hoisting ack counts into the ClaimDrawer header.
  • Orphan-ack triage bulk actions.
  • Audit-log entry on single-claim resubmit (the bulk path also has no audit — closing the gap is its own SP).

Notes for the implementer

  • Don't bump the schema. The claim_acks data is already there (SP28 + backfill).
  • Don't re-fetch per row. One batched query in compute_lanes for the whole lane.
  • e.stopPropagation() on the Resubmit button is mandatory — row click navigates to /claims?claim=ID, and both gestures firing would be a regression.
  • The +N more chip should also navigate to the ClaimDrawer (same as the row click) — gives the operator a way to see all 5+ acks without opening via the row body.
  • No backend bus publish — no live-tail event for SP29 (read-only shape change).
  • Visual chrome: reuse the existing --tt-oxblood (reject) and --tt-muted (accept / "not linked") CSS variables; no new tokens needed.

Deviations log

(Add any deviations discovered during implementation to this section and to the merge commit body. Pre-existing baselines from SP28 deviations log: 5 frontend test failures + 17 frontend typecheck errors. Document anything new here.)