Files
cyclone/CLAUDE.md
Tyler 00f11f84b6 feat(sp24): align CLAUDE.md + REQUIREMENTS.md + ARCHITECTURE.md with auth work + AUTH_DISABLED WARNING
Replaces every stale 'no auth' / 'no authentication' / 'no second party to
authenticate' claim in the three top-level docs with the v1 posture: the
auth boundary is HTTP (bcrypt + HttpOnly session cookie; first admin
bootstrapped from CYCLONE_ADMIN_USERNAME + CYCLONE_ADMIN_PASSWORD env
vars); the file-system posture (SQLCipher at rest, macOS Keychain) is
unchanged; the threat model is still a stolen/imaged drive.

Closes requirements §11 R-1 (was Open; now Closed by SP24 — auth shipped
via the origin/main merge on 2026-06-23, SP24 reconciled the docs).

Also:
- backend/src/cyclone/__main__.py — emit WARNING when AUTH_DISABLED is
  True at boot so a misconfigured production deploy fails loudly
- §6.1 migrations table in ARCHITECTURE.md — list 0013 (auth_users_and_
  sessions) and 0014 (audit_log_user_id) with the renumbering note
- §4.2 module map — add the cyclone.auth.* package
- Three skills addenda: cyclone-tests (AUTH_DISABLED conftest bypass is
  mandatory context), cyclone-api-router (every router needs
  Depends(matrix_gate)), cyclone-spec (spec template threat-model is now
  auth-aware)

This also tracks CLAUDE.md in git for the first time (was previously
untracked; the SP24 doc updates are in scope for the increment).

Spec: docs/superpowers/specs/2026-06-23-cyclone-auth-posture-alignment-design.md
Plan: docs/superpowers/plans/2026-06-23-cyclone-auth-posture-alignment.md
2026-06-23 14:49:31 -06:00

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. Local-only by design: binds to 127.0.0.1, 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), no internet exposure.

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 127.0.0.1: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), 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-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.
  • Local-only by design. The backend binds to 127.0.0.1, 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 add internet exposure. Don't disable auth without an explicit CYCLONE_AUTH_DISABLED=1 env var (the escape hatch logs a WARNING at boot).