+
Resubmit {pendingResubmitIds.length} claims and download a 837 bundle?
+
+
+
+)}
+```
+
+> **Aesthetic:** the existing inbox uses the Ticker Tape palette.
+> Adapt the modal styling to match (`var(--tt-amber)` etc.). Not a
+> focus of this task — the test asserts behavior, not pixels.
+
+- [ ] **Step 6: Run, confirm PASS**
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/index.html src/lib/api.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx
+git commit -m "feat(sp8): Inbox — resubmit bundle modal + JSZip download"
+```
+
+---
+
+## Phase 5 — Prodfile round-trip + Docs
+
+### Task 11: Prodfile round-trip smoke test
+
+**Files:**
+- Modify: `backend/tests/test_prodfiles_smoke.py`
+
+- [ ] **Step 1: Add failing parametrized test**
+
+Append to `backend/tests/test_prodfiles_smoke.py`:
+
+```python
+import pytest
+from pathlib import Path
+from cyclone.parsers.parse_837 import parse_837_text
+from cyclone.parsers.serialize_837 import serialize_837
+
+
+PRODFILES_DIR = Path("docs/prodfiles/claims")
+
+
+@pytest.mark.parametrize(
+ "fixture_path",
+ sorted(PRODFILES_DIR.glob("*.x12")),
+ ids=lambda p: p.name,
+)
+def test_claims_prodfile_round_trip(fixture_path: Path):
+ """Every prodfile in docs/prodfiles/claims/ round-trips through
+ serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo
+ validation, which is recomputed by the parser)."""
+ text = fixture_path.read_text()
+ source = parse_837_text(text)
+ assert source.claims, f"{fixture_path.name}: no claims parsed"
+ for claim in source.claims:
+ out = serialize_837(claim)
+ reparsed = parse_837_text(out).claims[0]
+ # Compare canonical fields (not raw_segments — those are rebuilt).
+ assert reparsed.claim.claim_id == claim.claim.claim_id
+ assert reparsed.claim.total_charge == claim.claim.total_charge
+ assert reparsed.claim.frequency_code == claim.claim.frequency_code
+ assert reparsed.claim.place_of_service == claim.claim.place_of_service
+ assert [d.code for d in reparsed.diagnoses] == [d.code for d in claim.diagnoses]
+ assert len(reparsed.service_lines) == len(claim.service_lines)
+```
+
+- [ ] **Step 2: Run, confirm pass (or FAIL with diagnostics)**
+
+```bash
+cd backend && .venv/bin/pytest tests/test_prodfiles_smoke.py -v
+```
+
+Expected: as many tests as there are files in `docs/prodfiles/claims/`
+(currently 113). One parametrized test, parametrized over the glob —
+counts as 1 in the test suite's totals.
+
+If failures occur: check the failing claim's structure (most likely a
+segment the serializer doesn't handle yet — `K3` notes, conditional
+segments, etc.). Document the gap in the plan and add the segment to
+`_EDITABLE_SEGMENT_KINDS` or the stable pass-through as needed.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add backend/tests/test_prodfiles_smoke.py
+git commit -m "test(sp8): prodfile round-trip smoke — every claims/*.x12 serializes back"
+```
+
+---
+
+### Task 12: README — Outbound 837 Serializer section
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Add the section**
+
+Insert after the existing "## Per-Line Adjustment Audit" section (around line 213):
+
+```markdown
+## Outbound 837 Serializer
+
+Any parsed claim can be regenerated as a complete, round-trippable
+X12 837P file. The serializer uses a hybrid approach: the envelope
+(ISA/GS/ST/SE/GE/IEA) and editable claim-level segments (CLM, REF*G1,
+HI, SV1, DTP*472) are freshly built from the canonical `ClaimOutput`
+fields; the stable provider / subscriber / payer hierarchy segments
+are passed through byte-identical from the parser's `raw_segments`.
+
+### Where to find it
+
+- **ClaimDrawerHeader → "Download 837"** — single-claim export to
+ `claim-{id}.x12`.
+- **Inbox → Resubmit (multi)** — when resubmitting 2+ rejected claims,
+ a modal asks whether to also download an X12 bundle; on confirm,
+ the frontend zips the regenerated files in-browser (JSZip via CDN)
+ and triggers a download.
+
+### Endpoints
+
+| Method | Path | Returns |
+|--------|------|---------|
+| GET | `/api/claims/{id}/serialize-837` | `text/x12` attachment. 404 if claim missing. 422 if no raw_segments. |
+| POST | `/api/inbox/rejected/resubmit?download=true` | existing JSON, plus `files: [{claim_id, filename, x12_text}]` array when `download=true`. |
+
+### Edit propagation
+
+After parse, edits to `claim.claim.{total_charge, frequency_code,
+prior_auth}`, `claim.diagnoses[]`, or any `service_lines[i]` field
+flow through to the regenerated X12 on the next call — the serializer
+rebuilds those segments from the canonical fields rather than from
+`raw_segments`. The stable hierarchies (billing provider, subscriber,
+payer, submitter, receiver) come through verbatim from the original
+file.
+
+### Round-trip guarantee
+
+Every file in `docs/prodfiles/claims/` (113 files at last count)
+round-trips through `serialize_837` → `parse_837_text` with a
+deep-equal `ClaimOutput` (modulo validation, which is recomputed).
+This is enforced by a parametrized smoke test.
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add README.md
+git commit -m "docs(sp8): README — Outbound 837 Serializer section"
+```
+
+---
+
+### Task 13: Final smoke + full-suite run
+
+**Files:** none
+
+- [ ] **Step 1: Backend suite**
+
+```bash
+cd backend && .venv/bin/pytest -q
+```
+
+Expected: ≥ 528 backend passing (528 prior + 15 new — 9 serializer + 3
+endpoint + 2 inbox + 1 prodfile). The prodfile test counts as 1 even
+though it's parametrized over 113 files.
+
+- [ ] **Step 2: Frontend suite**
+
+```bash
+cd .. && npx vitest run --reporter=dot
+```
+
+Expected: ≥ 342 frontend passing (342 prior + 2 new — 1 drawer button +
+1 inbox modal).
+
+- [ ] **Step 3: Typecheck**
+
+```bash
+npx tsc --noEmit
+```
+
+Expected: 0 errors.
+
+- [ ] **Step 4: Manual curl smoke**
+
+```bash
+cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/sp8-smoke.db .venv/bin/python -m cyclone serve &
+curl -X POST -F "file=@tests/fixtures/co_medicaid_837p.txt" http://127.0.0.1:8000/api/parse-837
+# Take the returned claim id, then:
+curl -i http://127.0.0.1:8000/api/claims/