# 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`: ```python { "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`: ```sql 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.