Reachability is controlled by the host firewall / compose port publishing, not the bind address. Backend, compose, and docs all now default to 0.0.0.0 so the API is reachable from the frontend container, the host, and the LAN without per-env overrides. Files: - backend/src/cyclone/__main__.py: default host = 0.0.0.0 - docker-compose.yml: refresh the comment to match the new posture - CLAUDE.md / README.md / docs/ARCHITECTURE.md / docs/REQUIREMENTS.md: reframe the bind note accordingly
15 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. As of the last backfill, SP numbers are used through SP22; SP23 is reserved for the Ubuntu + Docker + RBAC product fork (docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md, awaiting user decision); the next free increment is SP24. 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) |
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. The two largest files are api.py (~3,548 LOC, the only large file) and store.py (~2,423 LOC, the CycloneStore facade — SP21 is in flight to split it into a cyclone/store/ subpackage; the public API stays unchanged). Subpackages: api_routers/ (acks, admin, health, ta1_acks), clearhouse/ (Clearhouse + SftpClient), edi/ (filenames), parsers/ (X12 transaction parsers + models + validators + serializers), workflow/ (placeholder for future sub-project 6).
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; 12 SQL migrations under migrations/ (0001_initial through 0012_backups) 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.
Frontend at a glance
src/ is React 18 + TypeScript + Vite. Routes register in src/App.tsx (11 pages, all under a <Layout> route wrapper). 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/, plus the new ProviderDrawer/ and 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
python -m cyclone.cli parse-999 inbound_999.txt
python -m cyclone.cli parse-ta1 inbound_ta1.txt
python -m cyclone.cli parse-277ca inbound_277ca.txt
# 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).