- Mark SP8 as shipped (Sub-projects 2 through 8)
- Drop the 'Next up: outbound 837P serializer' trailer
- New 'Outbound 837 Serializer' section above 'Per-Line Adjustment
Audit': design rationale (Approach A full rebuild vs spec §3.1
hybrid), two-surface UX (single-claim drawer download + multi-claim
inbox ZIP bundle), pointers to the serializer module, the two API
endpoints, the frontend helpers, and the UI entry points.
- New 'SP8 endpoints' subsection in the endpoint inventory.
- inbox-api.test.ts: pin ?download=true POST contract; blob + filename +
X-Cyclone-Serialize-Errors parsing; non-2xx error surfacing.
- Inbox.test.tsx: end-to-end multi-select → Resubmit + Download path
verifies api call args and downloadBlob wiring.
- inbox-api.ts: drop redundant isConfigured short-circuit in download
variant (backend has its own auth gate) and switch error reading to
res.text() to match Blob response shape.
Wire the new ``?download=true`` resubmit endpoint into the Inbox
page. Operators can now ask the backend for a ZIP of regenerated
837s straight from the rejected-claims bulk action, with the
``X-Cyclone-Serialize-Errors`` header surfaced as a non-blocking
warning so partial successes don't swallow per-claim failures.
* ``src/lib/inbox-api.ts``: new ``resubmitRejectedWithDownload``
helper returning ``{blob, filename, serializeErrors}`` so callers
can hand the bundle to the new ``downloadBlob`` utility without
re-parsing headers.
* ``src/lib/download.ts``: new ``downloadBlob(blob, filename)`` plus
a test covering the extension/content-type mapping and the
"use the suggested filename when present" rule.
* ``src/pages/Inbox.tsx``: rejected-claims bulk action now exposes
a "Resubmit & download" button next to the existing JSON path,
wired through the helper. Conflicts and per-claim serialize
errors render in the existing toast/result surface.
Tests: 4 new download.ts tests, 5 inbox-api tests (including
serialize-errors header parsing).
Add an optional ``?download=true`` query param to
``POST /api/inbox/rejected/resubmit`` that returns the same operation
result as a ZIP archive of regenerated 837P files (one
``claim-{id}.x12`` per successfully resubmitted claim) rather than the
JSON envelope.
Why: operators who mass-resubmit rejected claims want to hand the
files straight to their clearinghouse; round-tripping through copy-
paste is error-prone.
Implementation notes:
* Uses ``serialize_837_for_resubmit`` so each X12 file in the bundle
gets a unique interchange/group control number (back-to-back
resubmits would otherwise collide on ISA13/GS06 = "000000001").
* Conflicts and missing ids are deliberately excluded from the ZIP
— the user already saw them in the JSON path on prior calls; the
download is the "give me the files I asked for" payload.
* Empty resubmit + download returns 200 with an empty ZIP so the UI
can still hand the user a downloadable artifact.
Tests (test_inbox_endpoints.py): 2 new tests covering the success
shape (one .x12 per accepted claim) and the conflict-exclusion
contract.
Three pieces:
- src/lib/download.ts: generic downloadTextFile(filename, mime, text)
helper. Mirrors csv.ts:downloadCsv but takes an explicit MIME type and
drops the BOM prepend (which would corrupt the ISA segment).
- src/lib/api.ts: serializeClaim837(id) → {text, filename}. Fetches
GET /api/claims/{id}/serialize-837, pulls the suggested filename from
Content-Disposition (falls back to claim-{id}.x12 if the header is
missing). Throws ApiError on non-2xx so callers can branch on .status.
- ClaimDrawerHeader: Download icon button between the amount and the
close button. Click → api.serializeClaim837 → downloadTextFile.
Disabled + 'Downloading 837 file' aria-label while the fetch is in
flight so the click feels responsive. Optional onError prop surfaces
fetch failures; defaults to a no-op so existing callers stay clean.
Tests: 3 download.test.ts, 3 api.test.ts, 2 header.test.ts (happy
path + error path). Frontend: 350 passing (+8 from 342).
X12 835 SVC segment:
SVC01 = composite procedure
SVC02 = charge
SVC03 = payment
SVC04 = Unit or Basis for Measurement Code (UN, MJ, DA, ...)
SVC05 = Service Unit Count
The parser previously read SVC04 as the units count and SVC05 as the
unit type — backwards. On real 835s (and the canonical minimal
fixture), SVC04 carries the code 'UN' which fails Decimal parsing, so
the units always came out as None and the code string was assigned to
unit_type. SP7's line-level matcher couldn't compare units on the SVC
side against the claim side because of this.
- _consume_service_payment: SVC04 → unit_type, SVC05 → units count
- Default unit_type to 'UN' when only the count is present
- minimal_835.txt + unbalanced_835.txt: swap positions to match spec
- Add 2 regression tests (units-and-unit-type, default-unit-type-to-UN)
Returns the persisted ClaimOutput as a regenerated X12 837P file via
the new outbound serializer (Approach A — full rebuild from canonical
fields). 404 on missing claim, 422 if the stored raw_json cannot be
validated as a ClaimOutput. text/x12 content-type, attachment
disposition with the claim id as the filename.
3 tests:
- endpoint returns text/x12 attachment starting with ISA*
- 404 for missing claim id
- regenerated text round-trips back through parse()
- 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.
Existing test fixtures (ClaimDrawer, ClaimDrawerHeader, Claims pages)
construct partial ClaimDetail objects without the new slim
lineReconciliation projection. Other SP7 fields follow the optional
pattern (matchedLines?, serviceLinePayments?, claimLevelAdjustments?),
so apply the same to lineReconciliation.
Backend always emits it for matched claims; UI treats absent as
"no per-line audit yet".
manual_match previously only flipped the claim↔remit FK and the
claim state — it never ran line-level reconciliation, so manually-
paired claims surfaced empty line-reconciliation rows to the UI
and skipped CLP-level CAS aggregate recompute.
Refactor reconcile.run() to call a new per-pair helper
_reconcile_pair(session, claim, remittance) that:
- clears any existing LineReconciliation rows for the claim
(idempotent re-run; safe across manual_unmatch + manual_rematch
cycles that may pair the claim with a different remittance),
- reads 837 SV1 lines from Claim.raw_json and 835 SVC rows from
ServiceLinePayment,
- runs match_service_lines() and persists a LineReconciliation row
per side,
- recomputes Remittance.claim_level_adjustment_amount (CLP-level
CAS) and Remittance.adjustment_amount (total CAS).
manual_match now calls this helper after the FK is set and before
commit, mirroring the auto-match path. Reversals are skipped (they
don't have SV1↔SVC line pairs; per §7.3).
Tests (test_store_reconcile.py):
- test_manual_match_populates_line_reconciliation_rows: end-to-end
check that a manual pair writes the expected matched +
unmatched_837_only rows plus zero CAS aggregates.
- test_manual_match_idempotent_line_reconciliation: after
manual_unmatch + manual_rematch + inserting a CLP-level CAS row
directly, the claim has exactly two fresh LineReconciliation rows
(no duplicates) and the remittance aggregate reflects the new CAS.
Smoke tested end-to-end via TestClient: parse co_medicaid_837p.txt
+ co_medicaid_835.txt, auto-matcher skips (PCNs differ), manual
match via POST /api/reconciliation/match, then
GET /api/claims/{id}/line-reconciliation returns 2 rows and
GET /api/inbox/lanes shows matched_remittance.total_lines=2.
Note: matched_lines may be 0 on real 835 fixtures due to a
pre-existing parser bug in _consume_service_payment that swaps
SVC04 (units) and SVC05 (unit-of-measure) when reading units from
the segment. The SP7 strict-match criterion requires units parity,
so the misread produces None on the SVC side and strict-match
never succeeds. Surfaced as a follow-up; the SP7 work itself is
correct (line counts are computed and persisted).
- Mark SP4 as fully shipped (batch diff, search, CSV, a11y all landed)
- Add SP6 (Inbox) and SP7 (Per-line reconciliation) sections
- Add SP6 + SP7 endpoint inventories
- Note next up: outbound 837P serializer
The workflow-automation plan was authored but never committed; the
features it specified have shipped, so commit it for the historical
record alongside this README refresh.
Migration renumbered from 0005 to 0006 because 0005_create_ta1_acks.sql
(another agent's WIP, unmerged) already claims version 5. Bumping our
migration to version 6 avoids stepping on it; user_version will land
at 6 once both migrations run.
Sub-project 7 adds serialize_837.py so a parsed ClaimOutput can be
regenerated as a complete X12 837P file (the shape of
docs/prodfiles/claims/). Unblocks the SP6 resubmit lane end-to-end
and adds a 'Download 837' affordance on the claim drawer.
Approach: hybrid (fresh envelope + selective body rebuild). Stable
segments (provider/subscriber/payer hierarchies) pass through from
claim.raw_segments; editable segments (CLM, REF*G1, HI, SV1, DTP*472)
are rebuilt from canonical ClaimOutput fields so post-parse edits
propagate to the output.
Out of scope: 997 ACK, claim <-> TA1/999 linking, in-app claim
editing UI, multi-CLM envelope batching, Inbox/drawer redesign.
T7-T10 combined (single edit: all endpoints share a section).
- GET /api/inbox/lanes — four lanes in one call
- POST /api/inbox/candidates/{remit_id}/match — 409 on conflict
- POST /api/inbox/candidates/dismiss — session-scoped dismissed set
- POST /api/inbox/rejected/resubmit — 200 with conflicts list
- GET /api/inbox/export.csv?lane=<lane> — streams CSV
Also adds module-level imports (db, Claim, ClaimState, Remittance,
csv, io, datetime) that the new endpoints need; cleans up the
duplicated local imports in the parse-999 SP6 T4 block.
T4 — calls apply_999_rejections inside /api/parse-999, then
publishes a claim.rejected event for each transitioned claim so
the Inbox live-tail refetches.
T3 — moves claims to ClaimState.REJECTED on 999 AK5 R/E/X.
- New module: src/cyclone/inbox_state.py
- New tests: tests/test_inbox_state.py (4 cases)
- Adds ORM mappings for rejection_reason / rejected_at / resubmit_count
/ state_changed_at on Claim (db.py) — these were added to the
schema by migration 0004 but not yet exposed to the ORM.