Files
cyclone/docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md
Tyler 561018c690 feat(sp8): outbound 837P serializer — full rebuild + round-trip tests
- backend/src/cyclone/parsers/serialize_837.py — full-rebuild 837P serializer.
  Emits envelope (ISA/GS/ST/SE/GE/IEA + BHT) + submitter/receiver/billing
  provider/subscriber/payer hierarchy + editable segments (CLM/REF*G1/HI)
  + per-service-line LX/SV1/DTP*472/REF*6R — all from canonical
  ClaimOutput fields.

  Pivoted from spec §3.1 hybrid to full rebuild because ClaimOutput.raw_segments
  only captures post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the
  envelope or hierarchies. A pass-through approach cannot regenerate those
  without expanding raw_segments in parse_837.py (out of scope for this SP).

- backend/tests/test_serialize_837.py — 36 tests covering envelope shape,
  hierarchy segments, claim-level builders, service-line builders, edited-field
  propagation, round-trip, custom sender/receiver IDs, and resubmit helper.

- backend/tests/test_prodfiles_smoke.py::test_claims_prodfile_round_trip —
  every file in docs/prodfiles/claims/ (113 files) round-trips through
  serialize_837 → parse_837_text with deep-equal ClaimOutput (modulo
  validation, which is recomputed by the parser).

- docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md — full plan
  with amendment note documenting the Approach A pivot.

Per session convention, plan note about unrelated modifications to
parse_835.py / fixtures stashed separately.
2026-06-20 20:27:48 -06:00

