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