Gainwell's production filer has shipped at least two inbound filename
shapes — the spec form with a trailing `_{file_type}.x12` suffix and
a shorter suffix-less form where the disambiguator token (between
`-` and `_M`) doubles as both orig_tx and file_type. The 6/15–6/19
835 batch arrived in the suffix-less form, and the strict
`INBOUND_RE` rejected them outright — the scheduler silently
dropped 5 days of production data without logging an error.
`backend/src/cyclone/edi/filenames.py`
- New `INBOUND_RE_LOOSE` regex: same prefix/tpid/tracking/ts/seq
rules as `INBOUND_RE`, but the disambiguator token is required
to be 3–5 uppercase alnum (covers 999, TA1, 835, 277CA, ENCR; the
5-char cap stops the engine from over-eating the next `_M` token
in a degenerate input).
- `parse_inbound_filename` now tries `INBOUND_RE` first
(preserves historical behavior for every existing caller) and
falls back to `INBOUND_RE_LOOSE`. In the loose form, orig_tx is
set to the disambiguator token so the parsed shape matches what
the strict form produces when orig_tx == file_type. The
`ALLOWED_FILE_TYPES` check is enforced in both branches.
- `is_inbound_filename` is loosened in the same shape so the two
never disagree — a refactor that pre-filters a directory listing
with `is_inbound_filename` then re-parses with
`parse_inbound_filename` would otherwise see the suffix-less
files rejected twice.
`backend/tests/test_inbound_filename_loose.py` (new, 10 tests):
1. Spec form with explicit `_835.x12` suffix still wins (strict
path unchanged).
2-5. Suffix-less 835 / 999 / 277CA / ENCR all parse correctly.
6. Suffix-less `.txt` is still rejected (the `.x12` ext check
applies to both forms).
7. Suffix-less unknown type (4-char `ABCD`) is rejected by
ALLOWED_FILE_TYPES — the loose regex's `{3,5}` shape would
otherwise let it through.
8. Suffix-less 6-char token (`999XX6`) is rejected by the
5-char cap, preventing the engine from swallowing the next
`_M` token.
9. Strict form takes precedence over the loose form when both
match — pinning the parser's branch order so a refactor can't
silently change the parsed shape.
10. `is_inbound_filename` accepts the loose form, the spec form,
and rejects garbage.
Live smoke: 5/5 suffix-less Gainwell patterns now parse correctly
(were ValueError before); spec form unchanged; 4 invalid forms
still rejected. Full backend suite: 1085/1121 — the 36 pre-existing
failures (test_serialize_837, test_api_stream_live,
test_inbox_endpoints) are unrelated and confirmed pre-existing by
running them on stashed pre-change code.
Cyclone Backend — X12 837P / 835 ERA Parser
Python module + CLI for parsing X12 837P professional claim files and X12 835 ERA (Health Care Claim Payment/Advice) files into one validated JSON per claim / payout. Output is consumed by the Cyclone Vite/React frontend via a FastAPI service (see REST API below).
Install
cd backend
python -m venv .venv
. .venv/bin/activate
pip install -e ".[dev]"
CLI
python -m cyclone.cli parse-837 path/to/837p.txt --output-dir ./claims
Options:
--payer {co_medicaid,generic_837p}— defaultco_medicaid--strict— promote warnings to errors--max-retries N— stub in v1 (no auto-fix yet)--include-raw-segments / --no-raw-segments— default: include--log-level {DEBUG,INFO,WARNING,ERROR}
Exit codes:
0— every claim parsed2— file-level failure (no envelope, no claims)1— unexpected exception
Output
claims/
├── claim-991102977-20260611-CLM001.json # one per claim
├── claim-991102977-20260611-CLM002.json
└── summary.json # batch summary
Programmatic use
from pathlib import Path
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.payer import PayerConfig
from cyclone.parsers.writer import write_outputs
result = parse(Path("input.txt").read_text(), PayerConfig.co_medicaid())
write_outputs(result, Path("./claims"))
print(f"passed={result.summary.passed} failed={result.summary.failed}")
Tests
pytest -v
The CLI smoke test against docs/prodfiles/837p-from-axiscare/*.txt is
auto-skipped if no production files are present.
REST API
The Cyclone Vite/React frontend (default dev origin http://localhost:5173)
talks to the parser over HTTP. The API module is cyclone.api:app.
Run the server
uvicorn cyclone.api:app --reload --port 8000
# or, equivalently:
python -m cyclone serve
Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /api/health |
Liveness probe: {"status": "ok", "version": ...} |
| POST | /api/parse-837 |
Upload an X12 837P file, get parsed claims back |
| POST | /api/parse-835 |
Upload an X12 835 ERA file, get parsed payouts back |
| POST | /api/parse-999 |
Upload an inbound 999 ACK and persist it |
| POST | /api/parse-ta1 |
Upload an inbound TA1 envelope ACK and persist it |
| POST | /api/parse-277ca |
Upload a 277CA claim acknowledgment and stamp payer_rejected claims |
| POST | /api/eligibility/request |
Build a 270 from JSON (subscriber / provider / payer) |
| POST | /api/eligibility/parse-271 |
Ingest a 271 and return structured coverage_benefits |
| GET | /api/clearhouse |
The clearhouse singleton (SP9) |
| POST | /api/clearhouse/submit |
Push a batch of generated 837 files via SFTP (SP9 stub, SP13 real) |
| POST | /api/admin/reload-config |
Re-read config/payers.yaml and refresh the in-process cache |
| GET | /api/admin/audit-log |
Paginated tamper-evident audit log (SP11) |
| GET | /api/admin/audit-log/verify |
Walk the audit_log hash chain (SP11) |
The full surface — claim / remittance / batch / inbox / stream endpoints, config lookups, and the 270/271 builder — is enumerated in the root README.
POST /api/parse-837 accepts multipart/form-data with a single file
field. Optional query parameters:
payer—co_medicaid(default) orgeneric_837pinclude_raw_segments—true(default) orfalsestrict—false(default) ortrue(promote warnings to errors)
The response shape depends on the request's Accept header:
Accept: application/json→ a singleParseResult-shaped JSON object (envelope, claims, summary).- No
Acceptheader (or anything that isn'tapplication/json) →application/x-ndjsonstreaming, one JSON object per line in this order:{"type": "envelope", "data": {...}}, one{"type": "claim", "data": {...}}per claim, then{"type": "summary", "data": {...}}.
Error responses are JSON of the form {"error": "...", "detail": "..."}:
400— file-level parse error (CycloneParseError) or unknownpayer422— at least one claim failed validation (body still includes the fullParseResultso the client can render the errors)500— unexpected internal error
CORS is configured to allow http://localhost:5173 with GET and POST
and any headers.
Example: upload + JSON response
curl -X POST http://localhost:8000/api/parse-837 \
-F "file=@./samples/co_837p.txt" \
-H "Accept: application/json"
{
"envelope": { "sender_id": "...", "receiver_id": "...", ... },
"claims": [ { "claim_id": "C1", ... }, { "claim_id": "C2", ... } ],
"summary": { "total_claims": 2, "passed": 2, "failed": 0, ... }
}
Example: stream NDJSON from the browser
const resp = await fetch("http://localhost:8000/api/parse-837", {
method: "POST",
body: formData, // FormData with a "file" entry
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
for (const line of buffer.split("\n")) {
if (!line) continue;
const evt = JSON.parse(line);
if (evt.type === "claim") renderClaim(evt.data);
else if (evt.type === "summary") showSummary(evt.data);
}
buffer = buffer.slice(buffer.lastIndexOf("\n") + 1);
}
Spec & plan
- Design:
docs/superpowers/specs/2026-06-19-cyclone-837p-parser-design.md - Plan:
docs/superpowers/plans/2026-06-19-cyclone-837p-parser.md
835 ERA parser
The 835 ERA parser mirrors the 837P architecture but is a separate
module (cyclone.parsers.parse_835) with its own models
(cyclone.parsers.models_835), writer (cyclone.parsers.writer_835),
validator (cyclone.parsers.validator_835), and PayerConfig835 preset.
It parses one 835 batch into:
envelope— ISA / GS / ST control segmentsfinancial_info— BPR (total paid, handling code, payer tax id, date)trace— TRN (reassociation trace number + originating company id)payer— Loop 1000A (NM1*PR + N3/N4/PER contact URL)payee— Loop 1000B (NM1*PE + N3/N4)claims— oneClaimPaymentper Loop 2100 CLP, with embeddedServicePaymentrows for each Loop 2110 SVCsummary— sameBatchSummaryused by 837P
835 CLI
python -m cyclone.cli parse-835 path/to/835.txt --output-dir ./payouts
Options:
--payer {co_medicaid_835,generic_835}— defaultco_medicaid_835--strict— promote warnings to errors--include-raw-segments / --no-raw-segments— default: include--log-level {DEBUG,INFO,WARNING,ERROR}
Output:
payouts/
├── payout-991102984-20260617-CLM001.json # one per claim, with envelope/financial/trace/payer/payee embedded
├── payout-991102984-20260617-CLM002.json
└── summary.json
835 programmatic use
from pathlib import Path
from cyclone.parsers.parse_835 import parse
from cyclone.parsers.payer import PayerConfig835
from cyclone.parsers.validator_835 import validate
from cyclone.parsers.writer_835 import write_outputs_835
result = parse(Path("input.txt").read_text(), PayerConfig835.co_medicaid_835())
report = validate(result, PayerConfig835.co_medicaid_835())
print(f"passed={report.passed} errors={len(report.errors)}")
write_outputs_835(result, Path("./payouts"))
835 payout balancing
The 835 validator enforces two monetary-balancing rules:
R835_BAL_BPR_vs_CLP04— the BPR02 total paid must equal the sum ofCLP04(total claim paid) across all claims in the batch. Tolerance is $0.01.R835_BAL_CLP04_vs_SVC03— eachCLP04must equal the sum of its embeddedSVC03service payment amounts. Tolerance is $0.01.
These rules catch the common "BPR doesn't match the sum of the claims" mismatch (often caused by a missing or duplicated claim in the batch).
835 REST API
POST /api/parse-835 mirrors POST /api/parse-837 exactly:
multipart/form-datawith a singlefilefield- Query params:
payer(defaultco_medicaid_835),include_raw_segments(defaulttrue),strict(defaultfalse) - Content negotiation:
Accept: application/json→ singleParseResult835object; default → NDJSON stream withenvelope → financial_info → trace → payer → payee → claim_payment (×N) → summarylines 400onCycloneParseErroror unknownpayer422whenvalidate()reports errors (body still includes the fullParseResult835so the UI can show the issues)
curl -X POST http://localhost:8000/api/parse-835 \
-F "file=@./samples/co_835.txt" \
-H "Accept: application/json"