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-drillSpec → 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_rejectedlane (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
rejectedlane with chrome cost paid. Adding the per-row affordance inside the existing lane is lower-friction than a new drill page. - D3 —
claim_acks.itemsis 5 most recent, sorted DESC. Matches what the eye scans (newest rejections first).totalandrejectedsummary counts cover the rest of the story. No pagination. - D4 — per-row Resubmit visible to
adminANDuserroles. Mirrors the existing bulk Resubmit gating.
Risks
- N+1 fetch via the inbox endpoint. Could grow
/api/inbox/laneslatency by O(N-rejected) without a batched query. Mitigation: one batched query insidecompute_lanes(claim_id IN (...)). - Backwards compatibility of
InboxClaimRow. Adding a nullableclaim_acksfield 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_837content 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.