1580 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Outbound 837P Serializer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an outbound X12 837P serializer so a parsed `ClaimOutput` can be regenerated as a complete, round-trippable file. Closes the SP6 resubmit lane end-to-end (`?download=true`) and adds a "Download 837" affordance on the claim drawer.
**Architecture:** **Full rebuild (Approach A).** Spec §3.1 originally proposed a "hybrid" that pass-throughs stable segments from `claim.raw_segments`, but raw_segments only contains post-CLM segments (CLM, REF*G1, HI, LX, SV1 pairs) — not the envelope or hierarchies. Full rebuild emits everything from canonical ClaimOutput fields (BillingProvider, Subscriber, Payer, ClaimHeader, diagnoses, service_lines). ~250-300 LOC. Self-contained — no parser changes. Matches existing `serialize_270.py` / `serialize_999.py` patterns.
**Tech Stack:** Python 3.13 (backend), FastAPI, existing test patterns; React + TypeScript + Vitest (frontend); JSZip via CDN `<script>` tag for the resubmit-bundle zip (no new npm dep per spec §5).
**Spec:** `docs/superpowers/specs/2026-06-20-cyclone-serialize-837-design.md`
**Pattern references:** `backend/src/cyclone/parsers/serialize_999.py`, `backend/src/cyclone/parsers/serialize_270.py`, `backend/src/cyclone/parsers/parse_837.py`
**Amendment note (2026-06-20):** Approach changed from spec §3.1 hybrid to Approach A full rebuild. Rationale recorded in execution log; spec doc unchanged for historical reference. The round-trip guarantee still holds (test in Phase 5 verifies 113 prodfiles).
---
## File Structure
**Create:**
- `backend/src/cyclone/parsers/serialize_837.py` — the serializer (helpers + `serialize_837` + `serialize_837_for_resubmit` + `SerializeError`)
- `backend/tests/test_serialize_837.py` — 9 serializer tests
- `backend/tests/test_api_serialize_837.py` — 3 endpoint tests
- `backend/tests/test_inbox_resubmit_download.py` — 2 endpoint tests for `?download=true`
**Modify:**
- `backend/src/cyclone/api.py` — add `GET /api/claims/{id}/serialize-837`, extend `POST /api/inbox/rejected/resubmit` with `?download=true`
- `backend/tests/test_prodfiles_smoke.py` — add `test_claims_prodfile_round_trip` parametrized over `docs/prodfiles/claims/*.x12`
- `src/lib/api.ts` — add `api.getClaim837(claimId)`, extend `api.resubmitRejected` with `{download: true}` option
- `src/components/ClaimDrawer/ClaimDrawerHeader.tsx` — add "Download 837" button
- `src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx` — add 1 test
- `src/pages/Inbox.tsx` — extend Resubmit action with a "download bundle?" modal (JSZip via CDN)
- `src/pages/Inbox.test.tsx` — add 1 test
- `README.md` — add "Outbound 837 Serializer" section
- `src/index.html` (or similar) — add JSZip CDN `<script>` tag
---
## Phase 1 — Serializer module
### Task 1: Add `SerializeError` + envelope helpers
**Files:**
- Create: `backend/src/cyclone/parsers/serialize_837.py`
- Test: `backend/tests/test_serialize_837.py`
- [ ] **Step 1: Write failing tests for `_build_isa`, `_build_gs`, `_build_st`, `_build_se`**
Append to `backend/tests/test_serialize_837.py`:
```python
from cyclone.parsers.serialize_837 import (
SerializeError,
_build_isa,
_build_gs,
_build_st,
_build_se,
)
def test_build_isa_is_106_chars_with_standard_delimiters():
isa = _build_isa("SENDER", "RECEIVER", "000000001")
assert isa.startswith("ISA*")
assert len(isa) == 106
# ISA ends with the component separator (element 16, the lone `^`).
assert isa.endswith("^")
def test_build_gs_emits_gs_segment():
gs = _build_gs("SENDER", "RECEIVER", "1")
assert gs.startswith("GS*HC*")
parts = gs.split("*")
assert parts[0] == "GS"
assert parts[1] == "HC" # functional ID code for 837
assert parts[2] == "SENDER"
assert parts[3] == "RECEIVER"
assert parts[6] == "1" # group control number
def test_build_st_emits_st_segment():
st = _build_st("0001", "005010X222A1")
assert st.startswith("ST*837*0001*005010X222A1~")
def test_build_se_emits_se_segment():
se = _build_se(10, "0001")
assert se == "SE*10*0001~"
def test_serialize_error_is_an_exception():
assert issubclass(SerializeError, Exception)
```
- [ ] **Step 2: Run tests, confirm FAIL**
```bash
cd backend && .venv/bin/pytest tests/test_serialize_837.py -v
```
Expected: `ModuleNotFoundError: No module named 'cyclone.parsers.serialize_837'`.
- [ ] **Step 3: Create `serialize_837.py` with the helpers**
Create `backend/src/cyclone/parsers/serialize_837.py`:
```python
"""Serialize a :class:`ClaimOutput` to a complete X12 837P text.
Hybrid approach (per spec §3.1):
- Envelope segments (ISA / GS / ST / SE / GE / IEA) are freshly built
with caller-supplied control numbers.
- BHT is freshly built from `Envelope` so BHT06 edits propagate.
- Stable hierarchy segments (submitter, receiver, billing provider,
subscriber, payer) are passed through byte-identical from
`claim.raw_segments` — the parser captured them and we don't want
to drift from the parser.
- Editable claim-level segments (CLM, REF*G1, HI) and service-line
segments (SV1, DTP*472) are rebuilt from canonical ClaimOutput fields
so post-parse edits propagate.
The output round-trips through :func:`cyclone.parsers.parse_837.parse_837_text`
— see the tests in ``backend/tests/test_serialize_837.py``.
If ``claim.raw_segments`` is empty, :class:`SerializeError` is raised:
v1 does not implement a from-scratch emitter (that's a future SP).
"""
from __future__ import annotations
from datetime import date
from cyclone.parsers.models import ClaimOutput
_SEG = "~"
_ELEM = "*"
class SerializeError(Exception):
"""Raised when a claim cannot be serialized (e.g. missing raw_segments)."""
def _today_yymmdd() -> str:
t = date.today()
return f"{t.year % 100:02d}{t.month:02d}{t.day:02d}"
def _today_hhmm() -> str:
t = date.today()
return f"{t.hour:02d}{t.minute:02d}"
def _pad(s: str, width: int) -> str:
return (s or "").ljust(width)[:width]
def _build_isa(sender_id: str, receiver_id: str, interchange_control_number: str) -> str:
"""Build the 106-char ISA envelope (mirrors serialize_999 layout)."""
parts = [
"ISA",
"00", _pad("", 10),
"00", _pad("", 10),
"ZZ", _pad(sender_id, 15),
"ZZ", _pad(receiver_id, 15),
_today_yymmdd(),
_today_hhmm(),
"^", # ISA11 — repetition separator
"00501", # ISA12 — control version
_pad(interchange_control_number, 9),
"0", # ISA14 — ack requested (0 = no)
"P", # ISA15 — usage indicator (P = production)
"^", # ISA16 — component separator
]
return _ELEM.join(parts) + _SEG
def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> str:
"""Build the GS envelope segment (functional ID code = HC for 837)."""
parts = [
"GS",
"HC",
sender_id,
receiver_id,
_today_yymmdd(),
_today_hhmm(),
group_control_number,
"X", # GS08 — responsibility agency code
"005010X222A1", # GS08 — implementation guide
]
return _ELEM.join(parts) + _SEG
def _build_st(control_number: str, implementation_guide: str = "005010X222A1") -> str:
return _ELEM.join(["ST", "837", control_number, implementation_guide]) + _SEG
def _build_se(count: int, control_number: str) -> str:
return _ELEM.join(["SE", str(count), control_number]) + _SEG
```
- [ ] **Step 4: Run tests, confirm PASS**
```bash
cd backend && .venv/bin/pytest tests/test_serialize_837.py -v
```
Expected: 5 passed.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
git commit -m "feat(sp8): 837 serializer envelope helpers (ISA/GS/ST/SE) + SerializeError"
```
---
### Task 2: Claim-level segment builders (CLM, REF*G1, HI, BHT)
**Files:**
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
- Test: `backend/tests/test_serialize_837.py`
- [ ] **Step 1: Add failing tests for `_build_bht`, `_build_clm`, `_build_ref_g1`, `_build_hi`**
Append to `backend/tests/test_serialize_837.py`:
```python
from datetime import date
from cyclone.parsers.serialize_837 import _build_bht, _build_clm, _build_ref_g1, _build_hi
from cyclone.parsers.models import ClaimHeader, Diagnosis
def test_build_bht_includes_bht06_from_envelope():
# BHT01=0019 (originator), BHT02=00 (purpose), BHT03=ref, BHT04=date, BHT05=time, BHT06=transaction type
from cyclone.parsers.models import Envelope
env = Envelope(transaction_type_code="CH")
bht = _build_bht(env, reference_id="REF123", service_date=date(2026, 6, 20))
parts = bht.rstrip("~").split("*")
assert parts[0] == "BHT"
assert parts[6] == "CH"
def test_build_clm_emits_clm01_to_clm05():
from decimal import Decimal
claim = ClaimHeader(
claim_id="CLM-1",
total_charge=Decimal("100.00"),
place_of_service="11",
frequency_code="1",
)
clm = _build_clm(claim)
parts = clm.rstrip("~").split("*")
assert parts[0] == "CLM"
assert parts[1] == "CLM-1"
assert parts[2] == "100.00"
assert parts[4] == "11" # place of service (CLM05 facility code qualifier "B" precedes)
def test_build_ref_g1_returns_empty_string_when_no_prior_auth():
assert _build_ref_g1(None) == ""
assert _build_ref_g1("") == ""
def test_build_ref_g1_emits_segment_when_prior_auth_set():
assert _build_ref_g1("AUTH123") == "REF*G1* AUTH123~".replace(" ", "")
def test_build_hi_emits_one_hi_segment_with_qualifier_bk_for_primary():
diags = [
Diagnosis(code="E11.9", qualifier="BK", sequence=1),
Diagnosis(code="I10", qualifier="BF", sequence=2),
]
hi = _build_hi(diags)
assert hi.startswith("HI*")
parts = hi.rstrip("~").split("*")
assert parts[0] == "HI"
# 12 diagnoses max; unused are blank
assert parts[1] == "BK:E11.9"
assert parts[2] == "BF:I10"
```
> **Note on `_build_bht` signature:** the `Envelope` model needs `transaction_type_code`. Verify it exists on the model from SP3 work; if not, fall back to a literal `"CH"`. (See SP3 EDI-features plan T5 step 3 — it was added then.) Adapt the test to whatever field is available; if `transaction_type_code` is `None`, emit `"CH"` as a default.
- [ ] **Step 2: Run tests, confirm FAIL**
Expected: `ImportError` or `AttributeError` on the new helpers.
- [ ] **Step 3: Implement `_build_bht`, `_build_clm`, `_build_ref_g1`, `_build_hi`**
Add to `backend/src/cyclone/parsers/serialize_837.py`:
```python
from cyclone.parsers.models import ClaimHeader, Diagnosis, Envelope
def _build_bht(envelope: Envelope, *, reference_id: str, service_date: date) -> str:
"""Build the BHT segment.
BHT01 = 0019 (originator application) for 837
BHT02 = 00 (purpose: original)
BHT03 = reference id
BHT04 = service date (CCYYMMDD)
BHT05 = service time (HHMM)
BHT06 = transaction type code (from envelope; defaults to "CH")
"""
bht06 = getattr(envelope, "transaction_type_code", None) or "CH"
parts = [
"BHT",
"0019",
"00",
reference_id,
service_date.strftime("%Y%m%d"),
_today_hhmm(),
bht06,
]
return _ELEM.join(parts) + _SEG
def _build_clm(claim: ClaimHeader) -> str:
"""CLM01..CLM05 (claim id, total, place of service, frequency)."""
charge = f"{claim.total_charge:.2f}"
parts = [
"CLM",
claim.claim_id,
charge,
"", # CLM03 — non-institutional claim filing indicator (omitted)
claim.place_of_service or "",
"", # CLM05-1 — facility code (omitted unless set)
"B", # CLM05-2 — facility code qualifier
"", # CLM06 — provider signature on file (Y/N, omitted)
"Y", # CLM07 — assignment of benefits
"Y", # CLM08 — assignment of benefits certification
]
return _ELEM.join(parts) + _SEG
def _build_ref_g1(prior_auth: str | None) -> str:
"""REF*G1 (prior authorization). Empty string when no prior auth."""
if not prior_auth:
return ""
return _ELEM.join(["REF", "G1", prior_auth]) + _SEG
def _build_hi(diagnoses: list[Diagnosis]) -> str:
"""One HI segment with up to 12 diagnoses (qualifier:code)."""
parts = ["HI"]
for diag in diagnoses[:12]:
parts.append(f"{diag.qualifier}:{diag.code}")
return _ELEM.join(parts) + _SEG
```
- [ ] **Step 4: Run tests, confirm PASS**
Expected: previous 5 + new 5 = 10 passed.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
git commit -m "feat(sp8): 837 serializer claim-level segment builders (BHT/CLM/REF*G1/HI)"
```
---
### Task 3: Service-line segment builders (SV1, DTP*472)
**Files:**
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
- Test: `backend/tests/test_serialize_837.py`
- [ ] **Step 1: Add failing tests**
Append:
```python
from cyclone.parsers.serialize_837 import _build_sv1, _build_dtp_472
from cyclone.parsers.models import ServiceLine
from decimal import Decimal
def test_build_sv1_emits_procedure_modifiers_charge_units():
line = ServiceLine(
line_number=1,
procedure_code="99213",
modifiers=["25"],
charge=Decimal("100.00"),
units=Decimal("1"),
unit_type="UN",
)
sv1 = _build_sv1(line)
parts = sv1.rstrip("~").split("*")
assert parts[0] == "SV1"
# SV1-01 composite: qualifier HC + procedure + up to 4 modifiers
assert parts[1].startswith("HC:99213")
assert ":25" in parts[1]
assert parts[2] == "100.00"
assert parts[3] == "UN"
assert parts[4] == "1"
def test_build_dtp_472_emits_service_date():
out = _build_dtp_472(date(2026, 6, 15))
assert out == "DTP*472*RD8*20260615-20260615~"
def test_build_dtp_472_returns_empty_when_no_date():
assert _build_dtp_472(None) == ""
```
- [ ] **Step 2: Run, confirm FAIL**
- [ ] **Step 3: Implement `_build_sv1`, `_build_dtp_472`**
Add to `serialize_837.py`:
```python
from cyclone.parsers.models import ServiceLine
def _build_sv1(line: ServiceLine) -> str:
"""SV1 segment — professional service line.
SV1-01 composite: HC:procedure_code + up to 4 modifiers
SV1-02: charge
SV1-03: unit type (UN/MJ/etc.)
SV1-04: units
"""
proc = line.procedure_code or ""
mods = line.modifiers or []
composite = "HC:" + proc + "".join(f":{m}" for m in mods[:4])
charge = f"{line.charge:.2f}"
parts = [
"SV1",
composite,
charge,
line.unit_type or "UN",
f"{line.units:g}" if line.units is not None else "1",
"", # SV1-05 — place of service (omitted at line level; carried by claim)
]
return _ELEM.join(parts) + _SEG
def _build_dtp_472(service_date: date | None) -> str:
"""DTP*472 (service date). RD8 qualifier with CCYYMMDD-CCYYMMDD range."""
if service_date is None:
return ""
stamp = service_date.strftime("%Y%m%d")
return _ELEM.join(["DTP", "472", "RD8", f"{stamp}-{stamp}"]) + _SEG
```
- [ ] **Step 4: Run, confirm PASS**
Expected: 13 tests passed.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
git commit -m "feat(sp8): 837 serializer service-line builders (SV1, DTP*472)"
```
---
### Task 4: `_pass_through_stable_segments` + `serialize_837` glue
**Files:**
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
- Test: `backend/tests/test_serialize_837.py`
- [ ] **Step 1: Add failing round-trip test + `serialize_837` import test**
Append:
```python
from cyclone.parsers.serialize_837 import serialize_837, _pass_through_stable_segments
from cyclone.parsers.parse_837 import parse_837_text
from cyclone.parsers.models import ClaimOutput
def _load_claim_from_fixture(path: str) -> ClaimOutput:
with open(path) as f:
text = f.read()
result = parse_837_text(text)
assert result.claims, f"no claims parsed from {path}"
return result.claims[0]
def test_pass_through_stable_segments_drops_editable_kinds():
raw = [
["ISA", "00", ...], # stable
["NM1", "85", "2", "CLINIC", ...], # stable
["CLM", "CLM-1", "100.00", ...], # editable → drop
["HI", "BK:E11.9"], # editable → drop
["LX", "1"], # stable
["SV1", "HC:99213", ...], # editable → drop
["DTP", "472", ...], # editable → drop
]
out = _pass_through_stable_segments(raw)
flat = [s[0] for s in [r.split("*") for r in out]]
assert "CLM" not in flat
assert "HI" not in flat
assert "SV1" not in flat
assert "DTP" not in flat
assert "ISA" in flat
assert "NM1" in flat
assert "LX" in flat
def test_serialize_837_round_trips_unchanged_claim():
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
text = serialize_837(claim)
reparsed = parse_837_text(text).claims[0]
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
def test_serialize_837_raises_when_raw_segments_empty():
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
claim.raw_segments = []
import pytest
with pytest.raises(SerializeError, match="no raw_segments"):
serialize_837(claim)
def test_serialize_837_edited_charge_propagates():
from decimal import Decimal
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
claim.claim.total_charge = Decimal("999.99")
text = serialize_837(claim)
assert "CLM*CLM-1*999.99*" in text
def test_serialize_837_edited_prior_auth_propagates():
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
claim.claim.prior_auth = "AUTH999"
text = serialize_837(claim)
assert "REF*G1*AUTH999" in text
def test_serialize_837_envelope_has_106_char_isa():
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
text = serialize_837(claim)
isa_line = next(line for line in text.split("~") if line.startswith("ISA"))
assert len(isa_line) == 106
```
- [ ] **Step 2: Run, confirm FAIL**
- [ ] **Step 3: Implement `_pass_through_stable_segments` and `serialize_837`**
Add to `serialize_837.py`:
```python
# (segment_id, qualifier-or-None) tuples that the serializer rebuilds
# from canonical ClaimOutput fields. Everything else passes through.
_EDITABLE_SEGMENT_KINDS: set[tuple[str, str | None]] = {
("CLM", None),
("REF", "G1"),
("HI", None),
("SV1", None),
("DTP", "472"),
}
def _pass_through_stable_segments(
raw_segments: list[list[str]],
) -> list[str]:
"""Drop editable segments from raw_segments and re-emit as 'seg*...' strings.
Preserves the original order of the remaining (stable) segments.
"""
out: list[str] = []
for seg in raw_segments:
if not seg:
continue
seg_id = seg[0]
qualifier = seg[1] if len(seg) > 1 else None
if (seg_id, qualifier) in _EDITABLE_SEGMENT_KINDS:
continue
out.append(_ELEM.join(seg) + _SEG)
return out
def serialize_837(
claim: ClaimOutput,
*,
interchange_control_number: str = "000000001",
group_control_number: str = "1",
) -> str:
"""Serialize a single claim to a complete 837P file.
Raises :class:`SerializeError` if ``claim.raw_segments`` is empty
(v1 cannot emit a from-scratch claim).
"""
if not claim.raw_segments:
raise SerializeError("claim has no raw_segments; cannot serialize from-scratch")
sender_id = "CYCLONE"
receiver_id = "RECEIVER"
parts: list[str] = [
_build_isa(sender_id, receiver_id, interchange_control_number),
_build_gs(sender_id, receiver_id, group_control_number),
_build_st("0001"),
_build_bht(
claim.envelope,
reference_id=claim.claim.claim_id,
service_date=claim.envelope.transaction_date or date.today(),
),
]
parts.extend(_pass_through_stable_segments(claim.raw_segments))
# Editable claim-level segments (insert after the stable hierarchies
# which the pass-through already emitted in original order).
parts.append(_build_clm(claim.claim))
ref_g1 = _build_ref_g1(claim.claim.prior_auth)
if ref_g1:
parts.append(ref_g1)
if claim.diagnoses:
parts.append(_build_hi(claim.diagnoses))
# Service lines — each emits a rebuilt SV1 + DTP*472 in order. The
# LX (line counter) and REF*6R pass through via raw_segments.
for line in claim.service_lines:
parts.append(_build_sv1(line))
dtp = _build_dtp_472(line.service_date)
if dtp:
parts.append(dtp)
# SE segment count = total segments in ST..SE inclusive (the loop above
# emits everything between; SE count includes ST + all body + SE itself).
seg_count = len(parts) + 1 # +1 for SE itself
parts.append(_build_se(seg_count, "0001"))
parts.append(f"GE*1*{group_control_number}{_SEG}")
parts.append(f"IEA*1*{interchange_control_number}{_SEG}")
return "".join(parts)
```
- [ ] **Step 4: Run, confirm PASS**
Expected: 19 tests passed.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
git commit -m "feat(sp8): 837 serializer pass-through + serialize_837() glue + round-trip"
```
---
### Task 5: `serialize_837_for_resubmit` helper + edited-field tests
**Files:**
- Modify: `backend/src/cyclone/parsers/serialize_837.py`
- Test: `backend/tests/test_serialize_837.py`
- [ ] **Step 1: Add failing tests**
Append:
```python
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
def test_serialize_837_for_resubmit_assigns_unique_control_numbers():
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
text_a = serialize_837_for_resubmit(claim, interchange_index=42)
text_b = serialize_837_for_resubmit(claim, interchange_index=43)
# Extract ISA13 (positions 105-113 zero-indexed of the ISA line, 9 chars).
isa_a = next(line for line in text_a.split("~") if line.startswith("ISA"))
isa_b = next(line for line in text_b.split("~") if line.startswith("ISA"))
assert isa_a != isa_b
# Interchange index 42 → control number padded to 9 chars.
assert "000000042" in isa_a
assert "000000043" in isa_b
def test_serialize_837_round_trip_edited_frequency_propagates():
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
claim.claim.frequency_code = "7"
text = serialize_837(claim)
assert "CLM*CLM-1*" in text # frequency_code is in CLM05-3; parser position varies
reparsed = parse_837_text(text).claims[0]
assert reparsed.claim.frequency_code == "7"
def test_serialize_837_round_trip_added_diagnosis_propagates():
from cyclone.parsers.models import Diagnosis
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
claim.diagnoses.append(Diagnosis(code="Z00.00", qualifier="BF", sequence=99))
text = serialize_837(claim)
assert "BF:Z00.00" in text
def test_serialize_837_round_trip_edited_service_line_charge_propagates():
from decimal import Decimal
claim = _load_claim_from_fixture(
"backend/tests/fixtures/co_medicaid_837p.txt"
)
claim.service_lines[0].charge = Decimal("555.55")
text = serialize_837(claim)
assert "555.55" in text
reparsed = parse_837_text(text).claims[0]
assert any(line.charge == Decimal("555.55") for line in reparsed.service_lines)
```
- [ ] **Step 2: Run, confirm FAIL**
- [ ] **Step 3: Implement `serialize_837_for_resubmit`**
Add to `serialize_837.py`:
```python
def serialize_837_for_resubmit(
claim: ClaimOutput,
*,
interchange_index: int,
) -> str:
"""Like :func:`serialize_837` but assigns deterministic-but-unique
interchange + group control numbers for a bundle position.
Interchange number = ``f"{interchange_index:09d}"``.
Group number = ``str(interchange_index)``.
"""
return serialize_837(
claim,
interchange_control_number=f"{interchange_index:09d}",
group_control_number=str(interchange_index),
)
```
- [ ] **Step 4: Run, confirm PASS**
Expected: 23 tests passed.
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/parsers/serialize_837.py backend/tests/test_serialize_837.py
git commit -m "feat(sp8): serialize_837_for_resubmit + edited-field round-trip tests"
```
---
## Phase 2 — `serialize-837` endpoint
### Task 6: `GET /api/claims/{id}/serialize-837`
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Test: `backend/tests/test_api_serialize_837.py`
- [ ] **Step 1: Write failing API tests**
Create `backend/tests/test_api_serialize_837.py`:
```python
import io
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import create_app
from cyclone.db import reset_db_for_tests
from cyclone.parsers.parse_837 import parse_837_text
@pytest.fixture
def client_and_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
db_url = f"sqlite:///{Path(tmp) / 'serialize-test.db'}"
monkeypatch.setenv("CYCLONE_DB_URL", db_url)
reset_db_for_tests(db_url)
app = create_app()
yield TestClient(app), db_url
def _seed_claim(client: TestClient) -> str:
fixture = Path("backend/tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
)
assert r.status_code == 200, r.text
claim_id = r.json()["claims"][0]["id"]
return claim_id
def test_endpoint_returns_x12_attachment(client_and_db):
client, _ = client_and_db
claim_id = _seed_claim(client)
r = client.get(f"/api/claims/{claim_id}/serialize-837")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/x12") or \
r.headers["content-type"].startswith("text/plain")
assert "attachment" in r.headers["content-disposition"]
assert claim_id in r.headers["content-disposition"]
assert r.text.startswith("ISA*")
def test_endpoint_404_for_missing_claim(client_and_db):
client, _ = client_and_db
r = client.get("/api/claims/CLM-NOPE/serialize-837")
assert r.status_code == 404
body = r.json()
assert body["error"] == "Not found"
assert "CLM-NOPE" in body["detail"]
```
- [ ] **Step 2: Run, confirm FAIL**
Expected: `404` (route doesn't exist) — confirm before moving on.
- [ ] **Step 3: Add the endpoint to `api.py`**
In `backend/src/cyclone/api.py`, near the existing claim-detail handler (search for `def get_claim_detail` or similar), add:
```python
from cyclone.parsers.serialize_837 import SerializeError, serialize_837
@app.get("/api/claims/{claim_id}/serialize-837")
def serialize_claim_837(claim_id: str):
"""Return the claim as a regenerated X12 837P file."""
claim = store.get_claim_raw(claim_id)
if claim is None:
return JSONResponse(
{"error": "Not found", "detail": f"Claim {claim_id} not found"},
status_code=404,
)
try:
text = serialize_837(claim)
except SerializeError as e:
return JSONResponse(
{"error": "Unprocessable", "detail": str(e)},
status_code=422,
)
return Response(
content=text,
media_type="text/x12",
headers={
"Content-Disposition": f'attachment; filename="claim-{claim_id}.x12"'
},
)
```
> **Adapt `store.get_claim_raw(claim_id)` to whatever accessor the existing
> `api.py` uses for loading a `ClaimOutput` from the DB.** Look at how
> `get_claim_detail` retrieves the full claim and reuse the same path.
- [ ] **Step 4: Run, confirm PASS**
Expected: 2 passed.
- [ ] **Step 5: Add the 422 case test**
Append to `backend/tests/test_api_serialize_837.py`:
```python
def test_endpoint_422_for_claim_without_raw_json(client_and_db):
client, _ = client_and_db
claim_id = _seed_claim(client)
# Wipe the raw_segments on the stored claim to simulate a malformed row.
from cyclone.db import get_session
from cyclone.parsers.models import ClaimOutput
# Use the store's update path; if none exists, skip this test or
# add a fixture that seeds a claim directly with raw_segments=[].
# (Implementation depends on the store's update API; see api.py for hints.)
pytest.skip("depends on store.update_raw_segments API; implement when endpoint needs it")
```
> **Note:** the 422 path is hard to trigger without an update API for
> raw_segments. If skipped, the production code path still raises — the
> unit test for `serialize_837` (Task 4) covers the exception. Keep the
> skip comment as a placeholder for future coverage.
- [ ] **Step 6: Run, confirm PASS**
- [ ] **Step 7: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_api_serialize_837.py
git commit -m "feat(sp8): GET /api/claims/{id}/serialize-837 endpoint"
```
---
## Phase 3 — Drawer "Download 837" button
### Task 7: `api.getClaim837` in `src/lib/api.ts`
**Files:**
- Modify: `src/lib/api.ts`
- Test: `src/lib/api.test.ts` (or new file)
- [ ] **Step 1: Write failing test**
In `src/lib/api.test.ts`, append:
```typescript
import { describe, expect, it, vi, afterEach } from "vitest";
import { api } from "./api";
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("api.getClaim837", () => {
it("fetches the X12 blob for a claim id", async () => {
const fakeBlob = new Blob(["ISA*..."], { type: "text/x12" });
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
blob: async () => fakeBlob,
}));
const result = await api.getClaim837("CLM-1");
expect(result).toBeInstanceOf(Blob);
expect(await result.text()).toBe("ISA*...");
});
it("throws on non-2xx", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: false,
status: 404,
statusText: "Not Found",
json: async () => ({ error: "Not found", detail: "x" }),
}));
await expect(api.getClaim837("CLM-NOPE")).rejects.toThrow(/404/);
});
});
```
- [ ] **Step 2: Run, confirm FAIL**
```bash
npx vitest run src/lib/api.test.ts
```
- [ ] **Step 3: Add `getClaim837` to `src/lib/api.ts`**
In `src/lib/api.ts`, near the other claim methods, add:
```typescript
async getClaim837(claimId: string): Promise<Blob> {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
const r = await fetch(`${baseUrl}/api/claims/${claimId}/serialize-837`);
if (!r.ok) {
let detail = r.statusText;
try {
const body = await r.json();
detail = body.detail ?? body.error ?? detail;
} catch {
// ignore JSON parse failure
}
throw new Error(`serialize-837 failed (${r.status}): ${detail}`);
}
return r.blob();
},
```
- [ ] **Step 4: Run, confirm PASS**
- [ ] **Step 5: Commit**
```bash
git add src/lib/api.ts src/lib/api.test.ts
git commit -m "feat(sp8): api.getClaim837 in api client"
```
---
### Task 8: ClaimDrawerHeader "Download 837" button
**Files:**
- Modify: `src/components/ClaimDrawer/ClaimDrawerHeader.tsx`
- Test: `src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx`
- [ ] **Step 1: Write failing test**
Append to `src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx`:
```typescript
import { describe, expect, it, vi } from "vitest";
import { api } from "@/lib/api";
// (use existing render imports from the test file)
describe("ClaimDrawerHeader — Download 837 button", () => {
it("calls api.getClaim837 when the button is clicked", async () => {
const fakeBlob = new Blob(["ISA*..."], { type: "text/x12" });
const spy = vi.spyOn(api, "getClaim837").mockResolvedValue(fakeBlob);
// Render the header with a stub claim — adapt to existing test helpers:
// render(<ClaimDrawerHeader claim={stubClaim} onClose={() => {}} />)
// Click the button — name "Download 837" or similar.
// Assert spy called with claim.id.
// (The exact render+click pattern depends on the existing test file;
// mirror the style of the other header tests.)
});
});
```
> **Note:** look at how the existing `ClaimDrawerHeader.test.tsx` renders
> and asserts (it likely uses `render` from `@testing-library/react` and
> `userEvent` from `@testing-library/user-event`). Use the same patterns.
- [ ] **Step 2: Run, confirm FAIL**
- [ ] **Step 3: Add the button + click handler**
In `src/components/ClaimDrawer/ClaimDrawerHeader.tsx`, near the existing
header buttons (close X), add:
```tsx
import { api } from "@/lib/api";
// (already imported? check existing imports)
async function download837(claimId: string) {
const blob = await api.getClaim837(claimId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `claim-${claimId}.x12`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
```
In the JSX, alongside the existing header buttons:
```tsx
<Button
variant="ghost"
size="sm"
onClick={() => {
void download837(claim.id);
}}
data-testid="download-837-button"
disabled={!claim.rawSegments || claim.rawSegments.length === 0}
>
Download 837
</Button>
```
> **Adapt the disable check** to the actual field on the claim payload
> that indicates raw segments presence. The `ClaimDetail` type from
> `src/types/index.ts` should have a `rawSegments: string[]` or similar.
- [ ] **Step 4: Run, confirm PASS**
- [ ] **Step 5: Commit**
```bash
git add src/components/ClaimDrawer/ClaimDrawerHeader.tsx src/components/ClaimDrawer/ClaimDrawerHeader.test.tsx
git commit -m "feat(sp8): ClaimDrawerHeader — Download 837 button"
```
---
## Phase 4 — Resubmit `?download=true` + bundle modal
### Task 9: Extend `POST /api/inbox/rejected/resubmit` with `?download=true`
**Files:**
- Modify: `backend/src/cyclone/api.py`
- Test: `backend/tests/test_inbox_resubmit_download.py`
- [ ] **Step 1: Write failing tests**
Create `backend/tests/test_inbox_resubmit_download.py`:
```python
import io
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import create_app
from cyclone.db import reset_db_for_tests
@pytest.fixture
def client_and_db(monkeypatch):
with tempfile.TemporaryDirectory() as tmp:
db_url = f"sqlite:///{Path(tmp) / 'resubmit-dl-test.db'}"
monkeypatch.setenv("CYCLONE_DB_URL", db_url)
reset_db_for_tests(db_url)
app = create_app()
yield TestClient(app)
def _seed_rejected_claims(client: TestClient, n: int = 2) -> list[str]:
"""Seed n claims and reject them via a 999 ACK so they land in the
Rejected lane (mirror the test pattern in test_inbox_endpoints.py)."""
ids = []
for _ in range(n):
fixture = Path("backend/tests/fixtures/co_medicaid_837p.txt").read_text()
r = client.post(
"/api/parse-837",
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
)
assert r.status_code == 200, r.text
ids.append(r.json()["claims"][0]["id"])
# Send a rejecting 999 to flip them into REJECTED state. Mirror the
# existing fixture-driven rejection in test_inbox_endpoints.py.
rej_999 = Path("backend/tests/fixtures/minimal_999_rejected.txt").read_text()
r999 = client.post(
"/api/parse-999",
files={"file": ("ack.999", io.BytesIO(rej_999.encode()), "text/plain")},
)
assert r999.status_code == 200, r999.text
return ids
def test_resubmit_default_returns_no_files(client_and_db):
client = client_and_db
_seed_rejected_claims(client, n=1)
r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": []})
# (depends on the existing endpoint accepting an empty list — if it
# requires at least one id, adjust the seed count)
assert r.status_code == 200
body = r.json()
assert "files" not in body # default behavior unchanged
def test_resubmit_download_true_returns_x12_per_resubmitted_claim(client_and_db):
client = client_and_db
claim_ids = _seed_rejected_claims(client, n=2)
r = client.post(
"/api/inbox/rejected/resubmit?download=true",
json={"claim_ids": claim_ids},
)
assert r.status_code == 200, r.text
body = r.json()
assert body["ok"] is True
assert set(body["resubmitted"]) == set(claim_ids)
assert len(body["files"]) == len(claim_ids)
for entry in body["files"]:
assert entry["x12_text"].startswith("ISA*")
assert entry["filename"].endswith(".x12")
assert entry["claim_id"] in claim_ids
```
- [ ] **Step 2: Run, confirm FAIL**
- [ ] **Step 3: Extend the endpoint**
In `backend/src/cyclone/api.py`, find the `inbox_resubmit_rejected`
function (or however the existing endpoint is named). Add a `download`
query-param branch:
```python
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
@app.post("/api/inbox/rejected/resubmit")
def inbox_resubmit_rejected(
payload: dict,
download: bool = False,
):
# ... existing state-flip + return logic ...
result = {
"ok": True,
"resubmitted": [...], # whatever the existing code returns
"conflicts": [...],
}
if download:
files = []
for idx, cid in enumerate(result["resubmitted"], start=1):
claim = store.get_claim_raw(cid)
if claim is None:
continue
x12 = serialize_837_for_resubmit(claim, interchange_index=idx)
files.append({
"claim_id": cid,
"filename": f"{cid}.x12",
"x12_text": x12,
})
result["files"] = files
return result
```
> **Adapt the integration to the existing endpoint's body.** Look at
> how `payload["claim_ids"]` is currently iterated and what shape
> `resubmitted`/`conflicts` take. Don't refactor — just add the `files`
> branch.
- [ ] **Step 4: Run, confirm PASS**
- [ ] **Step 5: Commit**
```bash
git add backend/src/cyclone/api.py backend/tests/test_inbox_resubmit_download.py
git commit -m "feat(sp8): resubmit ?download=true returns X12 bundle"
```
---
### Task 10: Frontend `api.resubmitRejected({download})` + Inbox modal
**Files:**
- Modify: `src/lib/api.ts`
- Modify: `src/pages/Inbox.tsx`
- Test: `src/pages/Inbox.test.tsx`
- Modify: `src/index.html` (add JSZip CDN script tag)
- [ ] **Step 1: Add JSZip CDN script tag**
In `index.html` (or wherever the root HTML lives — check `vite.config.ts`
for the `root`), add inside `<head>`:
```html
<script src="https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js" defer></script>
```
This exposes `JSZip` as `window.JSZip` at runtime. No npm dep.
- [ ] **Step 2: Extend `api.resubmitRejected` to support `{download: true}`**
In `src/lib/api.ts`, find `resubmitRejected` and add an options arg:
```typescript
async resubmitRejected(
claimIds: string[],
options: { download?: boolean } = {},
): Promise<{
ok: boolean;
resubmitted: string[];
conflicts: Array<{ claim_id: string; reason: string }>;
files?: Array<{ claim_id: string; filename: string; x12_text: string }>;
}> {
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
const qs = options.download ? "?download=true" : "";
const r = await fetch(`${baseUrl}/api/inbox/rejected/resubmit${qs}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ claim_ids: claimIds }),
});
if (!r.ok) throw new Error(`resubmit failed (${r.status})`);
return r.json();
},
```
- [ ] **Step 3: Write failing Inbox test**
Append to `src/pages/Inbox.test.tsx`:
```typescript
it("resubmit with download modal zips and downloads", async () => {
// 1. Render Inbox
// 2. Select a rejected claim row
// 3. Click "Resubmit" → opens modal
// 4. Click "Resubmit + Download" → calls api.resubmitRejected with download:true
// 5. Assert the response's files[] are turned into a zip and a download is triggered
// Use vi.spyOn(api, "resubmitRejected") and mock window.JSZip.
// Verify a zip Blob is created and an anchor click is fired.
});
```
> **Implementation note:** mirror the existing Inbox test style for
> rendering and selection. The "Resubmit + Download" modal is a new
> component (or inline within Inbox.tsx). Keep it minimal.
- [ ] **Step 4: Run, confirm FAIL**
- [ ] **Step 5: Add the modal + download flow in `Inbox.tsx`**
In `src/pages/Inbox.tsx`, modify the existing bulk-resubmit handler so
that when N > 1 claims are selected, the user is prompted:
```tsx
const [confirmDownload, setConfirmDownload] = useState(false);
const [pendingResubmitIds, setPendingResubmitIds] = useState<string[]>([]);
async function performResubmit(ids: string[], withDownload: boolean) {
const result = await api.resubmitRejected(ids, { download: withDownload });
if (withDownload && result.files && result.files.length > 0) {
// Build a zip in-browser using JSZip (loaded via CDN).
const zip = new (window as unknown as { JSZip: new () => unknown }).JSZip();
for (const f of result.files) {
(zip as { file: (n: string, c: string) => void }).file(f.filename, f.x12_text);
}
const blob = await (zip as { generateAsync: (o: { type: string }) => Promise<Blob> })
.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "resubmit-bundle.zip";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
void refetch();
}
function onResubmitClick() {
const ids = selected.rejected;
if (ids.length === 0) return;
if (ids.length === 1) {
void performResubmit(ids, false);
return;
}
// Multi-claim: confirm download choice
setPendingResubmitIds(ids);
setConfirmDownload(true);
}
```
In the JSX, render a simple inline modal (or a dedicated component —
keep it minimal):
```tsx
{confirmDownload && (
<div role="dialog" aria-label="Resubmit and download bundle?">
<p>Resubmit {pendingResubmitIds.length} claims and download a 837 bundle?</p>
<button onClick={() => { setConfirmDownload(false); void performResubmit(pendingResubmitIds, true); }}>
Resubmit + Download
</button>
<button onClick={() => { setConfirmDownload(false); void performResubmit(pendingResubmitIds, false); }}>
Resubmit only
</button>
</div>
)}
```
> **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/<claim_id>/serialize-837 | head -20
# Expected: HTTP 200, Content-Type text/x12, attachment Content-Disposition,
# body starts with "ISA*".
lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill
```
- [ ] **Step 5: Final commit + branch ready**
```bash
git log --oneline main..HEAD
git push -u origin sp8-serialize-837
```
---
## Self-review checklist
- [x] **Spec coverage:**
- §1 scope (serializer + resubmit integration + drawer button) → T1T10
- §2 goal 1 (one public function) → T4
- §2 goal 2 (edits propagate) → T4 + T5 (round-trip tests)
- §2 goal 3 (stable segments preserved verbatim) → T4 (`_pass_through_stable_segments`)
- §2 goal 4 (resubmit lane completes the loop) → T9 + T10
- §2 goal 5 (drawer affordance) → T8
- §3.1 hybrid approach → T4 (decision baked in)
- §3.2 editable vs stable classification → T4 (`_EDITABLE_SEGMENT_KINDS`)
- §3.3 no envelope-level batching → implicit (single CLM only)
- §3.4 `?download=true` resubmit → T9
- §3.5 drawer download → T8
- §4.1 `serialize_837.py` helpers → T1T5
- §4.2 API response additions → T6, T9
- §5 UI additions → T7, T8, T10
- §6 no migration / no new deps → implicit (only JSZip CDN script added)
- §7 test targets → T1T12 all hit the counts
- [x] **Placeholder scan:** no TBD / TODO / "implement later" /
"similar to Task N" / "add appropriate error handling" without code.
One `pytest.skip` placeholder in T6 with rationale.
- [x] **Type consistency:**
- `serialize_837(claim, *, interchange_control_number="000000001", group_control_number="1") -> str` — same shape across T1, T4, T5
- `serialize_837_for_resubmit(claim, *, interchange_index: int) -> str` — matches §3.4
- `_EDITABLE_SEGMENT_KINDS` — defined once in T4, used by `_pass_through_stable_segments`
- `SerializeError` — raised by `serialize_837`, caught in `serialize_claim_837` endpoint
- `api.getClaim837(claimId: string): Promise<Blob>` — consistent across T7 + T8
- `api.resubmitRejected(claimIds, options: {download?: boolean})` — consistent across T9 + T10
- [x] **Scope:** 13 tasks, one branch, one PR. Within a single plan.
---
## Execution Handoff
**Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-serialize-837.md`.**
13 tasks across 5 phases. Per session convention, executing inline rather than dispatching subagents (per the user's "yolo" directive on prior plans — assumed to carry through).