19 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
Cyclone is a self-hosted X12 EDI claims-management suite for a single billing office (Colorado Medicaid currently). It parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 / 005010X221A1) and also handles 999, TA1, 270, 271, and 277CA. Always binds to 0.0.0.0 — the host firewall / compose port publishing is what restricts reachability, not the bind address. Requires login (auth boundary is HTTP; bcrypt + HttpOnly session cookie; first admin bootstrapped from CYCLONE_ADMIN_USERNAME + CYCLONE_ADMIN_PASSWORD env vars; see SP24 spec for the full posture). LAN-only by design — don't expose the published ports to the WAN.
Stack: one Python process (FastAPI + uvicorn, port 8000) + one Node process in dev (Vite, port 5173). The authoritative state is a single SQLite file at ~/.local/share/cyclone/cyclone.db (or SQLCipher at the same path when the macOS Keychain entry + sqlcipher3 are both present).
For the day-1 architecture read, see docs/ARCHITECTURE.md (process topology, module map, store facade, parser pipeline, pubsub). For the what-it-does read, see docs/REQUIREMENTS.md (FRs + NFRs + DoD).
Install
# Backend (Python 3.11+)
cd backend
python -m venv .venv
.venv/bin/pip install -e '.[dev]'
# Frontend (Node 20+)
cd ..
npm install
Optional backend extras: pip install -e '.[sqlcipher]' (encryption at rest, SP12) and pip install -e '.[sftp]' (real SFTP, SP13).
Dev (two terminals)
# Terminal 1 — backend
cd backend
.venv/bin/python -m cyclone serve # default 0.0.0.0:8000
# CYCLONE_PORT=... overrides port; CYCLONE_RELOAD=1 enables uvicorn --reload
# Or: .venv/bin/uvicorn cyclone.api:app --reload --port 8000
# Terminal 2 — frontend
npm run dev # Vite on http://localhost:5173
Vite proxies /api/* to the backend at http://127.0.0.1:${CYCLONE_PORT:-8000} so relative-URL fetchers (the live-tail NDJSON streams in particular) resolve through the same origin. Override the backend port with CYCLONE_PORT in the frontend terminal too.
Create .env.local at the repo root with VITE_API_BASE_URL=http://127.0.0.1:8000. Without it, the UI runs against the in-memory zustand store and real EDI parsing is disabled.
Test
# Backend — full suite
cd backend && .venv/bin/pytest
# Backend — one file
cd backend && .venv/bin/pytest tests/test_api_999.py -v
# Backend — one test by node id
cd backend && .venv/bin/pytest tests/test_api_999.py::test_parse_999_endpoint_happy_path -v
# Frontend — full suite
npm test # alias for `vitest run`
# Frontend — one file
npx vitest run src/hooks/useFoo.test.ts
# Frontend — typecheck
npm run typecheck
# Frontend — build (tsc -b + vite build)
npm run build
# Frontend — lint
npm run lint
Conventions (full detail in .superpowers/skills/cyclone-tests/SKILL.md):
- Backend tests live under
backend/tests/test_*.py. Two flavors:test_api_<topic>_<verb>.py(FastAPI integration viafastapi.testclient.TestClient) andtest_<module>_<behavior>.py(pure-unit). Autouseconftest.pypointsCYCLONE_DB_URLattmp_path/test.db, callsdb._reset_for_tests()+db.init_db(), and wires a freshEventBusontoapp.state. - Prodfiles (real EDI samples under
docs/prodfiles/<source>/) are never read directly from a test — copy tobackend/tests/fixtures/<descriptive-name>.txtfirst and reference as a module-levelPathconstant. Thefixtures/dir is flat (no per-test subdirs). - Frontend tests are siblings:
useFoo.ts→useFoo.test.ts,ClaimDrawer.tsx→ClaimDrawer.test.tsx. Setup is// @vitest-environment happy-domplus(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;. Mock the API at the module boundary withvi.mock("@/lib/api", ...); stub fetch withvi.stubGlobal("fetch", vi.fn().mockResolvedValue(...)).vitest.config.tssetsVITE_API_BASE_URL=http://test.localso theapimodule doesn't thrownotConfiguredErrorbefore the mock fires. - Time-sensitive tests: frontend uses
vi.useFakeTimers()+vi.setSystemTime(...)+vi.advanceTimersByTime(ms). Backend passes explicitdatetime(...)values. Don't addawait new Promise((r) => setTimeout(r, N))— it's the legacy flaky pattern.
Project-scoped skills (.superpowers/skills/)
Cyclone ships 8 skills under .superpowers/skills/. They auto-load by description match — no slash command needed. Read the relevant skill before touching the matching subsystem.
| Skill | Owns |
|---|---|
cyclone-spec |
The SP-N spec → plan → implement → merge flow (branch shape, file paths, commit prefixes, PR title, merge shape). |
cyclone-tests |
pytest + vitest fixture patterns, prodfiles drop-in rule, determinism rules. |
cyclone-edi |
EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1, R-codes, CAS mapping). |
cyclone-tail |
Live-tail streaming wire format and the useTailStream + useMergedTail + TailStatusPill hook triplet. |
cyclone-store |
CycloneStore facade, write-paths, pubsub event contract, SP21 split map. |
cyclone-api-router |
FastAPI router conventions (api_routers/, api_helpers.py), response/error-envelope shapes. |
cyclone-frontend-page |
React page conventions (TanStack Query use<X> hook, drawer, URL state, sibling test). |
cyclone-cli |
CLI subcommand conventions (cli.py, exit codes, smoke tests). |
The SP-N increment flow
Every feature ships as a numbered SP-N increment: spec → plan → implementation branch → single atomic merge into main. SP numbers are used through SP41 (in-window rebill pipeline, merged 309e6c0); the next free increment is SP42. SP23 (Ubuntu + Docker + auth + RBAC + LAN-bind product fork) shipped via merge: SP23 Ubuntu Docker Deployment into main (07a7ecb). Read cyclone-spec before starting a new one. Non-negotiable shape:
- Branch:
sp<N>-<short-kebab-topic>(e.g.sp22-line-reconciliation). - Spec path:
docs/superpowers/specs/YYYY-MM-DD-cyclone-<topic>-design.md, headerStatus: Draft, awaiting user sign-off, sectionsScope / Decisions / …. Specs contain zero code blocks. - Plan path:
docs/superpowers/plans/YYYY-MM-DD-cyclone-<topic>.md, header persuperpowers:writing-planswithGoal / Architecture / Tech Stack / Specmetadata + numbered- [ ] Step N:tasks. - Commit prefixes:
feat(sp<N>): …,docs(spec): …,docs(plan): …,merge: SP<N> <topic> into main. - PR title:
SP<N> <Topic>(matches the merge-commit subject). - Merge shape: single atomic merge commit. No squash (collapses the audit trail) and no rebase (rewrites the SHAs the review was performed against). The SP-N merge commit is the record of the increment landing.
The matching skill to load alongside cyclone-spec depends on the subsystem the SP-N touches (see the "Related skills" section at the bottom of each skill file).
Live-tail wire format
The Claims, Remittances, and Activity pages stay current without manual refresh. The backend publishes an internal event on every store write, the page opens a streaming HTTP connection to the matching /api/<resource>/stream endpoint, and new rows append to the table the moment they hit the database.
Endpoints (all accept the same query params as their non-streaming counterparts; Content-Type: application/x-ndjson):
| Method | Path | Subscribes to | Default sort |
|---|---|---|---|
| GET | /api/claims/stream |
claim_written |
-submission_date |
| GET | /api/remittances/stream |
remittance_written |
-received_date |
| GET | /api/activity/stream |
activity_recorded |
-timestamp (limit 50) |
| GET | /api/acks/stream |
ack_received |
-received_date |
| GET | /api/ta1-acks/stream |
ta1_ack_received |
-received_date |
Wire format: one JSON object per line, {"type": ..., "data": ...}. The first batch is the snapshot of currently-known rows, then snapshot_end with the count, then the live events. Known types: item, snapshot_end, heartbeat (keeps the connection alive on idle — clients flip to stalled after 30s of total silence), item_dropped (rare), error.
Status pill states (rendered by <TailStatusPill> in src/components/TailStatusPill.tsx): live (success), connecting (warning), reconnecting (warning), stalled (destructive, ↻ Reconnect button), error (destructive, ↻ Reconnect button), closed (destructive). Backoff on error: 1s → 2s → 4s → 8s → 16s → 30s capped. STALL_TIMEOUT_MS = 30_000 in src/hooks/useTailStream.ts:53. Heartbeat interval is CYCLONE_TAIL_HEARTBEAT_S env var, default 15s.
Frontend triplet for any live page: use<X>(params) (initial fetch) + useTailStream(resource) (opens the NDJSON stream, drives backoff/stall) + useMergedTail(resource, baseItems, filterFn?) (merges snapshot + tail, dedup'd by id). The subscription lives on the page, not inside the data hook — see cyclone-frontend-page for why.
Backend at a glance
backend/src/cyclone/ is a single namespace. After SP21 (store split, 4360ef7) and SP36 (api-routers split, f005494), the largest files now live under cyclone/api_routers/ (22 routers) and cyclone/store/ (the SP21-split facade in 16 modules). api.py is a ~377-line shell that wires the FastAPI app, mounts the auth + admin + api_routers routers, and re-exports a few backward-compat shims for tests; store.py no longer exists. Subpackages: api_routers/ (SP36 — 22 routers: acks, activity, admin, batches, claim_acks, claims, clearhouse, config, dashboard, eligibility, health, inbox, parse, payers, providers, rebill, reconciliation, remittances, submission, ta1_acks, plus _shared.py), auth/ (SP24 — bcrypt + HttpOnly session cookies + RBAC matrix_gate; 10 modules), clearhouse/ (Clearhouse + SftpClient), edi/ (filenames), handlers/ (SP27 — per-file-type inbound parse handlers), parsers/ (X12 transaction parsers + models + validators + serializers), rebill/ (SP41 — in-window rebill pipeline), reissue/ (SP24 — offline 837P reissue workflow), store/ (SP21 — split CycloneStore facade), submission/ (SP37 — canonical submit_file helper shared by cyclone submit-batch CLI + POST /api/submit-batch HTTP endpoint; owns parse → DB-write → SFTP-upload → audit per file). The legacy workflow/ directory is dead bytecode — see REQUIREMENTS.md R-27 (Open).
The store is the only read/write surface for the database; every mutating endpoint goes through it. All persistence flows through SQLAlchemy sessions via db.SessionLocal()(). SQLAlchemy ORM models live in db.py; 23 SQL migrations under migrations/ (0001_initial through 0023_visits) are walked in order by db_migrate.py.
The parser pipeline is a 5-stage tokenize → segmentize → model → validate → write_to_store flow used for every inbound X12 type. Per-transaction parsers: parse_837.py, parse_835.py, parse_999.py, parse_ta1.py, parse_270.py, parse_271.py, parse_277ca.py. Each has a matching Pydantic model module (models.py, models_835.py, …) and a writer (writer.py / writer_835.py). The 837P serializer (serialize_837.py) is the byte-faithful outbound counterpart used by both single-claim download (/api/claims/{id}/serialize-837) and the bulk rejected-resubmit bundle (/api/inbox/rejected/resubmit?download=true).
The pubsub is cyclone.pubsub.EventBus — an in-process async fan-out broker. Publishers call publish(kind, payload); subscribers receive via an async iterator. If a subscriber's per-kind queue is full, the oldest event is dropped so a slow consumer can't stall the producer. Bus is single-event-loop only (matches FastAPI/uvicorn).
Config: config/payers.yaml is the on-disk source for providers / payers / clearhouse, schema-validated at boot against a Pydantic model. Reload with POST /api/admin/reload-config. Original in-code PAYER_FACTORIES dict in cli.py is kept as a fallback for ad-hoc testing.
Secrets live in the macOS Keychain (via keyring + cyclone.secrets): SQLCipher key (service cyclone, account cyclone.db.key), SFTP password, backup passphrase. No secrets on disk in plaintext.
Production SFTP posture (this box: manual mode)
This host runs in manual SFTP mode against Gainwell's MOVEit Transfer MFT at mft.gainwelltechnologies.com. The seeded dzinesco clearhouse keeps sftp_block.stub: true; the operator moves files to/from Gainwell with their SFTP client and drops inbound files into ingest/ for cyclone to ingest. Do NOT flip stub to false from this host — see "Auth caveat" below.
Env var convention
The operator's shell exports the Gainwell creds as GAINWELL_SFTP_USER, GAINWELL_SFTP_PASS, GAINWELL_SFTP_HOST, GAINWELL_REMOTE_DIR. Cyclone's secrets.get_secret() looks up CYCLONE_SFTP_PASSWORD (or CYCLONE_SFTP_PASSWORD_FILE) per secrets._ENV_NAME_FOR. The two are NOT the same name — bridge them per-call or in your shell rc:
export CYCLONE_SFTP_PASSWORD="$GAINWELL_SFTP_PASS"
Inbound drop zone
/home/tyler/dev/cyclone/ingest/ is the operator-maintained staging dir for inbound MFT files (999 acks, TA1, 835 remittances, 277CA claim acks). Files here are NOT auto-watched; processing happens via the procedure in docs/RUNBOOK.md § "Manual SFTP mode". At time of writing this dir holds ~1500 unprocessed 999 acks + 1 TA1 + 1 835 from early July 2026 — the local ingest flow clears them.
Auth caveat (do not retry)
Paramiko reaches MOVEit Transfer SFTP cleanly (SSH kex completes, MOVEit offers password auth) but AuthenticationException: Authentication failed is returned despite the operator confirming the credentials are known-good from another host. Most likely cause: MOVEit Transfer's IP-based access control — this host's public IP 103.14.26.95 is not whitelisted. Repeated auth attempts risk account lockout. Do NOT keep guessing. Resolve by either: (a) whitelisting this IP with Gainwell's MFT admin, or (b) staying in manual mode and moving files via the operator's SFTP client.
Inbound ingestion paths
The canonical way to write 999/TA1/277CA/835 rows into the DB is Scheduler.process_inbound_files, exposed as the CLI cyclone pull-inbound --date YYYYMMDD and the HTTP POST /api/admin/scheduler/pull-inbound. There is no parse-999 / parse-ta1 / parse-277ca CLI command (CLAUDE.md's "CLI" section above is aspirational on those three). The parse-837 and parse-835 CLIs exist but only emit JSON files to --output-dir; they do NOT write to the DB. For DB writes, use pull-inbound (which dedupes via processed_inbound_files).
Daemon hot-reload
python -m cyclone serve runs as root (started by tini) and keeps the SFTP block in memory. Flipping stub directly in the DB (store.update_clearhouse(...)) does NOT auto-reload the daemon's view — either restart the daemon or use PATCH /api/clearhouse (which calls scheduler.reconfigure_scheduler to hot-reload).
Frontend at a glance
src/ is React 18 + TypeScript + Vite. Routes register in src/App.tsx (12 pages — Dashboard, Claims, Remittances, Providers, ActivityLog, Upload, Reconciliation, Inbox, Acks, Batches, BatchDiff, Login — all under a <Layout> route wrapper, with Login outside the auth gate). Pages are pure renderers — every page pairs with a use<X> data hook in src/hooks/ and renders a <PageHeader> + a table/list/KPI grid. Drawers (ClaimDrawer/, RemitDrawer/, ProviderDrawer/, AckDrawer/) are mounted by the page and their open/close state is mirrored to the URL via useDrawerUrlState so deep-links round-trip. Drill-stack navigation is provided by <DrillStackProvider> in src/components/drill/.
State split: server state in TanStack Query (@tanstack/react-query); ephemeral client state in Zustand (useTailStore for live-tail append, plus the drill stack). The live-tail store is FIFO-capped at TAIL_CAP = 10_000 per slice (src/store/tail-store.ts:28); claims and remittances are key-by-id with first-write-wins dedup, activity is an append-only array.
UI primitives in src/components/ui/ are Radix-backed (button, dialog, table, select, pagination, empty-state, error-state, filter-chips, skeleton, input, label, card, badge, skip-link, claim-state-badge). Don't import a new UI library without discussion.
Path alias @/ → src/. Configured in vite.config.ts, vitest.config.ts, and tsconfig.app.json.
CLI
# Parser
python -m cyclone.cli parse-837 path/to/837p.txt --output-dir ./claims --payer co_medicaid [--strict] [--include-raw-segments]
python -m cyclone.cli parse-835 path/to/835.txt --output-dir ./remits
# Validators
python -m cyclone.cli validate-npi 1234567893
python -m cyclone.cli validate-tin 721587149
# Other
python -m cyclone serve # uvicorn
python -m cyclone backup list
python -m cyclone backup create --reason manual
Exit codes are documented per subcommand in cyclone-cli — 0 for success, 2 for file-level failure, 1 for unexpected exceptions.
Things that are easy to get wrong
VITE_API_BASE_URLmatters. With it empty, everyapimethod throwsnotConfiguredError()and the UI falls back to the in-memory zustand store — parses are disabled and the live-tail streams never open.- Prodfiles vs fixtures. Tests must reference
backend/tests/fixtures/<name>.txt, notdocs/prodfiles/<source>/<file>.txt. The prodfiles dir is the source-of-truth archive; the fixtures dir is the stable test surface. - SP-N merge shape. No squash, no rebase. The merge commit is the audit trail. Squash collapses the per-commit history and breaks the SP-N audit trail.
- Don't put domain logic in JSX. Conditional renderings, table sorting, and KPI math all belong in the
use<X>hook or a pure helper undersrc/lib/. - Don't open a drawer via local
useState. UseuseDrawerUrlState()so the URL is the single source of truth — deep-links and reload-restore depend on it. - Don't call
useTailStreamfrom inside ause<X>hook. The subscription lives on the page so the lifecycle ties to whoever mounts the hook, not to whoever happens to call it. - The store facade. The public API of
cyclone.storeis preserved through SP21's split — call through the facade, not directly into the underlying modules. - Encryption is optional, not required. When the Keychain entry is missing or
sqlcipher3is not installed, the DB falls back to plain SQLite. Don't fail boot on missing encryption. - Always bind to 0.0.0.0. The bind address does not control reachability — the host firewall / compose port publishing does. Requires login (bcrypt + HttpOnly session cookie; see SP24 spec), and the threat model is still a stolen/imaged drive — SQLCipher at rest and the macOS Keychain handle that. The auth boundary is the HTTP layer; the file-system posture is unchanged. Don't expose the published ports to the public internet (LAN-only or VPN-fronted). Don't disable auth without an explicit
CYCLONE_AUTH_DISABLED=1env var (the escape hatch logs a WARNING at boot).