Files
cyclone/backend
Nora 9c0aa577fb fix: RateLimitMiddleware._buckets is class-level so reload-safe
20 pre-existing pytest failures were caused by tests/test_api.py
calling importlib.reload(cyclone.api) mid-suite. Reload creates a
NEW FastAPI app with a NEW RateLimitMiddleware whose private
_buckets dict is independent of the OLD instance. Tests that
imported 'from cyclone.api import app' at module load kept
referencing the OLD app, so their requests accumulated in the
orphaned bucket which the conftest reset never cleared. After
~300 requests, the orphaned bucket tripped the limiter and these
tests got spurious 429s:
  - test_existing_endpoints_require_auth (6)
  - test_inbox_endpoints (8)
  - test_inbox_endpoints_sp7 (1)
  - test_list_endpoint_counts (4)

Hoisting _buckets to a class-level dict makes one
RateLimitMiddleware._buckets.clear() (in conftest reset) reach
every instance — current, stale, post-reload — eliminating the
orphaned-bucket leak. Per-instance _lock stays per-instance
since it guards mutation of the shared dict.

Verified stable: 3 consecutive full-suite runs all pass with
1435 passed, 10 skipped, 0 failed in ~82s each.
2026-07-07 13:56:49 -06:00
..

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} — default co_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 parsed
  • 2 — 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:

  • payerco_medicaid (default) or generic_837p
  • include_raw_segmentstrue (default) or false
  • strictfalse (default) or true (promote warnings to errors)

The response shape depends on the request's Accept header:

  • Accept: application/json → a single ParseResult-shaped JSON object (envelope, claims, summary).
  • No Accept header (or anything that isn't application/json) → application/x-ndjson streaming, 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 unknown payer
  • 422 — at least one claim failed validation (body still includes the full ParseResult so 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 segments
  • financial_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 — one ClaimPayment per Loop 2100 CLP, with embedded ServicePayment rows for each Loop 2110 SVC
  • summary — same BatchSummary used by 837P

835 CLI

python -m cyclone.cli parse-835 path/to/835.txt --output-dir ./payouts

Options:

  • --payer {co_medicaid_835,generic_835} — default co_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 of CLP04 (total claim paid) across all claims in the batch. Tolerance is $0.01.
  • R835_BAL_CLP04_vs_SVC03 — each CLP04 must equal the sum of its embedded SVC03 service 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-data with a single file field
  • Query params: payer (default co_medicaid_835), include_raw_segments (default true), strict (default false)
  • Content negotiation: Accept: application/json → single ParseResult835 object; default → NDJSON stream with envelope → financial_info → trace → payer → payee → claim_payment (×N) → summary lines
  • 400 on CycloneParseError or unknown payer
  • 422 when validate() reports errors (body still includes the full ParseResult835 so 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"