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

5.8 KiB

SP29 — Inbox 999-rejected claim drill: inline ack evidence + per-row Resubmit

Status: Approved, 2026-07-02. Branch: sp29-rejected-row-ack-drill Spec → plan → implement → atomic merge into main.

Goal

Make the Inbox rejected lane (the 999 envelope rejects — the resubmittable kind) actionable per claim: the operator can see why a claim was rejected (which 999 AK2 set_response, what code, what ST02) without opening the drawer, and they can resubmit a single rejected claim with one click instead of having to select its checkbox + open the bulk modal.

The SP28 → SP29 chain: SP28 gave us the claim_acks join table so we can now surface the 999 evidence inline on the rejected row. SP29 is the operator-facing surface that makes the rebill workflow usable.

Scope

Backend — extend /api/inbox/lanes

backend/src/cyclone/inbox_lanes.py (compute_lanes): for each rejected-lane claim row, attach claim_acks: {total, rejected, items} summarizing the 999 acks linked to that claim by SP28.

New field on InboxClaimRow:

{
    "total": int,                  # total claim_acks rows linked to this claim (ack_kind='999')
    "rejected": int,               # subset where set_accept_reject_code in ('R', 'E', 'X')
    "items": [                     # up to 5 most recent, newest first; for UI chip rendering
        {"ack_id": int, "set_control_number": str, "set_accept_reject_code": str, "ak2_index": int, "linked_at": iso8601}
    ],
}

The field is null when the claim has zero linked 999 acks. Filtered to ack_kind='999' — the 277CA ack evidence already lives on the payer_rejected_* fields on payer_rejected rows (out of scope for this SP; not touched).

Implementation: one batched query inside compute_lanes:

SELECT claim_id, ack_id, set_control_number, set_accept_reject_code, ak2_index, linked_at
FROM claim_acks
WHERE claim_id IN (...) AND ack_kind = '999'
ORDER BY linked_at DESC, id DESC

(Indexes sufficient — ix_claim_acks_claim_id already exists from 0018_claim_acks.sql. The (claim_id, linked_at) composite for sorted fetch is not added in v1; the LIMIT-by-claim is done in Python after the batched fetch. If latency grows on the inbox endpoint we add a (claim_id, linked_at) composite index in a follow-up SP.)

Frontend — render + per-row Resubmit

src/lib/inbox-api.ts — extend InboxClaimRow with the new claim_acks field (additive, non-breaking).

src/components/inbox/InboxRow.tsx — add an inline sub-row for rejected-state claim rows showing up to 3 AK2 chips + a +N more chip that opens the ClaimDrawer. Reject codes (R/E/X) get the oxblood tint; accept (A) gets a muted check tint. When claim_acks is null or empty, render 999 not linked in muted text.

src/components/inbox/InboxRow.tsx + src/pages/Inbox.tsx — a per-row Resubmit button at the right edge of each rejected claim row. Click triggers serializeClaim837(row.id)downloadTextFile(...) (operator gets the corrected 837 on disk; they reconcile via their own workflow or via the existing bulk Resubmit modal).

Auth posture: per-row Resubmit is visible to admin and user roles. Mirrors the bulk Resubmit gating in pages/Inbox.tsx:529-538. Manual-only SP24 posture unchanged.

Out of scope (deferred)

  • payer_rejected lane (277CA STC A4/A6/A7) — distinct drill, owns the Acknowledge gesture.
  • A 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 doesn't audit either; closing that gap is its own SP).

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 or the existing single-claim modal flow). Keeps v1 small. v2 can add the "submit + flip state" path if needed.
  • D2 — lane surface, not a new page. The Inbox already has the rejected lane with chrome cost paid. Adding the per-row affordance inside the existing lane is lower-friction than a new drill page.
  • D3 — claim_acks.items is 5 most recent, sorted DESC. Matches what the eye scans (newest rejections first). total and rejected summary counts cover the rest of the story. No pagination.
  • D4 — per-row Resubmit visible to admin AND user roles. Mirrors the existing bulk Resubmit gating.

Risks

  • N+1 fetch via the inbox endpoint. Could grow /api/inbox/lanes latency by O(N-rejected) without a batched query. Mitigation: one batched query inside compute_lanes (claim_id IN (...)).
  • Backwards compatibility of InboxClaimRow. Adding a nullable claim_acks field is non-breaking. Existing UI (and the CLAUDE.md-documented "5 lanes" assumption) continues to work; only the rejected row gets new chips.
  • Per-row Resubmit file may not match the operator's edits. Out of scope; the operator is responsible for editing. The download is the same serialize_837 content the bulk path produces. Trade-off documented in D1.

Implementation shape

  • Branch: sp29-rejected-row-ack-drill
  • Plan path: docs/superpowers/plans/2026-07-02-cyclone-999-rejected-drill.md
  • Commit prefixes: feat(sp29): …, docs(spec): …, docs(plan): …, merge: SP29 …
  • PR title: SP29 Inbox 999-rejected drill
  • Merge shape: single atomic merge, no squash, no rebase.

File footprint (per phase)

  • Backend modified (1): backend/src/cyclone/inbox_lanes.py.
  • Backend test added (1): backend/tests/test_inbox_lanes.py (extend or new).
  • Frontend modified (3): src/lib/inbox-api.ts, src/components/inbox/InboxRow.tsx, src/pages/Inbox.tsx.
  • Frontend test added (2): src/components/inbox/InboxRow.test.tsx, src/pages/Inbox.test.tsx.
  • Estimated 70-100 LOC total.