Files
cyclone/CLAUDE.md
T
Nora 7cb0278be0 docs(sp37): document canonical submit-batch flow
RUNBOOK gets a 'Submitting claims (canonical)' section with both CLI
and HTTP examples, the shared cyclone.submission.submit_file helper,
and a 'submit-batch vs resubmit-rejected-claims' decision rule.
CLAUDE.md gets a pointer to cyclone/submission/ in the 'Backend at a
glance' subpackage list.
2026-07-07 12:02:33 -06:00

18 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 via fastapi.testclient.TestClient) and test_<module>_<behavior>.py (pure-unit). Autouse conftest.py points CYCLONE_DB_URL at tmp_path/test.db, calls db._reset_for_tests() + db.init_db(), and wires a fresh EventBus onto app.state.
  • Prodfiles (real EDI samples under docs/prodfiles/<source>/) are never read directly from a test — copy to backend/tests/fixtures/<descriptive-name>.txt first and reference as a module-level Path constant. The fixtures/ dir is flat (no per-test subdirs).
  • Frontend tests are siblings: useFoo.tsuseFoo.test.ts, ClaimDrawer.tsxClaimDrawer.test.tsx. Setup is // @vitest-environment happy-dom plus (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;. Mock the API at the module boundary with vi.mock("@/lib/api", ...); stub fetch with vi.stubGlobal("fetch", vi.fn().mockResolvedValue(...)). vitest.config.ts sets VITE_API_BASE_URL=http://test.local so the api module doesn't throw notConfiguredError before the mock fires.
  • Time-sensitive tests: frontend uses vi.useFakeTimers() + vi.setSystemTime(...) + vi.advanceTimersByTime(ms). Backend passes explicit datetime(...) values. Don't add await 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, header Status: Draft, awaiting user sign-off, sections Scope / Decisions / …. Specs contain zero code blocks.
  • Plan path: docs/superpowers/plans/YYYY-MM-DD-cyclone-<topic>.md, header per superpowers:writing-plans with Goal / Architecture / Tech Stack / Spec metadata + 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), 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), 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.

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 (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-cli0 for success, 2 for file-level failure, 1 for unexpected exceptions.

Things that are easy to get wrong

  • VITE_API_BASE_URL matters. With it empty, every api method throws notConfiguredError() 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, not docs/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 under src/lib/.
  • Don't open a drawer via local useState. Use useDrawerUrlState() so the URL is the single source of truth — deep-links and reload-restore depend on it.
  • Don't call useTailStream from inside a use<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.store is 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 sqlcipher3 is 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=1 env var (the escape hatch logs a WARNING at boot).