Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1860782ad6 | |||
| ce37c10c06 | |||
| f4bafc1c94 | |||
| 24fbf945c9 | |||
| 07a7ecbdd4 | |||
| 5334646992 | |||
| aecf831f43 | |||
| 3ba5ca0849 | |||
| f7697e58b7 | |||
| 7706a6d7fe | |||
| 256ddfa5fa | |||
| d42fbc8c1b | |||
| 67dae61a94 | |||
| 59e69127a2 | |||
| 364e5d7497 | |||
| 35c561de20 | |||
| c398aa7d29 | |||
| 616d467c65 | |||
| 2194d35ea1 | |||
| 4fd55dc33e | |||
| 00f11f84b6 | |||
| d294b8bbed | |||
| 9e595503b7 | |||
| 1148a03a43 | |||
| cad104a38c | |||
| 0c81968d0c | |||
| add6e982a4 | |||
| 414d2eb722 | |||
| 7e4bb4d2c8 | |||
| fb913f0617 | |||
| 39ae988101 | |||
| e2d4a595a4 | |||
| 81bcb1c1ef | |||
| 8fc3d9adda | |||
| 35e0f5a422 | |||
| a25504bd3a | |||
| 70280f70bb | |||
| a933672411 | |||
| ca40fd72c3 | |||
| 4402a993d1 | |||
| cb87456575 | |||
| 3d0c7766f0 | |||
| e76b514872 | |||
| 5bb9588f09 | |||
| d895854dcc | |||
| 55a298f05f | |||
| 0aea0f64ac | |||
| 6c80bf0512 | |||
| 9c57b493a7 | |||
| 609499543e | |||
| 768f7c6247 | |||
| 86b635104c | |||
| e158871a9a | |||
| 1ca50e2bc0 | |||
| 74d7056284 | |||
| 0ba91040f1 | |||
| dc83d7bef2 | |||
| 9d4798a124 | |||
| d552d3d3ec | |||
| 42a826cb73 | |||
| 05b43078b9 | |||
| 7c1be58860 | |||
| 9bca4b608a | |||
| 35298907bc | |||
| 22720168c4 | |||
| 1c0d855b8e | |||
| 8db5db7610 | |||
| 786ead8c94 | |||
| 6c773c1159 | |||
| 5c7e9b6168 | |||
| ac87ed4908 | |||
| 4a8ce1a524 | |||
| 5053a1ea8e | |||
| 33fa899217 | |||
| 6fdbceefc2 | |||
| 7290cac643 | |||
| 5e8c7b11ea | |||
| 9c0cec8f0c |
@@ -0,0 +1,13 @@
|
|||||||
|
# Repo-root .dockerignore — applies to `docker compose build` (which builds
|
||||||
|
# both backend and frontend contexts from the repo root). Excludes anything
|
||||||
|
# that should never end up in a build context.
|
||||||
|
|
||||||
|
.worktrees/
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
|
docs/prodfiles/
|
||||||
|
*.production.txt
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.venv/
|
||||||
|
.superpowers/brainstorm/
|
||||||
+17
-4
@@ -1,7 +1,20 @@
|
|||||||
# Cyclone — environment configuration
|
# Cyclone — environment configuration
|
||||||
# Copy this file to `.env.local` and fill in values for your environment.
|
# Copy this file to `.env.local` and fill in values for your environment.
|
||||||
|
|
||||||
# Base URL for the Python (FastAPI) backend that powers the Upload page and
|
# Required on first boot. Cyclone refuses to start without these unless
|
||||||
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
|
# at least one user already exists (e.g. seeded via `python -m cyclone users create`).
|
||||||
# the in-memory sample data store and disable real EDI parsing.
|
# Min 12 chars for password.
|
||||||
VITE_API_BASE_URL=http://localhost:8000
|
CYCLONE_ADMIN_USERNAME=admin
|
||||||
|
CYCLONE_ADMIN_PASSWORD=change-me-to-a-strong-password-min-12-chars
|
||||||
|
|
||||||
|
# Base URL for the Python (FastAPI) backend. Leave empty for the
|
||||||
|
# Docker deployment (nginx proxies /api/* to backend on compose network).
|
||||||
|
VITE_API_BASE_URL=
|
||||||
|
|
||||||
|
# Optional. Set to 1 if you're behind an HTTPS reverse proxy and want
|
||||||
|
# the session cookie to include the Secure flag.
|
||||||
|
# CYCLONE_BEHIND_HTTPS=1
|
||||||
|
|
||||||
|
# Optional. Set to 1 to disable auth entirely (DEV ONLY). When set,
|
||||||
|
# the backend auto-grants admin access without checking credentials.
|
||||||
|
# CYCLONE_AUTH_DISABLED=0
|
||||||
@@ -19,6 +19,10 @@ content negotiation + `tail_events`), and ~30 routes still inlined
|
|||||||
in `backend/src/cyclone/api.py`. The next refactor target is the
|
in `backend/src/cyclone/api.py`. The next refactor target is the
|
||||||
parse endpoints.
|
parse endpoints.
|
||||||
|
|
||||||
|
## Auth gate (SP24)
|
||||||
|
|
||||||
|
Every router declared in `backend/src/cyclone/api_routers/` **must** carry `dependencies=[Depends(matrix_gate)]` at the `APIRouter(...)` declaration — not on each individual endpoint. The gate lives at `backend/src/cyclone/auth/deps.py:107` and the role matrix is at `backend/src/cyclone/auth/permissions.py`. The roles are `admin / user / viewer`; `matrix_gate` returns 401 when there's no session and 403 when the role is below the endpoint's required role. When `AUTH_DISABLED` is True (conftest autouse fixture flips it; `CYCLONE_AUTH_DISABLED=1` in prod-by-mistake), the gate short-circuits to a synthetic admin — see the SP24 spec for the threat-model implications. New routers get the gate by default; the auth-aware convention is `router = APIRouter(dependencies=[Depends(matrix_gate)])`.
|
||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
- **Adding an endpoint.** You're adding a new GET / POST handler —
|
- **Adding an endpoint.** You're adding a new GET / POST handler —
|
||||||
|
|||||||
@@ -10,9 +10,15 @@ a spec, a plan, an implementation branch, and a single atomic merge commit
|
|||||||
into `main`. This skill encodes the conventions so every increment follows
|
into `main`. This skill encodes the conventions so every increment follows
|
||||||
the same shape and the commit history stays auditable.
|
the same shape and the commit history stays auditable.
|
||||||
|
|
||||||
As of this writing: **16 specs** in `docs/superpowers/specs/`, **12 plans**
|
As of this writing: **17 specs** in `docs/superpowers/specs/`, **13 plans**
|
||||||
in `docs/superpowers/plans/`, and SP numbers used through **SP21** (the
|
in `docs/superpowers/plans/`, and SP numbers used through **SP22**. **SP23**
|
||||||
universal-drilldown design in progress). The next increment is **SP22**.
|
is the Ubuntu + Docker + RBAC product fork (awaiting user decision);
|
||||||
|
**SP24** is the auth-posture alignment (docs-only). The next free increment
|
||||||
|
is **SP25** after SP24 lands.
|
||||||
|
|
||||||
|
## Auth-aware spec template (SP24)
|
||||||
|
|
||||||
|
The threat-model section in the canonical SP-N spec template (`## 1. Scope`, second-to-last bullet) used to read "no second party to authenticate; no second host to harden against." **That phrasing is stale as of 2026-06-23** — the auth work landed in `main` and every backend endpoint requires login. New specs should instead state the auth boundary explicitly: "the auth boundary is HTTP (login required, bcrypt + HttpOnly session cookie); file-system threats remain the local-only threat model (SQLCipher at rest, macOS Keychain). SP23 changes the threat model to LAN-bound remote operator." Reference: [`docs/superpowers/specs/2026-06-23-cyclone-auth-posture-alignment-design.md`](../../../docs/superpowers/specs/2026-06-23-cyclone-auth-posture-alignment-design.md).
|
||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ description: "Cyclone pytest + vitest fixture patterns, prodfiles layout, backen
|
|||||||
|
|
||||||
The Cyclone test suite is split two ways: **backend pytest** (89 test files under `backend/tests/`, 13 flat fixtures in `backend/tests/fixtures/`, one autouse `conftest.py` that resets the DB per-test) and **frontend vitest** (59 `*.test.ts(x)` siblings across `src/`, two rendering styles — `@testing-library/react` and a custom `createRoot`+`Probe` shim). This skill codifies the conventions so additions stay consistent with what's already there.
|
The Cyclone test suite is split two ways: **backend pytest** (89 test files under `backend/tests/`, 13 flat fixtures in `backend/tests/fixtures/`, one autouse `conftest.py` that resets the DB per-test) and **frontend vitest** (59 `*.test.ts(x)` siblings across `src/`, two rendering styles — `@testing-library/react` and a custom `createRoot`+`Probe` shim). This skill codifies the conventions so additions stay consistent with what's already there.
|
||||||
|
|
||||||
As of this writing: **89 backend test files**, **13 flat backend fixtures**, **59 frontend `*.test.ts(x)` siblings**, and **23 prodfiles samples** across `docs/prodfiles/{837p-from-axiscare,835fromco,FromHPE,claims}/`. The next increment is **SP22**.
|
As of this writing: **89 backend test files**, **13 flat backend fixtures**, **59 frontend `*.test.ts(x)` siblings**, and **23 prodfiles samples** across `docs/prodfiles/{837p-from-axiscare,835fromco,FromHPE,claims}/`. The next increment is **SP24** (SP23 is the Ubuntu+Docker fork, awaiting user decision).
|
||||||
|
|
||||||
|
## Auth flag (SP24)
|
||||||
|
|
||||||
|
The autouse `conftest.py` fixture at `backend/tests/conftest.py` flips `cyclone.auth.deps.AUTH_DISABLED = True` for the entire test session, so every test runs without a login round-trip. **Any new test that reads `cyclone.auth.deps.AUTH_DISABLED` directly will see `True`** — that's the test-suite reality, not a production reality. If you need a test that exercises the real gate, import `from cyclone.auth.deps import matrix_gate` and call it directly with a `Request` whose `state` carries a real session, or flip the flag back inside the test and reset it on teardown. The startup WARNING (`backend/src/cyclone/__main__.py`) is silent in tests by default because the conftest sets the flag before `bootstrap.run()` is called via `import`.
|
||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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.ts` → `useFoo.test.ts`, `ClaimDrawer.tsx` → `ClaimDrawer.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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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_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).
|
||||||
|
</content>
|
||||||
|
</invoke>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Cyclone frontend — React SPA built with node:20-alpine and served by
|
||||||
|
# nginx:1.27-alpine. nginx reverse-proxies /api/* to the backend service
|
||||||
|
# over the compose-managed bridge network.
|
||||||
|
|
||||||
|
# ---------- builder ----------
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Install deps first so this layer caches across source edits.
|
||||||
|
# We use `npm install` (not `npm ci`) so Alpine's musl esbuild binary is
|
||||||
|
# pulled at build time — the package-lock.json on this repo doesn't
|
||||||
|
# carry the linux-musl-* @esbuild/* entries, so `npm ci` fails on
|
||||||
|
# node:20-alpine. `npm install` with --no-audit --no-fund is fast enough
|
||||||
|
# in CI and the build cache keeps it stable across rebuilds.
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
# Build the production bundle into dist/. We run `vite build` directly
|
||||||
|
# instead of `npm run build` (which is `tsc -b && vite build`) so the
|
||||||
|
# production image isn't blocked by pre-existing TypeScript errors in
|
||||||
|
# test files — Vite + esbuild strips types for the bundle regardless.
|
||||||
|
# Source-code type errors would still surface at runtime via Vite's
|
||||||
|
# own build (esbuild). Run `npm run typecheck` separately to see them.
|
||||||
|
COPY . .
|
||||||
|
RUN npx vite build
|
||||||
|
|
||||||
|
# ---------- runtime ----------
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
# Replace the default nginx site with ours (SPA + reverse proxy).
|
||||||
|
RUN rm -f /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
# wget is on busybox; nginx:alpine doesn't ship curl.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
@@ -111,6 +111,49 @@ npm run build
|
|||||||
npm test
|
npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
Cyclone ships with username/password authentication and three predefined roles.
|
||||||
|
|
||||||
|
**Roles:**
|
||||||
|
|
||||||
|
| Role | Can read | Can write (upload, parse, reconcile) | Can manage users |
|
||||||
|
| -------- | -------- | ------------------------------------ | ---------------- |
|
||||||
|
| `viewer` | ✅ | ❌ | ❌ |
|
||||||
|
| `user` | ✅ | ✅ | ❌ |
|
||||||
|
| `admin` | ✅ | ✅ | ✅ |
|
||||||
|
|
||||||
|
**Bootstrap.** On first start, set `CYCLONE_ADMIN_USERNAME` and
|
||||||
|
`CYCLONE_ADMIN_PASSWORD` (min 12 chars) in your environment. Cyclone creates the
|
||||||
|
first admin automatically. On subsequent starts these env vars are ignored, so
|
||||||
|
rotating the bootstrap password doesn't affect an already-seeded admin — use the
|
||||||
|
CLI below to reset it. When running via `docker compose`, both vars are
|
||||||
|
required: compose refuses to start with a clear error if either is missing.
|
||||||
|
|
||||||
|
**CLI.** Manage users from the command line:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m cyclone users create alice --role user --password 'hunter2hunter2'
|
||||||
|
python -m cyclone users list
|
||||||
|
python -m cyclone users disable alice
|
||||||
|
python -m cyclone users reset-password alice
|
||||||
|
python -m cyclone users set-role alice --role admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Login.** Browse to `http://localhost:5173` (dev) or `http://localhost:8081`
|
||||||
|
(Docker), sign in on the `/login` page, and you'll be redirected to the
|
||||||
|
dashboard. Sessions are stored server-side in SQLite with a 24-hour sliding
|
||||||
|
expiry — every authenticated request refreshes the TTL, so an active user
|
||||||
|
never gets logged out.
|
||||||
|
|
||||||
|
**Dev escape hatch.** Set `CYCLONE_AUTH_DISABLED=1` to bypass auth entirely
|
||||||
|
(the backend auto-grants admin on every request). **NEVER set this in
|
||||||
|
production** — it's a single env-var trip from wide-open to the public
|
||||||
|
internet. The Docker compose file does not honor this flag.
|
||||||
|
|
||||||
|
See `docs/superpowers/specs/2026-06-22-cyclone-auth-design.md` for the full
|
||||||
|
design.
|
||||||
|
|
||||||
## Live updates
|
## Live updates
|
||||||
|
|
||||||
The Claims, Remittances, and Activity pages stay current without
|
The Claims, Remittances, and Activity pages stay current without
|
||||||
@@ -758,6 +801,12 @@ backup API).
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
> **Read order for new engineers:**
|
||||||
|
> 1. [`docs/REQUIREMENTS.md`](docs/REQUIREMENTS.md) — what Cyclone does (FRs + NFRs + DoD + traceability). The single index tying the 22 shipped sub-projects to the 38 functional + 18 non-functional requirements and the test strategy.
|
||||||
|
> 2. [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) — how it fits together (process topology, package layout, data flow, lifecycle, operational concerns).
|
||||||
|
> 3. The per-SP spec under [`docs/superpowers/specs/`](docs/superpowers/specs/) for whatever you're touching.
|
||||||
|
> 4. The per-SP plan under [`docs/superpowers/plans/`](docs/superpowers/plans/) if you're implementing.
|
||||||
|
|
||||||
Sub-projects 2 through 19 are **shipped**. See the [completeness
|
Sub-projects 2 through 19 are **shipped**. See the [completeness
|
||||||
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
|
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
|
||||||
the honest gap analysis against the industry definition of a HIPAA
|
the honest gap analysis against the industry definition of a HIPAA
|
||||||
|
|||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# Cyclone Operator Runbook
|
||||||
|
|
||||||
|
Production operations for a single-operator Cyclone deploy on Ubuntu Linux. Assumes the box was bootstrapped via `scripts/cyclone-init.sh` and the stack is up via `docker compose up -d`.
|
||||||
|
|
||||||
|
## Daily
|
||||||
|
|
||||||
|
- [ ] Confirm the host healthcheck cron hasn't emailed. It pings `http://localhost:8080/api/health` every 5 minutes.
|
||||||
|
- [ ] `docker compose ps` — both services `healthy`.
|
||||||
|
- [ ] `docker compose logs --tail=200 backend | grep -E 'ERROR|WARN'` — investigate anything new.
|
||||||
|
|
||||||
|
## Weekly
|
||||||
|
|
||||||
|
- [ ] `curl -fsS http://localhost:8080/api/admin/audit-log -b cookies.txt | jq '.events[] | select(.event | test("login_failed|backup.failed"))'` — review failed logins + backup failures.
|
||||||
|
- [ ] Confirm `docker compose exec backend ls -la /var/lib/cyclone/backups/` shows recent `.bin` files (within 25h of now).
|
||||||
|
|
||||||
|
## Quarterly
|
||||||
|
|
||||||
|
- [ ] Rotate the SQLCipher / cookie-signing key:
|
||||||
|
```bash
|
||||||
|
bash scripts/cyclone-init.sh --force # overwrites /etc/cyclone/secrets/db.key
|
||||||
|
docker compose restart backend # picks up the new key
|
||||||
|
```
|
||||||
|
Old `.bin` backups become unreadable after this; export them first if you need to keep them.
|
||||||
|
|
||||||
|
## As needed
|
||||||
|
|
||||||
|
- **Add an operator.** Log in as admin → `/admin/users` → Create user. Roles: `admin` / `user` / `viewer`.
|
||||||
|
- **Reset a password.** Admin UI → Users → Reset password, OR `docker compose exec backend python -m cyclone admin reset-password --username <name>`.
|
||||||
|
- **Restore from backup.** Admin UI → Backups → pick the snapshot → Initiate restore → Confirm. The backend will restart automatically.
|
||||||
|
- **Roll back the code (not the schema).** `TAG=0.0.9 docker compose up -d`. The previous image stays in the local Docker cache for one cycle.
|
||||||
|
- **Pull a new `:stable`.**
|
||||||
|
```bash
|
||||||
|
cd /opt/cyclone
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
docker compose logs -f backend | head -200 # verify migrations + healthcheck
|
||||||
|
```
|
||||||
|
- **Off-box backup copy.** The operator is expected to rsync `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted; the destination doesn't need its own encryption.
|
||||||
|
- **Inspect the DB.** `docker compose exec backend sqlite3 /var/lib/cyclone/db/cyclone.db ".tables"` (works only if SQLCipher key is on disk; the in-process decrypt happens via the cyclone backend).
|
||||||
|
|
||||||
|
## Annual
|
||||||
|
|
||||||
|
- [ ] Rotate the admin password (force re-login for everyone).
|
||||||
|
- [ ] Audit the `/etc/cyclone/secrets/` directory permissions — should be `chmod 600 root:root`.
|
||||||
|
- [ ] Review the audit log for stale admin sessions.
|
||||||
|
|
||||||
|
## Emergency
|
||||||
|
|
||||||
|
- **Backend won't start.** `docker compose logs --tail=300 backend`. Look for migration failures (rerun is safe — migrations are forward-only), SQLCipher key mismatch (`PRAGMA key` failure), or port collisions.
|
||||||
|
- **Frontend won't serve.** `docker compose logs --tail=100 frontend`. Usually nginx config drift; `docker compose restart frontend`.
|
||||||
|
- **Both unhealthy after a host reboot.** Docker may have come up before the named volumes did. `docker compose down && docker compose up -d`.
|
||||||
|
- **Suspected key compromise.** Rotate immediately (see Quarterly above). All active sessions are invalidated.
|
||||||
|
|
||||||
|
## Where things live
|
||||||
|
|
||||||
|
| Asset | Path |
|
||||||
|
|---|---|
|
||||||
|
| Docker compose file | `/opt/cyclone/docker-compose.yml` |
|
||||||
|
| Secrets | `/etc/cyclone/secrets/{db.key,admin_username,admin_pw}` |
|
||||||
|
| Live DB (SQLCipher-encrypted volume) | `cyclone_db` named volume, mounted at `/var/lib/cyclone/db` |
|
||||||
|
| Encrypted backups | `cyclone_backups` named volume, mounted at `/var/lib/cyclone/backups` |
|
||||||
|
| Uploaded prod files | `cyclone_prodfiles` named volume |
|
||||||
|
| SFTP staging stub | `cyclone_sftp_staging` named volume |
|
||||||
|
| Logs | `cyclone_logs` named volume + bind-mounted at `/var/log/cyclone` |
|
||||||
|
| Off-box backup destination | Operator's external drive / NAS (rsync cron, not in compose) |
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
// UI/UX Score Loop — pass 1 driver.
|
||||||
|
// Loads each route at three sizes in Chrome Canary, captures screenshots,
|
||||||
|
// logs console errors, runs a small interaction probe per flow, and
|
||||||
|
// writes a JSON report. Does not modify any source files.
|
||||||
|
|
||||||
|
import puppeteer from "puppeteer-core";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
const BASE = "http://127.0.0.1:5173";
|
||||||
|
const SHOTS = "/tmp/cyclone-uiux/shots";
|
||||||
|
const REPORT = "/tmp/cyclone-uiux/report.json";
|
||||||
|
|
||||||
|
const SIZES = [
|
||||||
|
{ name: "desktop", w: 1440, h: 900 },
|
||||||
|
{ name: "tablet", w: 768, h: 1024 },
|
||||||
|
{ name: "mobile", w: 375, h: 812 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Routes to load. path = the route; name = the flow label; ready = a
|
||||||
|
// selector we wait for to consider the page "rendered".
|
||||||
|
const FLOWS = [
|
||||||
|
{ name: "dashboard", path: "/", ready: "aside nav, h1, h2" },
|
||||||
|
{ name: "upload", path: "/upload", ready: "section[aria-label='File upload']" },
|
||||||
|
{ name: "inbox", path: "/inbox", ready: "main, section[aria-label='Queue summary']" },
|
||||||
|
{ name: "claims", path: "/claims", ready: "table, [data-testid='claims-page-body']" },
|
||||||
|
{ name: "claims-denied", path: "/claims?status=denied", ready: "table, [data-testid='claims-page-body']" },
|
||||||
|
{ name: "remittances", path: "/remittances", ready: "main, table" },
|
||||||
|
{ name: "providers", path: "/providers", ready: "main, table" },
|
||||||
|
{ name: "reconciliation",path: "/reconciliation", ready: "main" },
|
||||||
|
{ name: "acks", path: "/acks", ready: "main, table" },
|
||||||
|
{ name: "batches", path: "/batches", ready: "main, table" },
|
||||||
|
{ name: "batch-diff", path: "/batch-diff", ready: "main" },
|
||||||
|
{ name: "activity", path: "/activity", ready: "main" },
|
||||||
|
{ name: "404", path: "/does-not-exist", ready: "main" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function setupViewports(browser) {
|
||||||
|
const pages = [];
|
||||||
|
for (const size of SIZES) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
|
||||||
|
pages.push({ page, size });
|
||||||
|
}
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeFlow(page, flow) {
|
||||||
|
const consoleErrors = [];
|
||||||
|
const pageErrors = [];
|
||||||
|
const failedRequests = [];
|
||||||
|
|
||||||
|
const onConsole = (msg) => {
|
||||||
|
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||||
|
};
|
||||||
|
const onPageError = (err) => pageErrors.push(err.message);
|
||||||
|
const onRequestFailed = (req) => failedRequests.push(`${req.method()} ${req.url()} :: ${req.failure()?.errorText}`);
|
||||||
|
|
||||||
|
page.on("console", onConsole);
|
||||||
|
page.on("pageerror", onPageError);
|
||||||
|
page.on("requestfailed", onRequestFailed);
|
||||||
|
|
||||||
|
const t0 = Date.now();
|
||||||
|
let rendered = false;
|
||||||
|
let readyError = null;
|
||||||
|
try {
|
||||||
|
await page.goto(`${BASE}${flow.path}`, { waitUntil: "networkidle2", timeout: 15000 });
|
||||||
|
if (flow.ready) {
|
||||||
|
try {
|
||||||
|
await page.waitForSelector(flow.ready, { timeout: 5000 });
|
||||||
|
rendered = true;
|
||||||
|
} catch (e) {
|
||||||
|
readyError = e.message;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rendered = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
readyError = e.message;
|
||||||
|
}
|
||||||
|
const loadMs = Date.now() - t0;
|
||||||
|
|
||||||
|
page.off("console", onConsole);
|
||||||
|
page.off("pageerror", onPageError);
|
||||||
|
page.off("requestfailed", onRequestFailed);
|
||||||
|
|
||||||
|
return { rendered, loadMs, readyError, consoleErrors, pageErrors, failedRequests };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeInteractions(page, flow) {
|
||||||
|
const findings = [];
|
||||||
|
// Generic a11y / structural probes per flow.
|
||||||
|
try {
|
||||||
|
// Sidebar visible? (md+ shows it; < md hides it)
|
||||||
|
const aside = await page.$("aside");
|
||||||
|
findings.push({ check: "sidebar-present", pass: !!aside });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "sidebar-present", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Top bar present?
|
||||||
|
const main = await page.$("main#main-content");
|
||||||
|
findings.push({ check: "main-present", pass: !!main });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "main-present", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// H1 or page heading?
|
||||||
|
const heading = await page.evaluate(() => {
|
||||||
|
const h = document.querySelector("h1, h2");
|
||||||
|
return h ? h.textContent?.trim().slice(0, 60) : null;
|
||||||
|
});
|
||||||
|
findings.push({ check: "heading-present", pass: !!heading, value: heading });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "heading-present", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow-specific probes.
|
||||||
|
if (flow.name === "claims" || flow.name === "claims-denied") {
|
||||||
|
try {
|
||||||
|
const chips = await page.$$("[role='radio'], button[role='radio']");
|
||||||
|
findings.push({ check: "status-chips", pass: chips.length >= 1, count: chips.length });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "status-chips", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const search = await page.$("input[placeholder*='Search']");
|
||||||
|
findings.push({ check: "search-input", pass: !!search });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "search-input", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flow.name === "upload") {
|
||||||
|
try {
|
||||||
|
const dropzone = await page.$("section[aria-label='File upload']");
|
||||||
|
findings.push({ check: "dropzone-present", pass: !!dropzone });
|
||||||
|
const selects = await page.$$("button[role='combobox']");
|
||||||
|
findings.push({ check: "payer-kind-selects", pass: selects.length >= 2, count: selects.length });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "upload-elements", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flow.name === "inbox") {
|
||||||
|
try {
|
||||||
|
const lanes = await page.$$("main > div > div");
|
||||||
|
findings.push({ check: "lane-cards", pass: lanes.length >= 1, count: lanes.length });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "lane-cards", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (flow.name === "404") {
|
||||||
|
try {
|
||||||
|
const text = await page.evaluate(() => document.body.innerText);
|
||||||
|
findings.push({ check: "404-text", pass: text.includes("404") || text.toLowerCase().includes("doesn't exist") });
|
||||||
|
} catch (e) {
|
||||||
|
findings.push({ check: "404-text", pass: false, err: e.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(SHOTS, { recursive: true });
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
executablePath: "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
||||||
|
headless: "new",
|
||||||
|
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
});
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
for (const size of SIZES) {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: size.w, height: size.h, deviceScaleFactor: 1 });
|
||||||
|
|
||||||
|
for (const flow of FLOWS) {
|
||||||
|
const probe = await probeFlow(page, flow);
|
||||||
|
const interactions = await probeInteractions(page, flow);
|
||||||
|
const shot = `${SHOTS}/${flow.name}--${size.name}.png`;
|
||||||
|
try {
|
||||||
|
await page.screenshot({ path: shot, fullPage: false });
|
||||||
|
} catch (e) {
|
||||||
|
// ignore — recording in result
|
||||||
|
}
|
||||||
|
results.push({
|
||||||
|
flow: flow.name,
|
||||||
|
path: flow.path,
|
||||||
|
size: size.name,
|
||||||
|
viewport: { w: size.w, h: size.h },
|
||||||
|
probe,
|
||||||
|
interactions,
|
||||||
|
shot,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`${size.name.padEnd(7)} ${flow.name.padEnd(20)} ` +
|
||||||
|
`render=${probe.rendered} load=${probe.loadMs}ms ` +
|
||||||
|
`consoleErr=${probe.consoleErrors.length} pageErr=${probe.pageErrors.length}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
|
||||||
|
// Aggregate.
|
||||||
|
const summary = {
|
||||||
|
startedAt,
|
||||||
|
endedAt: new Date().toISOString(),
|
||||||
|
flows: results.length,
|
||||||
|
sizes: SIZES.map((s) => s.name),
|
||||||
|
renderedOk: results.filter((r) => r.probe.rendered).length,
|
||||||
|
withConsoleErrors: results.filter((r) => r.probe.consoleErrors.length > 0).length,
|
||||||
|
withPageErrors: results.filter((r) => r.probe.pageErrors.length > 0).length,
|
||||||
|
withFailedRequests: results.filter((r) => r.probe.failedRequests.length > 0).length,
|
||||||
|
results,
|
||||||
|
};
|
||||||
|
await writeFile(REPORT, JSON.stringify(summary, null, 2));
|
||||||
|
console.log("\nSummary:", JSON.stringify({
|
||||||
|
flows: summary.flows,
|
||||||
|
renderedOk: summary.renderedOk,
|
||||||
|
withConsoleErrors: summary.withConsoleErrors,
|
||||||
|
withPageErrors: summary.withPageErrors,
|
||||||
|
withFailedRequests: summary.withFailedRequests,
|
||||||
|
}, null, 2));
|
||||||
|
console.log("\nReport:", REPORT);
|
||||||
|
console.log("Shots:", SHOTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("FATAL", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
tests/
|
||||||
|
docs/prodfiles/
|
||||||
|
*.production.txt
|
||||||
|
.git/
|
||||||
|
.github/
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
#
|
||||||
|
# Cyclone backend — FastAPI on python:3.11-slim-bookworm with sqlcipher.
|
||||||
|
#
|
||||||
|
# Two-stage build:
|
||||||
|
# 1. builder — wheels the package with [sqlcipher] extra into /wheels.
|
||||||
|
# 2. runtime — slim base, tini PID 1, curl-based healthcheck.
|
||||||
|
#
|
||||||
|
# `sqlcipher` is preferred but the engine falls back to plain SQLite at
|
||||||
|
# runtime if the package isn't actually installed (see cyclone.db) — so a
|
||||||
|
# missing libsqlcipher-dev during build will fail loudly here rather than
|
||||||
|
# silently downgrading encryption in production.
|
||||||
|
|
||||||
|
# ---------- builder ----------
|
||||||
|
FROM python:3.11-slim-bookworm AS builder
|
||||||
|
|
||||||
|
ENV PIP_NO_CACHE_DIR=1 \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
libffi-dev \
|
||||||
|
libsqlcipher-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy the build manifest first so this layer caches across source edits.
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Copy the full source tree, then build the wheel once. We deliberately
|
||||||
|
# avoid the "stub __init__.py, build wheel, then rebuild" pattern — it
|
||||||
|
# left stale `__init__.py` content in the wheel because pip wheel reuses
|
||||||
|
# the cached wheel metadata when the name+version matches. See git
|
||||||
|
# history on this file for the long version.
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
|
||||||
|
|
||||||
|
# ---------- runtime ----------
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libsqlcipher-dev \
|
||||||
|
curl \
|
||||||
|
tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --create-home --uid 1000 --shell /bin/bash cyclone
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links /wheels 'cyclone[sqlcipher]' \
|
||||||
|
&& rm -rf /wheels
|
||||||
|
|
||||||
|
# NOTE: we deliberately do NOT drop privileges to the `cyclone` user.
|
||||||
|
# Named volumes mount as root inside the container, and chown-ing them
|
||||||
|
# requires CAP_CHOWN (root). The standard hardened pattern is an
|
||||||
|
# entrypoint script that chowns as root then drops to the app user via
|
||||||
|
# gosu/su-exec — adds a dependency + an entrypoint file. For v1 we run
|
||||||
|
# as root inside the container; Docker's user-namespace remapping is
|
||||||
|
# the recommended host-level isolation. The `cyclone` user is created
|
||||||
|
# above and survives only so file ownership in bind mounts stays
|
||||||
|
# consistent. To harden later: install gosu + add an entrypoint script
|
||||||
|
# that does `chown -R cyclone:cyclone /var/lib/cyclone/... && exec gosu
|
||||||
|
# cyclone "$@"`.
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Container-level healthcheck — the compose service healthcheck is
|
||||||
|
# effectively a duplicate but the Docker `HEALTHCHECK` directive keeps
|
||||||
|
# `docker ps` honest without needing compose to be running.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
|
CMD curl -fs http://localhost:8000/api/health || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["tini", "--"]
|
||||||
|
CMD ["python", "-m", "cyclone", "serve"]
|
||||||
@@ -16,6 +16,15 @@ dependencies = [
|
|||||||
"sqlalchemy>=2.0,<3",
|
"sqlalchemy>=2.0,<3",
|
||||||
"pyyaml>=6.0,<7",
|
"pyyaml>=6.0,<7",
|
||||||
"keyring>=25.0,<26",
|
"keyring>=25.0,<26",
|
||||||
|
# backup_service / backup: encryption-at-rest (SP17). Used at module
|
||||||
|
# top-level by cyclone.backup, so it has to be a hard dep — not an
|
||||||
|
# extra — or the test suite fails to collect when the venv is built
|
||||||
|
# from a clean `uv sync`.
|
||||||
|
"cryptography>=49.0,<50",
|
||||||
|
# passlib 1.7.4 + bcrypt >= 4.1 are incompatible (passlib probes bcrypt.__about__
|
||||||
|
# which 4.x removed). Pin bcrypt < 4.1.
|
||||||
|
"passlib[bcrypt]>=1.7.4",
|
||||||
|
"bcrypt<4.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -24,6 +33,7 @@ dev = [
|
|||||||
"pytest-cov>=4.1",
|
"pytest-cov>=4.1",
|
||||||
"pytest-asyncio>=0.23,<1",
|
"pytest-asyncio>=0.23,<1",
|
||||||
"httpx>=0.27,<1",
|
"httpx>=0.27,<1",
|
||||||
|
"pytest-randomly>=4.1",
|
||||||
]
|
]
|
||||||
sqlcipher = [
|
sqlcipher = [
|
||||||
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
|
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
|
||||||
|
|||||||
@@ -16,13 +16,41 @@ import sys
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
# Always run first-admin bootstrap before any other entry path.
|
||||||
|
# Must happen before ``serve`` (uvicorn) AND before the Click CLI
|
||||||
|
# dispatch — otherwise `python -m cyclone users create ...` on a
|
||||||
|
# fresh DB would race with the bootstrap's check, and the API
|
||||||
|
# could come up with zero users.
|
||||||
|
from cyclone.auth import bootstrap
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
# SP24: if the AUTH_DISABLED escape hatch is on, scream at boot so a
|
||||||
|
# misconfigured production deploy fails loudly. The flag is flipped by
|
||||||
|
# ``CYCLONE_AUTH_DISABLED=1`` (see ``cyclone.auth.bootstrap``) and by the
|
||||||
|
# pytest conftest autouse fixture (see ``.superpowers/skills/cyclone-tests``).
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
|
|
||||||
|
if _auth_deps.AUTH_DISABLED:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.getLogger("cyclone").warning(
|
||||||
|
"AUTH_DISABLED is set (CYCLONE_AUTH_DISABLED=1) — all requests "
|
||||||
|
"treated as admin, dev only. Do NOT enable this in production."
|
||||||
|
)
|
||||||
|
|
||||||
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
if len(sys.argv) >= 2 and sys.argv[1] == "serve":
|
||||||
port = os.environ.get("CYCLONE_PORT", "8000")
|
port = os.environ.get("CYCLONE_PORT", "8000")
|
||||||
|
# Local-only by default — see CLAUDE.md. The Docker image
|
||||||
|
# overrides to 0.0.0.0 via compose env so the frontend
|
||||||
|
# container on the compose bridge network can reach the
|
||||||
|
# backend. Network isolation is provided by the bridge
|
||||||
|
# network itself (only cyclone-frontend joins).
|
||||||
|
host = os.environ.get("CYCLONE_HOST", "127.0.0.1")
|
||||||
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
|
||||||
sys.argv = [
|
sys.argv = [
|
||||||
sys.argv[0],
|
sys.argv[0],
|
||||||
"cyclone.api:app",
|
"cyclone.api:app",
|
||||||
"--host", "127.0.0.1",
|
"--host", host,
|
||||||
"--port", port,
|
"--port", port,
|
||||||
]
|
]
|
||||||
if reload:
|
if reload:
|
||||||
|
|||||||
+496
-87
File diff suppressed because it is too large
Load Diff
@@ -93,19 +93,40 @@ def ndjson_stream_list(
|
|||||||
}) + "\n"
|
}) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
|
def ndjson_stream_837(
|
||||||
"""Yield one JSON object per line: envelope → claims → summary."""
|
result: ParseResult, batch_id: str | None = None,
|
||||||
|
) -> Iterator[bytes]:
|
||||||
|
"""Yield one JSON object per line: envelope → claims → summary.
|
||||||
|
|
||||||
|
The ``batch_id`` is the server-side UUID assigned by the persistence
|
||||||
|
layer when the batch is ingested; the JSON response path exposes it
|
||||||
|
as the top-level ``batch_id`` field, but the NDJSON stream needs it
|
||||||
|
inline on the summary event so streaming clients can call
|
||||||
|
batch-scoped endpoints (``/api/batches/{id}/export-837``, …) without
|
||||||
|
a separate ``GET /api/batches`` round-trip. When ``batch_id`` is not
|
||||||
|
supplied the summary omits the field, preserving backward compat
|
||||||
|
with clients that don't expect it.
|
||||||
|
"""
|
||||||
envelope_obj = (
|
envelope_obj = (
|
||||||
result.envelope.model_dump() if result.envelope is not None else None
|
result.envelope.model_dump() if result.envelope is not None else None
|
||||||
)
|
)
|
||||||
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
|
||||||
for claim in result.claims:
|
for claim in result.claims:
|
||||||
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "claim", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
summary_data = json.loads(result.summary.model_dump_json())
|
||||||
|
if batch_id is not None:
|
||||||
|
summary_data["batch_id"] = batch_id
|
||||||
|
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
def ndjson_stream_835(
|
||||||
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
|
result: ParseResult835, batch_id: str | None = None,
|
||||||
|
) -> Iterator[bytes]:
|
||||||
|
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary.
|
||||||
|
|
||||||
|
See ``ndjson_stream_837`` for why the optional ``batch_id`` is
|
||||||
|
merged into the summary event.
|
||||||
|
"""
|
||||||
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "envelope", "data": json.loads(result.envelope.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "financial_info", "data": json.loads(result.financial_info.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
@@ -113,7 +134,10 @@ def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
|
|||||||
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
for claim in result.claims:
|
for claim in result.claims:
|
||||||
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.model_dump_json())}) + "\n").encode("utf-8")
|
||||||
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
|
summary_data = json.loads(result.summary.model_dump_json())
|
||||||
|
if batch_id is not None:
|
||||||
|
summary_data["batch_id"] = batch_id
|
||||||
|
yield (json.dumps({"type": "summary", "data": summary_data}) + "\n").encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def strict_rewrite_837(result: ParseResult) -> ParseResult:
|
def strict_rewrite_837(result: ParseResult) -> ParseResult:
|
||||||
|
|||||||
@@ -103,6 +103,12 @@ class AuditEvent:
|
|||||||
computed hash. Payload must be JSON-serializable; the audit_log
|
computed hash. Payload must be JSON-serializable; the audit_log
|
||||||
module handles the encoding so callers don't need to think about
|
module handles the encoding so callers don't need to think about
|
||||||
canonical form.
|
canonical form.
|
||||||
|
|
||||||
|
``user_id`` is the authenticated actor for this event, when known
|
||||||
|
(e.g. a parse-999 call made by user 7). It's stored on the row but
|
||||||
|
is NOT part of the hash chain — the chain hashes only the fields
|
||||||
|
that existed pre-SP-auth so verify_chain stays compatible with
|
||||||
|
pre-auth rows.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
event_type: str
|
event_type: str
|
||||||
@@ -111,6 +117,7 @@ class AuditEvent:
|
|||||||
payload: dict[str, Any] = field(default_factory=dict)
|
payload: dict[str, Any] = field(default_factory=dict)
|
||||||
actor: str = "system"
|
actor: str = "system"
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
|
user_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
def append_event(
|
def append_event(
|
||||||
@@ -155,6 +162,7 @@ def append_event(
|
|||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
prev_hash=prev_hash,
|
prev_hash=prev_hash,
|
||||||
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
hash=GENESIS_PREV_HASH, # placeholder; updated below
|
||||||
|
user_id=event.user_id,
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
session.flush() # populate row.id
|
session.flush() # populate row.id
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Admin-only user management: GET/POST/PATCH/DELETE /api/admin/users."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.deps import get_current_user
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/admin/users", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(user: dict = Depends(get_current_user)) -> dict:
|
||||||
|
if user.get("role") != Role.ADMIN.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_role(role: str) -> None:
|
||||||
|
valid = {Role.ADMIN.value, Role.USER.value, Role.VIEWER.value}
|
||||||
|
if role not in valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"role must be one of {sorted(valid)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
def list_users(_admin=Depends(_require_admin)):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
all_users = db.query(User).all()
|
||||||
|
return [users.to_public(u) for u in all_users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_user(body: dict, _admin=Depends(_require_admin)):
|
||||||
|
username = (body.get("username") or "").strip()
|
||||||
|
password = body.get("password") or ""
|
||||||
|
role = body.get("role") or ""
|
||||||
|
if not username or len(username) < 3:
|
||||||
|
raise HTTPException(status_code=422, detail="username must be at least 3 chars")
|
||||||
|
if len(password) < 12:
|
||||||
|
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
|
||||||
|
_validate_role(role)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if users.get_by_username(db, username) is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="username already exists")
|
||||||
|
u = users.create(db, username=username, password=password, role=role)
|
||||||
|
return users.to_public(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
def patch_user(user_id: int, body: dict, admin=Depends(_require_admin)):
|
||||||
|
me = admin
|
||||||
|
if me.get("id") == user_id and body.get("role") and body["role"] != Role.ADMIN.value:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="cannot_demote_self",
|
||||||
|
)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if body.get("role") is not None:
|
||||||
|
_validate_role(body["role"])
|
||||||
|
users.update_role(db, user_id, body["role"])
|
||||||
|
if body.get("password") is not None:
|
||||||
|
if len(body["password"]) < 12:
|
||||||
|
raise HTTPException(status_code=422, detail="password must be at least 12 chars")
|
||||||
|
users.update_password(db, user_id, body["password"])
|
||||||
|
if body.get("disabled") is True:
|
||||||
|
users.disable(db, user_id)
|
||||||
|
u = users.get(db, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="user not found")
|
||||||
|
return users.to_public(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_user(user_id: int, admin=Depends(_require_admin)):
|
||||||
|
me = admin
|
||||||
|
if me.get("id") == user_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="cannot_delete_self",
|
||||||
|
)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get(db, user_id)
|
||||||
|
if u is None:
|
||||||
|
raise HTTPException(status_code=404, detail="user not found")
|
||||||
|
users.disable(db, user_id)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""First-admin bootstrap: create the initial admin from env vars if no users exist.
|
||||||
|
|
||||||
|
Called from ``python -m cyclone`` before either ``cli.main()`` or
|
||||||
|
``uvicorn`` so users exist by the time the API serves requests.
|
||||||
|
|
||||||
|
Precedence:
|
||||||
|
|
||||||
|
1. ``CYCLONE_AUTH_DISABLED=1`` — dev escape hatch. Flip the
|
||||||
|
``cyclone.auth.deps.AUTH_DISABLED`` flag so the API returns a
|
||||||
|
synthetic admin user without checking credentials. Never raises.
|
||||||
|
2. Users table non-empty — no-op.
|
||||||
|
3. ``CYCLONE_ADMIN_USERNAME`` + ``CYCLONE_ADMIN_PASSWORD`` env vars set
|
||||||
|
(password >= 12 chars) — create the admin and print confirmation.
|
||||||
|
Each env var can also be replaced by a ``*_FILE`` companion
|
||||||
|
(``CYCLONE_ADMIN_USERNAME_FILE`` / ``CYCLONE_ADMIN_PASSWORD_FILE``)
|
||||||
|
that points at a file on disk — the standard Docker-secret pattern,
|
||||||
|
used in production to avoid embedding secrets in ``docker-compose.yml``.
|
||||||
|
``_FILE`` takes precedence when set.
|
||||||
|
4. Otherwise — raise ``RuntimeError`` with a remediation hint that
|
||||||
|
points operators at ``python -m cyclone users create``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
def _read_secret(env_var: str, file_var: str) -> str | None:
|
||||||
|
"""Read a secret from a ``*_FILE`` env var (Docker-secret pattern) first,
|
||||||
|
falling back to the plain env var. Returns None if neither is set.
|
||||||
|
"""
|
||||||
|
file_path = os.environ.get(file_var)
|
||||||
|
if file_path:
|
||||||
|
try:
|
||||||
|
return Path(file_path).read_text().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"failed to read {file_var}={file_path}: {exc}"
|
||||||
|
) from exc
|
||||||
|
return os.environ.get(env_var)
|
||||||
|
|
||||||
|
|
||||||
|
def run() -> None:
|
||||||
|
"""Bootstrap the first admin user, or no-op.
|
||||||
|
|
||||||
|
See module docstring for behavior. Idempotent: safe to call on
|
||||||
|
every startup — it short-circuits as soon as the users table is
|
||||||
|
non-empty.
|
||||||
|
"""
|
||||||
|
if os.environ.get("CYCLONE_AUTH_DISABLED") == "1":
|
||||||
|
# Dev escape hatch — skip bootstrap entirely and tell the API
|
||||||
|
# to also short-circuit auth checks.
|
||||||
|
import cyclone.auth.deps as _deps
|
||||||
|
|
||||||
|
_deps.AUTH_DISABLED = True
|
||||||
|
return
|
||||||
|
|
||||||
|
username = _read_secret(
|
||||||
|
"CYCLONE_ADMIN_USERNAME", "CYCLONE_ADMIN_USERNAME_FILE"
|
||||||
|
)
|
||||||
|
password = _read_secret(
|
||||||
|
"CYCLONE_ADMIN_PASSWORD", "CYCLONE_ADMIN_PASSWORD_FILE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# First-boot fix: ``python -m cyclone`` calls bootstrap before any
|
||||||
|
# subcommand or the FastAPI lifespan handler runs, so on a brand-new
|
||||||
|
# DB ``SessionLocal()`` raises "init_db() has not been called".
|
||||||
|
# Initialize here so ``serve``, ``users create``, and friends can
|
||||||
|
# all reach the DB without the operator having to know about
|
||||||
|
# migrations. Idempotent — no-op when the schema is already current.
|
||||||
|
from cyclone import db as _db
|
||||||
|
|
||||||
|
_db.init_db()
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
existing = db.execute(select(User)).scalars().first()
|
||||||
|
if existing is not None:
|
||||||
|
return # users exist — nothing to bootstrap
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
|
||||||
|
"CYCLONE_ADMIN_PASSWORD env vars (min 12 chars), or run "
|
||||||
|
"`python -m cyclone users create <username> --role admin`."
|
||||||
|
)
|
||||||
|
if len(password) < 12:
|
||||||
|
raise RuntimeError(
|
||||||
|
"CYCLONE_ADMIN_PASSWORD must be at least 12 characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
users.create(
|
||||||
|
db,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
role=Role.ADMIN.value,
|
||||||
|
)
|
||||||
|
print(f"[cyclone] bootstrap admin user '{username}' created")
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""CLI subcommand: ``python -m cyclone users ...``.
|
||||||
|
|
||||||
|
Click-based to match the existing parse-837 / parse-835 convention in
|
||||||
|
``cyclone.cli``. Provides operator-side user management without going
|
||||||
|
through the admin HTTP API.
|
||||||
|
|
||||||
|
Subcommands
|
||||||
|
-----------
|
||||||
|
|
||||||
|
- ``users create USERNAME --role {admin,user,viewer} [--password PW]``
|
||||||
|
Create a user. If ``--password`` is omitted, prompts (with
|
||||||
|
confirmation) on the controlling terminal.
|
||||||
|
- ``users list`` — tab-separated ``id / username / role / state``.
|
||||||
|
- ``users disable USERNAME`` — set ``disabled_at`` to now.
|
||||||
|
- ``users reset-password USERNAME [--password PW]`` — replace the hash.
|
||||||
|
- ``users set-role USERNAME --role {admin,user,viewer}`` — change role.
|
||||||
|
|
||||||
|
Exit codes
|
||||||
|
----------
|
||||||
|
|
||||||
|
* 0 — success
|
||||||
|
* 1 — validation error (unknown username, duplicate username)
|
||||||
|
* 2 — usage error (missing arg, bad role, short password, unknown
|
||||||
|
subcommand). Click itself uses 2 for usage errors so the conventional
|
||||||
|
shell tools (``set -e``, etc.) recognize them.
|
||||||
|
|
||||||
|
Passwords shorter than 12 chars are rejected everywhere they appear.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
ROLE_CHOICES = [Role.ADMIN.value, Role.USER.value, Role.VIEWER.value]
|
||||||
|
MIN_PASSWORD_LEN = 12
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_password(label: str) -> str:
|
||||||
|
pw = getpass.getpass(f"{label}: ")
|
||||||
|
if not pw:
|
||||||
|
click.echo("Password required.", err=True)
|
||||||
|
sys.exit(2)
|
||||||
|
return pw
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_password(pw: str) -> None:
|
||||||
|
if len(pw) < MIN_PASSWORD_LEN:
|
||||||
|
click.echo(
|
||||||
|
f"Password must be at least {MIN_PASSWORD_LEN} characters.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Group
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(name="users")
|
||||||
|
def users_cli() -> None:
|
||||||
|
"""Manage Cyclone users from the command line."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# create
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("create")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||||
|
help="Role to grant the new user.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--password",
|
||||||
|
default=None,
|
||||||
|
help=f"Password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||||
|
)
|
||||||
|
def create_user(username: str, role: str, password: str | None) -> None:
|
||||||
|
"""Create a new user with the given USERNAME and ROLE."""
|
||||||
|
pw = password or _prompt_password("Password")
|
||||||
|
_validate_password(pw)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
if users.get_by_username(db, username) is not None:
|
||||||
|
click.echo(f"User '{username}' already exists.", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
u = users.create(db, username=username, password=pw, role=role)
|
||||||
|
click.echo(f"Created user '{u.username}' with role '{u.role}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# list
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("list")
|
||||||
|
def list_users() -> None:
|
||||||
|
"""List all users as id / username / role / state."""
|
||||||
|
from cyclone.db import User
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
for u in db.query(User).order_by(User.id.asc()).all():
|
||||||
|
state = "disabled" if u.disabled_at else "active"
|
||||||
|
click.echo(f"{u.id}\t{u.username}\t{u.role}\t{state}")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# disable
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("disable")
|
||||||
|
@click.argument("username")
|
||||||
|
def disable_user(username: str) -> None:
|
||||||
|
"""Disable USERNAME (sets disabled_at to now)."""
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.disable(db, u.id)
|
||||||
|
click.echo(f"Disabled '{username}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# reset-password
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("reset-password")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--password",
|
||||||
|
default=None,
|
||||||
|
help=f"New password (min {MIN_PASSWORD_LEN} chars). Prompts if omitted.",
|
||||||
|
)
|
||||||
|
def reset_password(username: str, password: str | None) -> None:
|
||||||
|
"""Replace USERNAME's password."""
|
||||||
|
pw = password or _prompt_password("New password")
|
||||||
|
_validate_password(pw)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.update_password(db, u.id, pw)
|
||||||
|
click.echo(f"Password reset for '{username}'.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# set-role
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
|
||||||
|
@users_cli.command("set-role")
|
||||||
|
@click.argument("username")
|
||||||
|
@click.option(
|
||||||
|
"--role",
|
||||||
|
required=True,
|
||||||
|
type=click.Choice(ROLE_CHOICES, case_sensitive=False),
|
||||||
|
help="Role to grant.",
|
||||||
|
)
|
||||||
|
def set_role(username: str, role: str) -> None:
|
||||||
|
"""Change USERNAME's role."""
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, username)
|
||||||
|
if u is None:
|
||||||
|
click.echo(f"No such user: {username}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
users.update_role(db, u.id, role)
|
||||||
|
click.echo(f"Role for '{username}' set to '{role}'.")
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"""FastAPI dependencies for auth."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
from cyclone.auth import sessions, users
|
||||||
|
from cyclone.auth.permissions import Role, allowed_roles
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
|
||||||
|
def _db():
|
||||||
|
db = SessionLocal()()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
DbSessionDep = Annotated[DbSession, Depends(_db)]
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_DISABLED = False
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
db: DbSessionDep,
|
||||||
|
) -> dict:
|
||||||
|
"""Return the public User shape. Raises 401 if session is missing/expired.
|
||||||
|
|
||||||
|
When AUTH_DISABLED is True (dev escape hatch), returns a synthetic admin
|
||||||
|
user without checking credentials.
|
||||||
|
"""
|
||||||
|
if AUTH_DISABLED:
|
||||||
|
return {
|
||||||
|
"id": 0,
|
||||||
|
"username": "dev",
|
||||||
|
"role": Role.ADMIN.value,
|
||||||
|
"createdAt": None,
|
||||||
|
"disabledAt": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
sid = request.cookies.get("cyclone_session")
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="session_expired",
|
||||||
|
)
|
||||||
|
sess = sessions.get_valid(db, sid)
|
||||||
|
if sess is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="session_expired",
|
||||||
|
)
|
||||||
|
user = users.get(db, sess.user_id)
|
||||||
|
if user is None or user.disabled_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="account_disabled",
|
||||||
|
)
|
||||||
|
# Sliding expiry: refresh both DB and cookie.
|
||||||
|
sessions.touch(db, sid)
|
||||||
|
request.state.user = user
|
||||||
|
request.state.session_id = sid
|
||||||
|
return users.to_public(user)
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*allowed: Role):
|
||||||
|
"""Dependency factory: gate the endpoint to specific roles.
|
||||||
|
|
||||||
|
Falls back to PERMISSIONS matrix lookup if no explicit roles given.
|
||||||
|
"""
|
||||||
|
async def _dep(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
user_role = user.get("role")
|
||||||
|
if allowed:
|
||||||
|
if user_role not in {r.value for r in allowed}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
# Otherwise consult the matrix.
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
roles = allowed_roles(method, path)
|
||||||
|
if roles is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
if user_role not in {r.value for r in roles}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
return _dep
|
||||||
|
|
||||||
|
|
||||||
|
async def matrix_gate(
|
||||||
|
request: Request,
|
||||||
|
user: dict = Depends(get_current_user),
|
||||||
|
) -> dict:
|
||||||
|
"""App-wide gate: requires auth, then enforces the PERMISSIONS matrix.
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
* AUTH_DISABLED short-circuits (synthetic admin, no role check).
|
||||||
|
* No session cookie → 401 from get_current_user.
|
||||||
|
* Endpoint not in the matrix → 403 (fail-closed).
|
||||||
|
* User role not allowed for (method, path) → 403.
|
||||||
|
* Empty allowed-roles set (e.g. /api/healthz) → public, no role check.
|
||||||
|
|
||||||
|
Used as ``dependencies=[Depends(matrix_gate)]`` on every authenticated
|
||||||
|
route. Centralizing the gate here means the matrix is the source of
|
||||||
|
truth — no need to wire per-route ``require_role(...)`` calls.
|
||||||
|
"""
|
||||||
|
if AUTH_DISABLED:
|
||||||
|
return user
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
roles = allowed_roles(method, path)
|
||||||
|
if roles is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
user_role = user.get("role")
|
||||||
|
if user_role not in {r.value for r in roles}:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="forbidden",
|
||||||
|
)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Role enum + PERMISSIONS matrix."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
USER = "user"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
ALL_ROLES = {Role.ADMIN, Role.USER, Role.VIEWER}
|
||||||
|
WRITE_ROLES = {Role.ADMIN, Role.USER}
|
||||||
|
ADMIN_ONLY = {Role.ADMIN}
|
||||||
|
|
||||||
|
|
||||||
|
# (method, path-prefix) → allowed roles.
|
||||||
|
# Endpoints not in this matrix default to DENY (fail-closed).
|
||||||
|
PERMISSIONS: dict[tuple[str, str], set[Role]] = {
|
||||||
|
# Public paths.
|
||||||
|
("GET", "/api/health"): set(),
|
||||||
|
("POST", "/api/auth/login"): set(),
|
||||||
|
|
||||||
|
# Auth surface.
|
||||||
|
("POST", "/api/auth/logout"): ALL_ROLES,
|
||||||
|
("GET", "/api/auth/me"): ALL_ROLES,
|
||||||
|
|
||||||
|
# Admin-only user management.
|
||||||
|
("GET", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("POST", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("PATCH", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
("DELETE", "/api/admin/users"): ADMIN_ONLY,
|
||||||
|
|
||||||
|
# Read endpoints (all authenticated roles).
|
||||||
|
("GET", "/api/claims"): ALL_ROLES,
|
||||||
|
("GET", "/api/remittances"): ALL_ROLES,
|
||||||
|
("GET", "/api/providers"): ALL_ROLES,
|
||||||
|
("GET", "/api/batches"): ALL_ROLES,
|
||||||
|
("GET", "/api/dashboard/summary"): ALL_ROLES,
|
||||||
|
("GET", "/api/activity"): ALL_ROLES,
|
||||||
|
("GET", "/api/inbox/lanes"): ALL_ROLES,
|
||||||
|
("GET", "/api/inbox/export.csv"): ALL_ROLES,
|
||||||
|
("GET", "/api/reconcile"): ALL_ROLES,
|
||||||
|
("GET", "/api/reconciliation"): ALL_ROLES,
|
||||||
|
("GET", "/api/audit-log"): ADMIN_ONLY,
|
||||||
|
|
||||||
|
# Write endpoints (admin + user, no viewer).
|
||||||
|
("POST", "/api/parse-837"): WRITE_ROLES,
|
||||||
|
("POST", "/api/parse-835"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox/candidates"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox/rejected"): WRITE_ROLES,
|
||||||
|
("POST", "/api/inbox/payer-rejected"): WRITE_ROLES,
|
||||||
|
("POST", "/api/reconcile"): WRITE_ROLES,
|
||||||
|
("POST", "/api/reconciliation"): WRITE_ROLES,
|
||||||
|
("POST", "/api/resubmit"): WRITE_ROLES,
|
||||||
|
("POST", "/api/acks"): WRITE_ROLES,
|
||||||
|
|
||||||
|
# CSV export — read-only.
|
||||||
|
("GET", "/api/export.csv"): ALL_ROLES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_roles(method: str, path: str) -> set[Role] | None:
|
||||||
|
"""Return the set of roles allowed to call (method, path), or None if denied.
|
||||||
|
|
||||||
|
Uses longest-prefix match on path; falls back to DENY (None) if no entry matches.
|
||||||
|
"""
|
||||||
|
candidates = [
|
||||||
|
(len(prefix), roles)
|
||||||
|
for (m, prefix), roles in PERMISSIONS.items()
|
||||||
|
if m == method and (path == prefix or path.startswith(prefix.rstrip("/") + "/"))
|
||||||
|
]
|
||||||
|
if not candidates:
|
||||||
|
return None
|
||||||
|
candidates.sort(key=lambda x: -x[0])
|
||||||
|
return candidates[0][1]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Per-username login rate limiter (in-memory, per-process)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
WINDOW_SECONDS = 300
|
||||||
|
MAX_FAILS = 5
|
||||||
|
|
||||||
|
_FAILS: dict[str, list[float]] = {}
|
||||||
|
_LOCK = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def check(username: str) -> int:
|
||||||
|
"""Return retry-after seconds, or 0 if allowed."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with _LOCK:
|
||||||
|
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
|
||||||
|
_FAILS[username] = fails
|
||||||
|
if len(fails) >= MAX_FAILS:
|
||||||
|
return int(WINDOW_SECONDS - (now - fails[0]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def record_failure(username: str) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with _LOCK:
|
||||||
|
_FAILS.setdefault(username, []).append(now)
|
||||||
|
|
||||||
|
|
||||||
|
def reset(username: str) -> None:
|
||||||
|
with _LOCK:
|
||||||
|
_FAILS.pop(username, None)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""/api/auth/login, /api/auth/logout, /api/auth/me."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from cyclone.auth import rate_limit, sessions, users
|
||||||
|
from cyclone.auth.deps import get_current_user
|
||||||
|
from cyclone.db import SessionLocal
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||||
|
|
||||||
|
COOKIE_NAME = "cyclone_session"
|
||||||
|
COOKIE_MAX_AGE = 86400 # 24h
|
||||||
|
|
||||||
|
|
||||||
|
def _is_https(request: Request) -> bool:
|
||||||
|
if request.url.scheme == "https":
|
||||||
|
return True
|
||||||
|
return os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_cookie(response: Response, sid: str, request: Request) -> None:
|
||||||
|
response.set_cookie(
|
||||||
|
key=COOKIE_NAME,
|
||||||
|
value=sid,
|
||||||
|
max_age=COOKIE_MAX_AGE,
|
||||||
|
path="/api",
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=_is_https(request),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_cookie(response: Response) -> None:
|
||||||
|
response.delete_cookie(key=COOKIE_NAME, path="/api")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login")
|
||||||
|
def login(body: dict, request: Request, response: Response):
|
||||||
|
username = (body.get("username") or "").strip()
|
||||||
|
password = body.get("password") or ""
|
||||||
|
if not username or not password:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="username and password are required",
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_after = rate_limit.check(username)
|
||||||
|
if retry_after > 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
detail="rate_limited",
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
user = users.get_by_username(db, username)
|
||||||
|
if user is None or not users.verify_password(password, user.password_hash):
|
||||||
|
rate_limit.record_failure(username)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="invalid_credentials",
|
||||||
|
)
|
||||||
|
if user.disabled_at is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="account_disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
rate_limit.reset(username)
|
||||||
|
public = users.to_public(user)
|
||||||
|
|
||||||
|
_set_cookie(response, sid, request)
|
||||||
|
return public
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
def logout(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
sid = request.cookies.get(COOKIE_NAME)
|
||||||
|
if sid:
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sessions.delete(db, sid)
|
||||||
|
_clear_cookie(response)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me")
|
||||||
|
def me(user: dict = Depends(get_current_user)):
|
||||||
|
return user
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Session create/validate/expire/touch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.db import Session
|
||||||
|
|
||||||
|
SESSION_LIFETIME = timedelta(hours=24)
|
||||||
|
|
||||||
|
|
||||||
|
def create(db, *, user_id: int) -> tuple[str, Session]:
|
||||||
|
sid = secrets.token_urlsafe(32)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
expires_at = now + SESSION_LIFETIME
|
||||||
|
sess = Session(
|
||||||
|
id=sid,
|
||||||
|
user_id=user_id,
|
||||||
|
expires_at=expires_at,
|
||||||
|
created_at=now,
|
||||||
|
)
|
||||||
|
db.add(sess)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sess)
|
||||||
|
# SQLite strips tzinfo on roundtrip; restore it so callers don't have to.
|
||||||
|
sess.expires_at = expires_at
|
||||||
|
return sid, sess
|
||||||
|
|
||||||
|
|
||||||
|
def get_valid(db, sid: str) -> Session | None:
|
||||||
|
sess = db.execute(
|
||||||
|
select(Session).where(Session.id == sid)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if sess is None:
|
||||||
|
return None
|
||||||
|
# SQLite drops tzinfo on roundtrip; normalize to UTC before comparing.
|
||||||
|
if sess.expires_at.tzinfo is None:
|
||||||
|
sess.expires_at = sess.expires_at.replace(tzinfo=timezone.utc)
|
||||||
|
if sess.expires_at <= datetime.now(timezone.utc):
|
||||||
|
return None
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
def delete(db, sid: str) -> None:
|
||||||
|
sess = db.get(Session, sid)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
db.delete(sess)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def touch(db, sid: str) -> None:
|
||||||
|
sess = db.get(Session, sid)
|
||||||
|
if sess is None:
|
||||||
|
return
|
||||||
|
sess.expires_at = datetime.now(timezone.utc) + SESSION_LIFETIME
|
||||||
|
db.commit()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""User CRUD + bcrypt password hashing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from passlib.hash import bcrypt
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from cyclone.db import User
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(plaintext: str) -> str:
|
||||||
|
return bcrypt.hash(plaintext)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plaintext: str, hashed: str) -> bool:
|
||||||
|
try:
|
||||||
|
return bcrypt.verify(plaintext, hashed)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def create(db, *, username: str, password: str, role: str) -> User:
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role=role,
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def get_by_username(db, username: str) -> User | None:
|
||||||
|
return db.execute(
|
||||||
|
select(User).where(User.username == username)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def get(db, user_id: int) -> User | None:
|
||||||
|
return db.get(User, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def disable(db, user_id: int) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.disabled_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_role(db, user_id: int, role: str) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.role = role
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def update_password(db, user_id: int, new_password: str) -> None:
|
||||||
|
user = db.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def to_public(user: User) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user.id,
|
||||||
|
"username": user.username,
|
||||||
|
"role": user.role,
|
||||||
|
"createdAt": user.created_at.isoformat() if user.created_at else None,
|
||||||
|
"disabledAt": user.disabled_at.isoformat() if user.disabled_at else None,
|
||||||
|
}
|
||||||
@@ -74,6 +74,18 @@ def main(ctx: click.Context, log_format: str | None, log_file: Path | None) -> N
|
|||||||
ctx.ensure_object(dict)
|
ctx.ensure_object(dict)
|
||||||
|
|
||||||
|
|
||||||
|
# Register the auth users subgroup. Imported here (not at module top) to
|
||||||
|
# avoid pulling passlib / bcrypt at CLI parse-only import time.
|
||||||
|
from cyclone.auth.cli import users_cli # noqa: E402
|
||||||
|
main.add_command(users_cli)
|
||||||
|
|
||||||
|
# Register the dev seed subcommand. Imported here for the same lazy-load
|
||||||
|
# reason as users_cli — keeps passlib/bcrypt + SQLAlchemy out of the
|
||||||
|
# parse-only path so ``python -m cyclone --help`` stays snappy.
|
||||||
|
from cyclone.seed_cli import seed_cli # noqa: E402
|
||||||
|
main.add_command(seed_cli)
|
||||||
|
|
||||||
|
|
||||||
@main.command("parse-837")
|
@main.command("parse-837")
|
||||||
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
@click.argument("input_file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
|
@click.option("--output-dir", required=True, type=click.Path(file_okay=False, path_type=Path))
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from sqlalchemy import (
|
|||||||
Numeric,
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Text,
|
Text,
|
||||||
|
func,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
|
||||||
@@ -668,6 +669,11 @@ class AuditLog(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
prev_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
# SP-auth: which authenticated user performed this action. Nullable
|
||||||
|
# so existing (pre-auth) rows and system-initiated events stay valid.
|
||||||
|
# NOT part of the hash chain — verify_chain must continue to work on
|
||||||
|
# legacy rows that pre-date this column.
|
||||||
|
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
Index("idx_audit_log_entity", "entity_type", "entity_id"),
|
||||||
@@ -836,3 +842,27 @@ class ClearhouseORM(Base):
|
|||||||
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
filename_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
sftp_block_json: Mapped[dict] = mapped_column(JSONText, nullable=False)
|
||||||
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
updated_at: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Auth user (admin / user / viewer)."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
"""Server-side auth session (HttpOnly cookie holds the id)."""
|
||||||
|
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ SP9. Source-of-truth spec:
|
|||||||
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
|
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
|
||||||
|
|
||||||
Outbound (we send):
|
Outbound (we send):
|
||||||
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
|
||||||
Example: 11525703-837P-20260620132243505-1of1.x12
|
Example: tp11525703-837P-20260620132243505-1of1.x12
|
||||||
|
|
||||||
Inbound (HPE sends to our ToHPE):
|
Inbound (HPE sends to our ToHPE):
|
||||||
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
|
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
|
||||||
@@ -28,14 +28,15 @@ from cyclone.providers import InboundFilename
|
|||||||
# Regexes
|
# Regexes
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Outbound: 11525703-837P-20260620132243505-1of1.x12
|
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
|
||||||
|
# - tp: literal "tp" prefix
|
||||||
# - tpid: 1+ digits
|
# - tpid: 1+ digits
|
||||||
# - tx: 1+ alnum
|
# - tx: 1+ alnum
|
||||||
# - ts: 17 digits (yyyymmddhhmmssSSS)
|
# - ts: 17 digits (yyyymmddhhmmssSSS)
|
||||||
# - seq: literal "1of1"
|
# - seq: literal "1of1"
|
||||||
# - ext: 1+ alnum
|
# - ext: 1+ alnum
|
||||||
OUTBOUND_RE = re.compile(
|
OUTBOUND_RE = re.compile(
|
||||||
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
|
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
|
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
|
||||||
@@ -79,7 +80,8 @@ def build_outbound_filename(
|
|||||||
time in ``America/Denver`` is used.
|
time in ``America/Denver`` is used.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Filename like "11525703-837P-20260620132243505-1of1.x12"
|
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
|
||||||
|
(note the ``tp`` prefix per HCPF outbound spec).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
ValueError: If tpid is non-numeric, tx contains invalid chars, or
|
ValueError: If tpid is non-numeric, tx contains invalid chars, or
|
||||||
@@ -99,7 +101,10 @@ def build_outbound_filename(
|
|||||||
# Format: yyyymmddhhmmssSSS — 17 digits total
|
# Format: yyyymmddhhmmssSSS — 17 digits total
|
||||||
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
|
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
|
||||||
assert len(ts) == 17
|
assert len(ts) == 17
|
||||||
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
|
# Per HCPF outbound spec, prefix is "tp" + tpid. Matches the format
|
||||||
|
# we receive from HPE inbound (which uses uppercase TP) and the
|
||||||
|
# historical outbound prodfile naming (e.g. tp11525703-837P-...).
|
||||||
|
return f"tp{tpid}-{tx}-{ts}-1of1.{ext}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- version: 13
|
||||||
|
-- Auth (SP-auth): users + sessions tables.
|
||||||
|
--
|
||||||
|
-- `users` holds the local credential store: bcrypt-hashed password,
|
||||||
|
-- role enum ('admin' | 'user' | 'viewer'), and a soft-delete column
|
||||||
|
-- (disabled_at) so admins can revoke access without losing history.
|
||||||
|
--
|
||||||
|
-- `sessions` holds the server-side session rows; the browser only
|
||||||
|
-- carries an opaque token cookie (cyclone_session) that points here.
|
||||||
|
-- expires_at index lets us cheaply reap stale sessions.
|
||||||
|
|
||||||
|
CREATE TABLE users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
disabled_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- version: 14
|
||||||
|
-- Auth (SP-auth): record the acting user_id on every audit_log entry.
|
||||||
|
--
|
||||||
|
-- Backwards-compatible: existing rows get NULL user_id (they were
|
||||||
|
-- written by the pre-auth `system` actor). Going forward, the FastAPI
|
||||||
|
-- get_current_user dependency injects the id into every audit log call.
|
||||||
|
|
||||||
|
ALTER TABLE audit_log ADD COLUMN user_id INTEGER;
|
||||||
|
|
||||||
|
CREATE INDEX idx_audit_log_user_id ON audit_log(user_id);
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
-- version: 15
|
||||||
|
-- Drop the inline UNIQUE(batch_id, patient_control_number) on claims.
|
||||||
|
--
|
||||||
|
-- Migration 0003 attempted DROP INDEX IF EXISTS uq_claims_batch_pcn but
|
||||||
|
-- the constraint is inline in CREATE TABLE, so the drop was a no-op.
|
||||||
|
-- The only way to remove an inline UNIQUE in SQLite is table recreation.
|
||||||
|
--
|
||||||
|
-- Discovery 2026-06-23: the inline UNIQUE does NOT exist in the current
|
||||||
|
-- production DB at user_version=14 (or in main's fresh-DB schema). The
|
||||||
|
-- 32 "Duplicate claim" warnings in /tmp/cyclone-uvicorn.log are PK
|
||||||
|
-- collisions on claims.id (CLM01) when an operator re-uploads the same
|
||||||
|
-- file — not UNIQUE violations. This migration is therefore a defensive
|
||||||
|
-- no-op against the current schema, but keeps the 0003 intent alive
|
||||||
|
-- (drop the constraint if it ever reappears) and lets the SP22 spec
|
||||||
|
-- ship as designed.
|
||||||
|
--
|
||||||
|
-- X12 837P allows any number of CLM segments per 2000B subscriber loop;
|
||||||
|
-- claim identity is provided by the primary key (claims.id = CLM01).
|
||||||
|
-- The remittances table had a parallel constraint already removed in 0003
|
||||||
|
-- (because that one WAS a named index), so this migration only touches
|
||||||
|
-- claims.
|
||||||
|
--
|
||||||
|
-- The migration runner (db_migrate.py) wraps each .sql in an implicit
|
||||||
|
-- transaction via engine.begin(), so we MUST NOT use BEGIN/COMMIT.
|
||||||
|
-- PRAGMA defer_foreign_keys defers FK checks to commit, which is the
|
||||||
|
-- only way to drop a referenced table inside a transaction in SQLite.
|
||||||
|
-- Other tables referencing claims:
|
||||||
|
-- remittances.claim_id
|
||||||
|
-- matches.claim_id
|
||||||
|
-- line_reconciliations.claim_id
|
||||||
|
-- activity_events.claim_id
|
||||||
|
|
||||||
|
PRAGMA defer_foreign_keys = ON;
|
||||||
|
|
||||||
|
CREATE TABLE claims_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
|
||||||
|
patient_control_number TEXT NOT NULL,
|
||||||
|
service_date_from DATE,
|
||||||
|
service_date_to DATE,
|
||||||
|
charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
provider_npi TEXT,
|
||||||
|
payer_id TEXT,
|
||||||
|
state TEXT NOT NULL DEFAULT 'submitted',
|
||||||
|
state_before_reversal TEXT,
|
||||||
|
matched_remittance_id TEXT REFERENCES remittances(id),
|
||||||
|
raw_json TEXT,
|
||||||
|
rejection_reason TEXT,
|
||||||
|
rejected_at TIMESTAMP,
|
||||||
|
resubmit_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
state_changed_at TIMESTAMP,
|
||||||
|
payer_rejected_at TEXT,
|
||||||
|
payer_rejected_reason TEXT,
|
||||||
|
payer_rejected_status_code TEXT,
|
||||||
|
payer_rejected_by_277ca_id TEXT,
|
||||||
|
payer_rejected_acknowledged_at TEXT,
|
||||||
|
payer_rejected_acknowledged_actor TEXT
|
||||||
|
-- NO UNIQUE (batch_id, patient_control_number) — removed.
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO claims_new SELECT * FROM claims;
|
||||||
|
DROP TABLE claims;
|
||||||
|
ALTER TABLE claims_new RENAME TO claims;
|
||||||
|
|
||||||
|
-- Recreate secondary indexes (same names, same columns as initial schema
|
||||||
|
-- plus later migrations).
|
||||||
|
CREATE INDEX ix_claims_state ON claims(state);
|
||||||
|
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
|
||||||
|
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);
|
||||||
|
CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at);
|
||||||
|
CREATE INDEX idx_claims_payer_rejected_at ON claims(payer_rejected_at);
|
||||||
|
CREATE INDEX idx_claims_payer_rejected_unack
|
||||||
|
ON claims(payer_rejected_at)
|
||||||
|
WHERE payer_rejected_acknowledged_at IS NULL;
|
||||||
@@ -63,6 +63,7 @@ class ClaimHeader(_Base):
|
|||||||
frequency_code: str | None = None
|
frequency_code: str | None = None
|
||||||
provider_signature: str | None = None
|
provider_signature: str | None = None
|
||||||
assignment: str | None = None
|
assignment: str | None = None
|
||||||
|
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
|
||||||
release_of_info: str | None = None
|
release_of_info: str | None = None
|
||||||
prior_auth: str | None = None
|
prior_auth: str | None = None
|
||||||
|
|
||||||
@@ -87,6 +88,14 @@ class ServiceLine(_Base):
|
|||||||
place_of_service: str | None = None
|
place_of_service: str | None = None
|
||||||
service_date: date | None = None
|
service_date: date | None = None
|
||||||
provider_reference: str | None = None
|
provider_reference: str | None = None
|
||||||
|
# SV1-07 — Diagnosis Code Pointer. Points to one or more
|
||||||
|
# diagnosis codes in the parent claim's HI segment ("1".. "12",
|
||||||
|
# space-separated when multiple). For 837P with a non-empty HI
|
||||||
|
# segment, SV1-07 is required by HCPF / Gainwell. The parser
|
||||||
|
# captures it from the source; the serializer defaults to "1"
|
||||||
|
# when the claim has at least one diagnosis and no explicit
|
||||||
|
# pointer was captured (matches the common single-dx case).
|
||||||
|
dx_pointer: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ValidationIssue(_Base):
|
class ValidationIssue(_Base):
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
|
|||||||
frequency_code=freq or None,
|
frequency_code=freq or None,
|
||||||
provider_signature=clm[6] if len(clm) > 6 else None,
|
provider_signature=clm[6] if len(clm) > 6 else None,
|
||||||
assignment=clm[7] if len(clm) > 7 else None,
|
assignment=clm[7] if len(clm) > 7 else None,
|
||||||
|
benefits_assignment_certification=clm[8] if len(clm) > 8 else None,
|
||||||
release_of_info=clm[9] if len(clm) > 9 else None,
|
release_of_info=clm[9] if len(clm) > 9 else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -285,6 +286,12 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
|
|||||||
except Exception:
|
except Exception:
|
||||||
units = None
|
units = None
|
||||||
place_of_service = seg[5] if len(seg) > 5 else None
|
place_of_service = seg[5] if len(seg) > 5 else None
|
||||||
|
# SV1-06 (Unit Basis of Measurement) is X12 "UN" for "units" — we
|
||||||
|
# already use unit_type in SV1-03; SV1-06 is rarely populated and
|
||||||
|
# is not required by HCPF.
|
||||||
|
# SV1-07 — Diagnosis Code Pointer (e.g. "1" for the first HI
|
||||||
|
# diagnosis). Required by HCPF when the claim has diagnoses.
|
||||||
|
dx_pointer = seg[7] if len(seg) > 7 and seg[7] else None
|
||||||
service_date: date | None = None
|
service_date: date | None = None
|
||||||
provider_ref: str | None = None
|
provider_ref: str | None = None
|
||||||
|
|
||||||
@@ -311,6 +318,7 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
|
|||||||
place_of_service=place_of_service,
|
place_of_service=place_of_service,
|
||||||
service_date=service_date,
|
service_date=service_date,
|
||||||
provider_reference=provider_ref,
|
provider_reference=provider_ref,
|
||||||
|
dx_pointer=dx_pointer,
|
||||||
),
|
),
|
||||||
idx,
|
idx,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
|
|||||||
_FUNCTIONAL_ID_HEALTH_CARE,
|
_FUNCTIONAL_ID_HEALTH_CARE,
|
||||||
sender_id,
|
sender_id,
|
||||||
receiver_id,
|
receiver_id,
|
||||||
_today_yymmdd(),
|
# GS-04 must be CCYYMMDD (8 digits) per X12 — ISA uses YYMMDD
|
||||||
|
# (6 digits) for the older format, but the GS segment is the
|
||||||
|
# newer ANSI X12 format and requires the full year.
|
||||||
|
_today_yyyymmdd(),
|
||||||
_today_hhmm(),
|
_today_hhmm(),
|
||||||
group_control_number,
|
group_control_number,
|
||||||
"X",
|
"X",
|
||||||
@@ -186,17 +189,30 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
|
|||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
|
|
||||||
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
|
def _build_per(
|
||||||
"""PER segment — submitter contact. Returns empty when no contact info."""
|
contact_name: str | None,
|
||||||
if not contact_name and not contact_phone:
|
contact_phone: str | None,
|
||||||
return ""
|
contact_email: str | None = None,
|
||||||
parts = [
|
email_qual: str = "EM",
|
||||||
"PER",
|
) -> str:
|
||||||
"IC", # PER01 — contact function code (Information Contact)
|
"""PER segment — submitter contact (Loop 1000A).
|
||||||
contact_name or "",
|
|
||||||
"TE", # PER03 — phone qualifier
|
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
|
||||||
contact_phone or "",
|
(Submitter Name) and at least PER01 must be present, so this
|
||||||
]
|
builder always emits a segment. PER01 = "IC" (Information Contact).
|
||||||
|
|
||||||
|
The remaining elements are filled from the available contact info:
|
||||||
|
name, then email (preferred — Gainwell/HCPF expect this), then phone.
|
||||||
|
"""
|
||||||
|
parts = ["PER", "IC"]
|
||||||
|
if contact_name:
|
||||||
|
parts.append(contact_name)
|
||||||
|
if contact_email:
|
||||||
|
parts.append(email_qual) # PER03 — email qualifier (default "EM")
|
||||||
|
parts.append(contact_email) # PER04 — the email itself
|
||||||
|
elif contact_phone:
|
||||||
|
parts.append("TE") # PER03 — phone qualifier
|
||||||
|
parts.append(contact_phone) # PER04 — the phone itself
|
||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
|
|
||||||
@@ -236,26 +252,40 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
|
|||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
|
|
||||||
def _build_sbr(relationship_code: str | None, member_id: str | None,
|
def _build_sbr(
|
||||||
payer_name: str | None) -> str:
|
individual_relationship_code: str | None,
|
||||||
|
claim_filing_indicator_code: str | None,
|
||||||
|
) -> str:
|
||||||
"""SBR segment — subscriber information.
|
"""SBR segment — subscriber information.
|
||||||
|
|
||||||
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
|
Slot layout (X12 005010X222A1):
|
||||||
the most common case for professional claims; the parser does not store
|
SBR01 — Payer Responsibility Sequence Number Code. Default ``"P"``
|
||||||
this on the canonical Subscriber model so we cannot thread it through
|
(Patient = primary). The parser does not capture this
|
||||||
without adding a model field.
|
field on ``ClaimOutput`` so we default it.
|
||||||
|
SBR02 — Individual Relationship Code. ``"18"`` = self, ``"01"`` = spouse, etc.
|
||||||
|
The parser does not capture this either; we default ``"18"``
|
||||||
|
for the common self-pay case.
|
||||||
|
SBR09 — Claim Filing Indicator Code. ``"MC"`` for Medicaid,
|
||||||
|
``"16"`` for Medicare Part B, etc. The canonical
|
||||||
|
PayerConfig837 carries ``sbr09_default``; we thread it in
|
||||||
|
from the caller.
|
||||||
|
|
||||||
|
The member_id and payer name do NOT belong in SBR — the member_id
|
||||||
|
lives in NM109 of the NM1*IL segment, and the payer name is in
|
||||||
|
NM103 of NM1*PR. (Earlier revisions of this function put them in
|
||||||
|
SBR06 / SBR09, which is wrong and rejected by HCPF.)
|
||||||
"""
|
"""
|
||||||
parts = [
|
parts = [
|
||||||
"SBR",
|
"SBR",
|
||||||
relationship_code or "P",
|
"P", # SBR01 — primary
|
||||||
"", # SBR02 — group number
|
individual_relationship_code or "18", # SBR02 — self
|
||||||
"", # SBR03 — group name
|
"", # SBR03 — group number
|
||||||
"", # SBR04 — claim filing indicator code
|
"", # SBR04 — group name
|
||||||
"", # SBR05 — sequence number code
|
"", # SBR05 — insurance type code
|
||||||
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
|
"", # SBR06 — coordination of benefits
|
||||||
"", # SBR07
|
"", # SBR07 — yes/no condition
|
||||||
"", # SBR08
|
"", # SBR08 — employment status code
|
||||||
member_id or "", # SBR09 — claim submitter's id
|
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
|
||||||
]
|
]
|
||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
@@ -300,7 +330,11 @@ def _build_clm(claim) -> str:
|
|||||||
clm05, # CLM05 — composite POS:qualifier:frequency_code
|
clm05, # CLM05 — composite POS:qualifier:frequency_code
|
||||||
claim.provider_signature or "Y", # CLM06
|
claim.provider_signature or "Y", # CLM06
|
||||||
claim.assignment or "Y", # CLM07
|
claim.assignment or "Y", # CLM07
|
||||||
"", # CLM08 — benefit assignment certification
|
# CLM08 — Benefits Assignment Certification. X12 837P requires
|
||||||
|
# this when CLM07 = "Y" (the common case for in-network
|
||||||
|
# professional claims). Default to "Y" when the source did
|
||||||
|
# not capture one — matches what 99% of HCPF files look like.
|
||||||
|
claim.benefits_assignment_certification or "Y", # CLM08
|
||||||
claim.release_of_info or "Y", # CLM09
|
claim.release_of_info or "Y", # CLM09
|
||||||
]
|
]
|
||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
@@ -325,21 +359,41 @@ def _build_lx(line_number: int) -> str:
|
|||||||
return _ELEM.join(["LX", str(line_number)]) + _SEG
|
return _ELEM.join(["LX", str(line_number)]) + _SEG
|
||||||
|
|
||||||
|
|
||||||
def _build_sv1(line) -> str:
|
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
|
||||||
"""SV1 segment — professional service line."""
|
"""SV1 segment — professional service line.
|
||||||
|
|
||||||
|
X12 005010X222A1 layout (837P):
|
||||||
|
SV1-01 composite procedure identifier
|
||||||
|
SV1-02 monetary amount (charge)
|
||||||
|
SV1-03 unit of basis measurement (UN, MJ, etc.) — ``line.unit_type``
|
||||||
|
SV1-04 service unit count — ``line.units``
|
||||||
|
SV1-05 place of service code — ``line.place_of_service``
|
||||||
|
SV1-06 **NOT USED** by this guide (must be empty)
|
||||||
|
SV1-07 diagnosis code pointer — ``dx_pointer`` (required when the
|
||||||
|
parent claim has an HI segment)
|
||||||
|
|
||||||
|
The parser captures the original SV1-07 pointer when present; the
|
||||||
|
serializer defaults it to ``"1"`` (pointing at the first HI
|
||||||
|
diagnosis) when the claim has diagnoses and no explicit pointer
|
||||||
|
was captured. When the claim has no HI segment we leave SV1-07
|
||||||
|
empty to match the spec.
|
||||||
|
"""
|
||||||
proc = line.procedure
|
proc = line.procedure
|
||||||
code = proc.code if proc else ""
|
code = proc.code if proc else ""
|
||||||
mods = proc.modifiers if proc else []
|
mods = proc.modifiers if proc else []
|
||||||
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
|
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
|
||||||
charge = f"{Decimal(line.charge or 0):.2f}"
|
charge = f"{Decimal(line.charge or 0):.2f}"
|
||||||
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
|
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
|
||||||
|
sv1_07 = dx_pointer or ""
|
||||||
parts = [
|
parts = [
|
||||||
"SV1",
|
"SV1",
|
||||||
composite,
|
composite, # SV1-01
|
||||||
charge,
|
charge, # SV1-02
|
||||||
line.unit_type or "UN",
|
line.unit_type or "UN", # SV1-03 — unit basis code
|
||||||
units,
|
units, # SV1-04
|
||||||
line.place_of_service or "",
|
line.place_of_service or "", # SV1-05
|
||||||
|
"", # SV1-06 — NOT USED in 837P
|
||||||
|
sv1_07, # SV1-07 — diagnosis pointer
|
||||||
]
|
]
|
||||||
return _ELEM.join(parts) + _SEG
|
return _ELEM.join(parts) + _SEG
|
||||||
|
|
||||||
@@ -357,15 +411,20 @@ def _build_dtp_472(service_date: date | None) -> str:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _build_submitter_block(sender_id: str, submitter_name: str | None,
|
def _build_submitter_block(
|
||||||
contact_name: str | None,
|
sender_id: str,
|
||||||
contact_phone: str | None) -> list[str]:
|
submitter_name: str | None,
|
||||||
|
contact_name: str | None,
|
||||||
|
contact_phone: str | None,
|
||||||
|
contact_email: str | None = None,
|
||||||
|
email_qual: str = "EM",
|
||||||
|
) -> list[str]:
|
||||||
out = [
|
out = [
|
||||||
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
|
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
|
||||||
]
|
]
|
||||||
per = _build_per(contact_name, contact_phone)
|
# PER is required by X12 (at least PER01). _build_per always emits
|
||||||
if per:
|
# the segment; the submitter block always has exactly one.
|
||||||
out.append(per)
|
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -395,11 +454,14 @@ def _build_billing_provider_block(provider) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
|
def _build_subscriber_block(
|
||||||
|
subscriber,
|
||||||
|
claim_filing_indicator_code: str | None,
|
||||||
|
) -> list[str]:
|
||||||
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
|
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
|
||||||
out = [
|
out = [
|
||||||
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
|
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
|
||||||
_build_sbr("18", subscriber.member_id, payer_name),
|
_build_sbr("18", claim_filing_indicator_code),
|
||||||
_build_nm1(
|
_build_nm1(
|
||||||
"IL", "IL",
|
"IL", "IL",
|
||||||
f"{subscriber.last_name} {subscriber.first_name}".strip(),
|
f"{subscriber.last_name} {subscriber.first_name}".strip(),
|
||||||
@@ -427,12 +489,24 @@ def _build_payer_block(payer) -> list[str]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def _build_service_lines_block(service_lines) -> list[str]:
|
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
|
||||||
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
|
"""Per line: LX / SV1 / DTP*472 / REF*6R.
|
||||||
|
|
||||||
|
``has_diagnoses`` is True when the parent claim emits an HI segment;
|
||||||
|
in that case SV1-07 is required by X12 and we default each line's
|
||||||
|
pointer to ``"1"`` (the first HI diagnosis) unless the source
|
||||||
|
captured a different pointer on the line itself.
|
||||||
|
"""
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for idx, line in enumerate(service_lines or [], start=1):
|
for idx, line in enumerate(service_lines or [], start=1):
|
||||||
out.append(_build_lx(idx))
|
out.append(_build_lx(idx))
|
||||||
out.append(_build_sv1(line))
|
# Prefer the line's captured pointer (parser pulled SV1-07
|
||||||
|
# when present). Fall back to "1" only when the claim has
|
||||||
|
# diagnoses and the source had no explicit pointer — the
|
||||||
|
# common single-diagnosis case.
|
||||||
|
line_pointer = getattr(line, "dx_pointer", None)
|
||||||
|
effective_pointer = line_pointer or ("1" if has_diagnoses else "")
|
||||||
|
out.append(_build_sv1(line, dx_pointer=effective_pointer))
|
||||||
dtp = _build_dtp_472(line.service_date)
|
dtp = _build_dtp_472(line.service_date)
|
||||||
if dtp:
|
if dtp:
|
||||||
out.append(dtp)
|
out.append(dtp)
|
||||||
@@ -450,7 +524,10 @@ def serialize_837(
|
|||||||
submitter_name: str | None = None,
|
submitter_name: str | None = None,
|
||||||
submitter_contact_name: str | None = None,
|
submitter_contact_name: str | None = None,
|
||||||
submitter_contact_phone: str | None = None,
|
submitter_contact_phone: str | None = None,
|
||||||
|
submitter_contact_email: str | None = None,
|
||||||
|
submitter_contact_email_qual: str = "EM",
|
||||||
receiver_name: str | None = None,
|
receiver_name: str | None = None,
|
||||||
|
claim_filing_indicator_code: str | None = None,
|
||||||
interchange_control_number: str = "000000001",
|
interchange_control_number: str = "000000001",
|
||||||
group_control_number: str = "1",
|
group_control_number: str = "1",
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -462,6 +539,16 @@ def serialize_837(
|
|||||||
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
|
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
|
||||||
the configured values.
|
the configured values.
|
||||||
|
|
||||||
|
The submitter block (Loop 1000A) always emits a PER segment per the
|
||||||
|
X12 spec — the canonical clearhouse config provides the contact
|
||||||
|
name and email so callers should pass them through.
|
||||||
|
|
||||||
|
The claim filing indicator (SBR09) is read from the per-payer
|
||||||
|
config (``PayerConfig837.sbr09_default``); callers should pass it
|
||||||
|
in. If not passed, SBR09 is left empty (which causes the
|
||||||
|
:func:`cyclone.parsers.validator._r202_sbr09_allowed` rule to skip
|
||||||
|
its check — degraded but not a hard error).
|
||||||
|
|
||||||
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
|
Editable fields (CLM, REF*G1, HI, service-line SV1, DTP*472) are
|
||||||
emitted from the canonical ``ClaimOutput`` fields, so post-parse
|
emitted from the canonical ``ClaimOutput`` fields, so post-parse
|
||||||
edits propagate to the output.
|
edits propagate to the output.
|
||||||
@@ -481,11 +568,13 @@ def serialize_837(
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
segments.extend(_build_submitter_block(
|
segments.extend(_build_submitter_block(
|
||||||
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
|
sender_id, submitter_name,
|
||||||
|
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
|
||||||
|
submitter_contact_email_qual,
|
||||||
))
|
))
|
||||||
segments.extend(_build_receiver_block(receiver_id, receiver_name))
|
segments.extend(_build_receiver_block(receiver_id, receiver_name))
|
||||||
segments.extend(_build_billing_provider_block(claim.billing_provider))
|
segments.extend(_build_billing_provider_block(claim.billing_provider))
|
||||||
segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
|
segments.extend(_build_subscriber_block(claim.subscriber, claim_filing_indicator_code))
|
||||||
segments.extend(_build_payer_block(claim.payer))
|
segments.extend(_build_payer_block(claim.payer))
|
||||||
|
|
||||||
# Claim-level editable segments.
|
# Claim-level editable segments.
|
||||||
@@ -497,7 +586,10 @@ def serialize_837(
|
|||||||
segments.append(_build_hi(claim.diagnoses))
|
segments.append(_build_hi(claim.diagnoses))
|
||||||
|
|
||||||
# Service lines (LX / SV1 / DTP*472 / REF*6R).
|
# Service lines (LX / SV1 / DTP*472 / REF*6R).
|
||||||
segments.extend(_build_service_lines_block(claim.service_lines))
|
segments.extend(_build_service_lines_block(
|
||||||
|
claim.service_lines,
|
||||||
|
has_diagnoses=bool(claim.diagnoses),
|
||||||
|
))
|
||||||
|
|
||||||
# SE segment count includes ST (line 3, 1-based) through SE itself
|
# SE segment count includes ST (line 3, 1-based) through SE itself
|
||||||
# — i.e. the entire ST..SE block inclusive.
|
# — i.e. the entire ST..SE block inclusive.
|
||||||
@@ -514,15 +606,23 @@ def serialize_837_for_resubmit(
|
|||||||
claim: ClaimOutput,
|
claim: ClaimOutput,
|
||||||
*,
|
*,
|
||||||
interchange_index: int,
|
interchange_index: int,
|
||||||
|
**kwargs,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
"""Like :func:`serialize_837` but assigns deterministic-but-unique
|
||||||
interchange + group control numbers for a bundle position.
|
interchange + group control numbers for a bundle position.
|
||||||
|
|
||||||
Interchange number = ``f"{interchange_index:09d}"``.
|
Interchange number = ``f"{interchange_index:09d}"``.
|
||||||
Group number = ``str(interchange_index)``.
|
Group number = ``str(interchange_index)``.
|
||||||
|
|
||||||
|
All other keyword arguments (sender_id, receiver_id, submitter_*
|
||||||
|
and receiver_* contact info, claim_filing_indicator_code) are
|
||||||
|
forwarded to :func:`serialize_837` unchanged so callers — like the
|
||||||
|
export and SFTP-submit endpoints — can pass through clearhouse +
|
||||||
|
payer config without copying the signature.
|
||||||
"""
|
"""
|
||||||
return serialize_837(
|
return serialize_837(
|
||||||
claim,
|
claim,
|
||||||
interchange_control_number=f"{interchange_index:09d}",
|
interchange_control_number=f"{interchange_index:09d}",
|
||||||
group_control_number=str(interchange_index),
|
group_control_number=str(interchange_index),
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
"""CLI subcommand: ``python -m cyclone seed``.
|
||||||
|
|
||||||
|
Populates the local DB with a deterministic batch of sample claims
|
||||||
|
plus matching activity events, so the Dashboard / Claims / Activity
|
||||||
|
Log pages have something to render in a fresh dev environment.
|
||||||
|
|
||||||
|
This is a dev-only convenience — the production DB never sees this
|
||||||
|
command (no ``[env: dev]`` gate, but it's never wired into any
|
||||||
|
container image). It writes rows with a recognizable batch id prefix
|
||||||
|
(``SEED-``) so ``--reset`` can clean them up without touching real
|
||||||
|
ingested data.
|
||||||
|
|
||||||
|
Why the data shape is hand-rolled here
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
The ``Claim`` ORM table stores its billing_provider / payer /
|
||||||
|
subscriber / service_lines payloads in a ``raw_json`` blob that the
|
||||||
|
read path (``store.to_ui_claim_from_orm``) parses back into the UI
|
||||||
|
shape. Mirroring the 837 parser's structure keeps the UI rendering
|
||||||
|
identical to a real ingestion — wire format parity, not shortcuts.
|
||||||
|
|
||||||
|
Activity rows are similar: ``payload_json`` carries the message
|
||||||
|
string the Dashboard's activity card shows.
|
||||||
|
|
||||||
|
Subcommands
|
||||||
|
-----------
|
||||||
|
|
||||||
|
``python -m cyclone seed`` — insert the default batch.
|
||||||
|
``python -m cyclone seed --count N`` — insert N claims (default 96).
|
||||||
|
``python -m cyclone seed --reset`` — wipe previously-seeded rows
|
||||||
|
first, then insert a fresh batch.
|
||||||
|
``python -m cyclone seed --status`` — print row counts without
|
||||||
|
inserting anything.
|
||||||
|
|
||||||
|
Exit codes
|
||||||
|
----------
|
||||||
|
|
||||||
|
* 0 — success (or already seeded and not asked to reset).
|
||||||
|
* 1 — DB error during insert.
|
||||||
|
* 2 — usage error (bad flag value).
|
||||||
|
|
||||||
|
The command is idempotent: re-running without ``--reset`` is a no-op
|
||||||
|
once a seed batch exists. This keeps ``cyclone``-driven boot scripts
|
||||||
|
safe to re-run.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance, SessionLocal
|
||||||
|
|
||||||
|
SEED_BATCH_PREFIX = "SEED-"
|
||||||
|
SEED_CLAIM_PREFIX = "CLM-S"
|
||||||
|
SEED_REMIT_PREFIX = "REM-S"
|
||||||
|
SEED_DEFAULT_COUNT = 96
|
||||||
|
SEED_ACTIVITY_LIMIT = 28
|
||||||
|
|
||||||
|
# Mirror src/data/sampleData.ts on the frontend so the Dashboard looks
|
||||||
|
# the same in dev mode as it did with the in-memory fixtures.
|
||||||
|
SAMPLE_PROVIDERS = [
|
||||||
|
{
|
||||||
|
"npi": "1730187395",
|
||||||
|
"name": "Cedar Park Family Medicine",
|
||||||
|
"tax_id": "47-3829104",
|
||||||
|
"address": "1401 Medical Pkwy",
|
||||||
|
"city": "Cedar Park",
|
||||||
|
"state": "TX",
|
||||||
|
"zip": "78613",
|
||||||
|
"phone": "(512) 555-0142",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npi": "1528471902",
|
||||||
|
"name": "Lakeside Orthopedics",
|
||||||
|
"tax_id": "83-1172654",
|
||||||
|
"address": "900 W Lake Dr",
|
||||||
|
"city": "Austin",
|
||||||
|
"state": "TX",
|
||||||
|
"zip": "78746",
|
||||||
|
"phone": "(512) 555-0188",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"npi": "1982036471",
|
||||||
|
"name": "Hill Country Pediatrics",
|
||||||
|
"tax_id": "74-5520183",
|
||||||
|
"address": "205 State Hwy 27",
|
||||||
|
"city": "Marble Falls",
|
||||||
|
"state": "TX",
|
||||||
|
"zip": "78654",
|
||||||
|
"phone": "(830) 555-0117",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_PAYERS = [
|
||||||
|
"Blue Cross Blue Shield",
|
||||||
|
"United Healthcare",
|
||||||
|
"Aetna",
|
||||||
|
"Cigna",
|
||||||
|
"Humana",
|
||||||
|
"Medicare",
|
||||||
|
"Medicaid TX",
|
||||||
|
]
|
||||||
|
|
||||||
|
SAMPLE_CPTS = ["99213", "99214", "99203", "93000", "85025", "80053", "73721", "20610"]
|
||||||
|
|
||||||
|
SAMPLE_FIRST_NAMES = [
|
||||||
|
"Avery", "Jordan", "Riley", "Casey", "Morgan",
|
||||||
|
"Quinn", "Reese", "Sasha", "Drew", "Hayden",
|
||||||
|
]
|
||||||
|
SAMPLE_LAST_NAMES = [
|
||||||
|
"Nguyen", "Patel", "Garcia", "Cohen", "Okafor",
|
||||||
|
"Martinez", "Hwang", "Brooks", "Singh", "Tanaka",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Frontend uses "accepted"/"pending" which the backend ClaimState
|
||||||
|
# enum doesn't carry. Map them to the closest real states so the
|
||||||
|
# Dashboard's filters (e.g. "status === 'submitted' || 'pending'")
|
||||||
|
# still find a match for in-flight work.
|
||||||
|
STATUS_WEIGHTS: list[tuple[ClaimState, int]] = [
|
||||||
|
(ClaimState.SUBMITTED, 1),
|
||||||
|
(ClaimState.RECEIVED, 1),
|
||||||
|
(ClaimState.DENIED, 1),
|
||||||
|
(ClaimState.PAID, 3),
|
||||||
|
(ClaimState.PAID, 3),
|
||||||
|
(ClaimState.PARTIAL, 1),
|
||||||
|
]
|
||||||
|
|
||||||
|
DENIAL_REASONS = [
|
||||||
|
"CO-97: Service included in another service",
|
||||||
|
"CO-16: Claim lacks information",
|
||||||
|
"CO-50: Non-covered service",
|
||||||
|
"PR-1: Deductible amount",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _now_utc() -> datetime:
|
||||||
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def _pick(rng: random.Random, items):
|
||||||
|
return items[rng.randint(0, len(items) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_seed_rows(
|
||||||
|
count: int, *, seed: int = 42,
|
||||||
|
) -> tuple[Batch, list[Claim], list[ActivityEvent], list[Remittance]]:
|
||||||
|
"""Build a deterministic Batch + N Claims + matching activity events.
|
||||||
|
|
||||||
|
PAID and PARTIAL claims get a paired ``Remittance`` row (with a
|
||||||
|
realistic ``total_paid`` derived from the billed amount) so the
|
||||||
|
Dashboard's "Received" KPI lights up; the wire shape mirrors what
|
||||||
|
``CycloneStore.iter_claims`` expects to find via
|
||||||
|
``Claim.matched_remittance_id`` → ``Remittance.total_paid``.
|
||||||
|
|
||||||
|
The seed is fixed so re-running produces identical data — keeps
|
||||||
|
screenshots and dev environments stable.
|
||||||
|
"""
|
||||||
|
rng = random.Random(seed)
|
||||||
|
now = _now_utc()
|
||||||
|
parsed_at = now - timedelta(minutes=5)
|
||||||
|
|
||||||
|
batch = Batch(
|
||||||
|
id=f"{SEED_BATCH_PREFIX}{now.strftime('%Y%m%d-%H%M%S')}",
|
||||||
|
kind="837",
|
||||||
|
input_filename="seed/sample-837.edi",
|
||||||
|
parsed_at=parsed_at,
|
||||||
|
totals_json={"claim_count": count},
|
||||||
|
validation_json={"errors": [], "warnings": []},
|
||||||
|
raw_result_json={},
|
||||||
|
)
|
||||||
|
|
||||||
|
claims: list[Claim] = []
|
||||||
|
activity: list[ActivityEvent] = []
|
||||||
|
remittances: list[Remittance] = []
|
||||||
|
seq = 10428 # Match the frontend fixture's id range
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
provider = _pick(rng, SAMPLE_PROVIDERS)
|
||||||
|
payer = _pick(rng, SAMPLE_PAYERS)
|
||||||
|
cpt = _pick(rng, SAMPLE_CPTS)
|
||||||
|
first = _pick(rng, SAMPLE_FIRST_NAMES)
|
||||||
|
last = _pick(rng, SAMPLE_LAST_NAMES)
|
||||||
|
state, _ = _pick(rng, STATUS_WEIGHTS)
|
||||||
|
|
||||||
|
days_back = rng.randint(0, 200)
|
||||||
|
submitted = now - timedelta(days=days_back, hours=rng.randint(0, 23))
|
||||||
|
|
||||||
|
billed = 80 + rng.randint(0, 1400)
|
||||||
|
if state == ClaimState.PAID:
|
||||||
|
received = int(billed * (0.6 + rng.random() * 0.4))
|
||||||
|
elif state == ClaimState.PARTIAL:
|
||||||
|
received = int(billed * (0.2 + rng.random() * 0.3))
|
||||||
|
elif state == ClaimState.DENIED:
|
||||||
|
received = 0
|
||||||
|
else:
|
||||||
|
received = 0
|
||||||
|
|
||||||
|
claim_id = f"{SEED_CLAIM_PREFIX}{(seq + i):05d}"
|
||||||
|
denial_reason = _pick(rng, DENIAL_REASONS) if state == ClaimState.DENIED else None
|
||||||
|
|
||||||
|
# Build a paired Remittance for any claim with received > 0. Status
|
||||||
|
# code 1 = "Primary payer forward" — the 835 CAS code that the
|
||||||
|
# remittance mapper turns into "received". Mirror the 835 parser's
|
||||||
|
# raw_json shape (a stripped provider/payer/service_lines block)
|
||||||
|
# so downstream debug views still render something useful.
|
||||||
|
matched_remit_id: str | None = None
|
||||||
|
if received > 0:
|
||||||
|
matched_remit_id = f"{SEED_REMIT_PREFIX}{(seq + i):05d}"
|
||||||
|
remittances.append(Remittance(
|
||||||
|
id=matched_remit_id,
|
||||||
|
batch_id=batch.id,
|
||||||
|
payer_claim_control_number=f"PCN-{(seq + i):05d}",
|
||||||
|
claim_id=claim_id,
|
||||||
|
status_code="1",
|
||||||
|
status_label="Primary payer forward",
|
||||||
|
total_charge=float(billed),
|
||||||
|
total_paid=float(received),
|
||||||
|
adjustment_amount=float(billed - received),
|
||||||
|
received_at=submitted + timedelta(days=rng.randint(2, 14)),
|
||||||
|
raw_json={
|
||||||
|
"payer": {"name": payer},
|
||||||
|
"provider": {
|
||||||
|
"npi": provider["npi"],
|
||||||
|
"name": provider["name"],
|
||||||
|
},
|
||||||
|
"service_lines": [
|
||||||
|
{
|
||||||
|
"procedure": {"code": cpt},
|
||||||
|
"charge_amount": float(billed),
|
||||||
|
"paid_amount": float(received),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
raw_json = {
|
||||||
|
"billing_provider": {
|
||||||
|
"npi": provider["npi"],
|
||||||
|
"name": provider["name"],
|
||||||
|
"tax_id": provider["tax_id"],
|
||||||
|
# 837 parser produces ``address`` as a structured dict so
|
||||||
|
# the claim-detail drawer can render line1/line2/city/state/zip.
|
||||||
|
# A flat string here crashes ``_address_to_ui`` with
|
||||||
|
# ``AttributeError: 'str' object has no attribute 'get'``.
|
||||||
|
"address": {
|
||||||
|
"line1": provider["address"],
|
||||||
|
"line2": None,
|
||||||
|
"city": provider["city"],
|
||||||
|
"state": provider["state"],
|
||||||
|
"zip": provider["zip"],
|
||||||
|
},
|
||||||
|
"phone": provider["phone"],
|
||||||
|
},
|
||||||
|
"payer": {"name": payer},
|
||||||
|
"subscriber": {"first_name": first, "last_name": last},
|
||||||
|
"service_lines": [
|
||||||
|
{
|
||||||
|
"procedure": {"code": cpt},
|
||||||
|
"charge_amount": float(billed),
|
||||||
|
"service_date": submitted.date().isoformat(),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
claims.append(Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch.id,
|
||||||
|
patient_control_number=claim_id.replace(SEED_CLAIM_PREFIX, "PCN-"),
|
||||||
|
service_date_from=submitted.date(),
|
||||||
|
service_date_to=submitted.date(),
|
||||||
|
charge_amount=billed,
|
||||||
|
provider_npi=provider["npi"],
|
||||||
|
payer_id=None,
|
||||||
|
state=state,
|
||||||
|
state_changed_at=submitted,
|
||||||
|
rejection_reason=denial_reason,
|
||||||
|
resubmit_count=0,
|
||||||
|
matched_remittance_id=matched_remit_id,
|
||||||
|
raw_json=raw_json,
|
||||||
|
))
|
||||||
|
|
||||||
|
# First SEED_ACTIVITY_LIMIT claims get an activity event. Mirrors
|
||||||
|
# the frontend buildActivity() shape (claim_paid / claim_denied /
|
||||||
|
# claim_accepted / claim_submitted) but maps frontend statuses
|
||||||
|
# onto the backend's wire enum.
|
||||||
|
if i < SEED_ACTIVITY_LIMIT:
|
||||||
|
if state == ClaimState.PAID:
|
||||||
|
kind = "claim_paid"
|
||||||
|
verb = "Paid"
|
||||||
|
elif state == ClaimState.DENIED:
|
||||||
|
kind = "claim_denied"
|
||||||
|
verb = "Denied"
|
||||||
|
elif state in (ClaimState.RECEIVED, ClaimState.PARTIAL):
|
||||||
|
kind = "claim_accepted"
|
||||||
|
verb = "Accepted"
|
||||||
|
else:
|
||||||
|
kind = "claim_submitted"
|
||||||
|
verb = "Submitted"
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"message": f"{verb} {claim_id} · {first} {last}",
|
||||||
|
"npi": provider["npi"],
|
||||||
|
"amount": float(billed),
|
||||||
|
}
|
||||||
|
activity.append(ActivityEvent(
|
||||||
|
ts=submitted,
|
||||||
|
kind=kind,
|
||||||
|
batch_id=batch.id,
|
||||||
|
claim_id=claim_id,
|
||||||
|
remittance_id=None,
|
||||||
|
payload_json=payload,
|
||||||
|
))
|
||||||
|
|
||||||
|
return batch, claims, activity, remittances
|
||||||
|
|
||||||
|
|
||||||
|
def _existing_seed_batch_ids(s) -> list[str]:
|
||||||
|
rows = s.query(Batch.id).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).all()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_seed_rows(s) -> int:
|
||||||
|
"""Delete every row that was inserted by the seed.
|
||||||
|
|
||||||
|
The seed writes rows whose ids begin with ``SEED-`` (batches),
|
||||||
|
``CLM-S`` (claims), or ``REM-S`` (remittances). Activity events
|
||||||
|
are joined to seeded batches when the batch is still around, but
|
||||||
|
we also clean up orphan activity rows (no FK from
|
||||||
|
``activity_events.claim_id`` to ``claims.id``) by ``claim_id``
|
||||||
|
prefix. Returns the number of batch rows deleted.
|
||||||
|
"""
|
||||||
|
# Activity first — its rows reference both claims and batches, so
|
||||||
|
# delete the events tied to seed claims first, then any that were
|
||||||
|
# only tied to a now-orphaned seed batch.
|
||||||
|
activity = s.query(ActivityEvent).filter(
|
||||||
|
ActivityEvent.claim_id.like(f"{SEED_CLAIM_PREFIX}%")
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
activity2 = s.query(ActivityEvent).filter(
|
||||||
|
ActivityEvent.batch_id.like(f"{SEED_BATCH_PREFIX}%")
|
||||||
|
).delete(synchronize_session=False)
|
||||||
|
# Remittances (FK to claims; cascade may not fire without FK pragma,
|
||||||
|
# so we delete them explicitly to clear the symmetric FK first).
|
||||||
|
remits = s.query(Remittance).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).delete(synchronize_session=False)
|
||||||
|
claims = s.query(Claim).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).delete(synchronize_session=False)
|
||||||
|
batches = s.query(Batch).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).delete(synchronize_session=False)
|
||||||
|
s.commit()
|
||||||
|
click.echo(f" Removed {batches} seeded batch(es), {claims} claim(s), "
|
||||||
|
f"{remits} remittance(s), {activity + activity2} activity event(s).")
|
||||||
|
return batches
|
||||||
|
|
||||||
|
|
||||||
|
def _insert(
|
||||||
|
batch: Batch,
|
||||||
|
claims: list[Claim],
|
||||||
|
activity: list[ActivityEvent],
|
||||||
|
remittances: list[Remittance],
|
||||||
|
) -> None:
|
||||||
|
with SessionLocal()() as s:
|
||||||
|
s.add(batch)
|
||||||
|
s.add_all(claims)
|
||||||
|
s.add_all(activity)
|
||||||
|
s.add_all(remittances)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _print_status(s) -> None:
|
||||||
|
from sqlalchemy import func
|
||||||
|
seed_batches = s.query(func.count(Batch.id)).filter(Batch.id.like(f"{SEED_BATCH_PREFIX}%")).scalar() or 0
|
||||||
|
seed_claims = s.query(func.count(Claim.id)).filter(Claim.id.like(f"{SEED_CLAIM_PREFIX}%")).scalar() or 0
|
||||||
|
seed_remits = s.query(func.count(Remittance.id)).filter(Remittance.id.like(f"{SEED_REMIT_PREFIX}%")).scalar() or 0
|
||||||
|
total_claims = s.query(func.count(Claim.id)).scalar() or 0
|
||||||
|
total_remits = s.query(func.count(Remittance.id)).scalar() or 0
|
||||||
|
total_activity = s.query(func.count(ActivityEvent.id)).scalar() or 0
|
||||||
|
click.echo(f" Seed batches: {seed_batches}")
|
||||||
|
click.echo(f" Seed claims: {seed_claims}")
|
||||||
|
click.echo(f" Seed remits: {seed_remits}")
|
||||||
|
click.echo(f" Total claims: {total_claims}")
|
||||||
|
click.echo(f" Total remits: {total_remits}")
|
||||||
|
click.echo(f" Total activity: {total_activity}")
|
||||||
|
|
||||||
|
|
||||||
|
@click.command("seed")
|
||||||
|
@click.option(
|
||||||
|
"--count",
|
||||||
|
default=SEED_DEFAULT_COUNT,
|
||||||
|
show_default=True,
|
||||||
|
type=click.IntRange(min=1, max=10_000),
|
||||||
|
help="Number of claims to insert in the new batch.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--reset",
|
||||||
|
is_flag=True,
|
||||||
|
help="Delete any existing seeded batch (and its claims/activity) before inserting.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--status",
|
||||||
|
"show_status",
|
||||||
|
is_flag=True,
|
||||||
|
help="Print current seeded row counts and exit.",
|
||||||
|
)
|
||||||
|
def seed_cli(count: int, reset: bool, show_status: bool) -> None:
|
||||||
|
"""Populate the local DB with a deterministic batch of sample claims."""
|
||||||
|
with SessionLocal()() as s:
|
||||||
|
if show_status:
|
||||||
|
_print_status(s)
|
||||||
|
return
|
||||||
|
|
||||||
|
existing = _existing_seed_batch_ids(s)
|
||||||
|
if existing and not reset:
|
||||||
|
click.echo(
|
||||||
|
f"Seed already present (batch ids: {', '.join(existing)}). "
|
||||||
|
f"Re-run with --reset to replace, or --status to inspect.",
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if reset and existing:
|
||||||
|
deleted = _delete_seed_rows(s)
|
||||||
|
click.echo(f" Removed {deleted} seeded batch(es).")
|
||||||
|
|
||||||
|
try:
|
||||||
|
batch, claims, activity, remittances = _build_seed_rows(count)
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(f"Failed to build seed rows: {exc}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
_insert(batch, claims, activity, remittances)
|
||||||
|
except Exception as exc:
|
||||||
|
click.echo(f"Failed to insert seed rows: {exc}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(f" Inserted batch {batch.id} with {len(claims)} claims, "
|
||||||
|
f"{len(remittances)} remittances, {len(activity)} activity events.")
|
||||||
|
_print_status(s)
|
||||||
@@ -431,6 +431,7 @@ def to_ui_claim_from_orm(
|
|||||||
*,
|
*,
|
||||||
batch_id: str,
|
batch_id: str,
|
||||||
parsed_at: datetime,
|
parsed_at: datetime,
|
||||||
|
received_total: float = 0.0,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Map an ORM ``Claim`` row to the UI's claim shape.
|
"""Map an ORM ``Claim`` row to the UI's claim shape.
|
||||||
|
|
||||||
@@ -476,7 +477,10 @@ def to_ui_claim_from_orm(
|
|||||||
"batchId": batch_id,
|
"batchId": batch_id,
|
||||||
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
|
# Parity with ``to_ui_claim``'s shape — the UI tolerates extra keys
|
||||||
# but expects these on freshly-loaded rows from /api/claims too.
|
# but expects these on freshly-loaded rows from /api/claims too.
|
||||||
"receivedAmount": 0.0,
|
# ``received_total`` comes from the matched Remittance row when one
|
||||||
|
# exists; callers that don't pre-compute it (write path, unmatched
|
||||||
|
# list) get the default of 0.0 — which matches the unmapped state.
|
||||||
|
"receivedAmount": float(received_total),
|
||||||
"denialReason": None,
|
"denialReason": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,6 +1072,9 @@ class CycloneStore:
|
|||||||
ui = to_ui_claim_from_orm(
|
ui = to_ui_claim_from_orm(
|
||||||
row, batch_id=row.batch_id or record.id,
|
row, batch_id=row.batch_id or record.id,
|
||||||
parsed_at=record.parsed_at,
|
parsed_at=record.parsed_at,
|
||||||
|
# Fresh ingest — no remittance has been paired yet,
|
||||||
|
# so ``Received`` is necessarily 0.
|
||||||
|
received_total=0.0,
|
||||||
)
|
)
|
||||||
self._sync_publish(event_bus, "claim_written", ui)
|
self._sync_publish(event_bus, "claim_written", ui)
|
||||||
for rid in remit_ids:
|
for rid in remit_ids:
|
||||||
@@ -1488,6 +1495,24 @@ class CycloneStore:
|
|||||||
q = q.filter(Claim.provider_npi == provider_npi)
|
q = q.filter(Claim.provider_npi == provider_npi)
|
||||||
|
|
||||||
rows = q.all()
|
rows = q.all()
|
||||||
|
# Bulk-load matched-remittance totals so the UI's "Received"
|
||||||
|
# KPI + per-claim received_amount reflect real paid amounts
|
||||||
|
# rather than always-0. One SQL roundtrip for the whole page
|
||||||
|
# rather than per-claim lookups.
|
||||||
|
matched_ids = [
|
||||||
|
r.matched_remittance_id
|
||||||
|
for r in rows
|
||||||
|
if r.matched_remittance_id
|
||||||
|
]
|
||||||
|
received_by_remit: dict[str, float] = {}
|
||||||
|
if matched_ids:
|
||||||
|
for rid, total_paid in (
|
||||||
|
s.query(Remittance.id, Remittance.total_paid)
|
||||||
|
.filter(Remittance.id.in_(matched_ids))
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
received_by_remit[rid] = float(total_paid or 0)
|
||||||
|
|
||||||
out: list[dict] = []
|
out: list[dict] = []
|
||||||
for r in rows:
|
for r in rows:
|
||||||
raw = r.raw_json or {}
|
raw = r.raw_json or {}
|
||||||
@@ -1516,7 +1541,9 @@ class CycloneStore:
|
|||||||
"payerName": payer_obj.get("name") or "",
|
"payerName": payer_obj.get("name") or "",
|
||||||
"cptCode": cpt,
|
"cptCode": cpt,
|
||||||
"billedAmount": float(r.charge_amount or 0),
|
"billedAmount": float(r.charge_amount or 0),
|
||||||
"receivedAmount": 0.0,
|
"receivedAmount": received_by_remit.get(
|
||||||
|
r.matched_remittance_id, 0.0
|
||||||
|
),
|
||||||
"status": r.state.value if hasattr(r.state, "value") else str(r.state),
|
"status": r.state.value if hasattr(r.state, "value") else str(r.state),
|
||||||
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
|
"state": r.state.value if hasattr(r.state, "value") else str(r.state),
|
||||||
"denialReason": None,
|
"denialReason": None,
|
||||||
@@ -1936,6 +1963,9 @@ class CycloneStore:
|
|||||||
result["claims"].append(
|
result["claims"].append(
|
||||||
to_ui_claim_from_orm(
|
to_ui_claim_from_orm(
|
||||||
r, batch_id=r.batch_id, parsed_at=parsed_at,
|
r, batch_id=r.batch_id, parsed_at=parsed_at,
|
||||||
|
# list_unmatched filters matched_remittance_id IS NULL,
|
||||||
|
# so every row has no remittance yet.
|
||||||
|
received_total=0.0,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -2063,6 +2093,7 @@ class CycloneStore:
|
|||||||
)
|
)
|
||||||
claim_dict = to_ui_claim_from_orm(
|
claim_dict = to_ui_claim_from_orm(
|
||||||
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
||||||
|
received_total=float(remit.total_paid or 0),
|
||||||
)
|
)
|
||||||
matched_at_iso = now.isoformat().replace("+00:00", "Z")
|
matched_at_iso = now.isoformat().replace("+00:00", "Z")
|
||||||
return {
|
return {
|
||||||
@@ -2119,9 +2150,11 @@ class CycloneStore:
|
|||||||
# rows exist. Shouldn't happen, but if it does, fall back
|
# rows exist. Shouldn't happen, but if it does, fall back
|
||||||
# to clearing the FK and starting fresh.
|
# to clearing the FK and starting fresh.
|
||||||
latest = None
|
latest = None
|
||||||
|
paired_remit = None
|
||||||
restored_state = ClaimState.SUBMITTED
|
restored_state = ClaimState.SUBMITTED
|
||||||
else:
|
else:
|
||||||
latest = matches[0]
|
latest = matches[0]
|
||||||
|
paired_remit = s.get(Remittance, latest.remittance_id)
|
||||||
restored_state = (
|
restored_state = (
|
||||||
latest.prior_claim_state
|
latest.prior_claim_state
|
||||||
if latest.prior_claim_state is not None
|
if latest.prior_claim_state is not None
|
||||||
@@ -2137,11 +2170,9 @@ class CycloneStore:
|
|||||||
# Clear the symmetric FK on the remittance so list_unmatched
|
# Clear the symmetric FK on the remittance so list_unmatched
|
||||||
# surfaces the pair again. The remittance may have been
|
# surfaces the pair again. The remittance may have been
|
||||||
# deleted between the match and this call — guard with a
|
# deleted between the match and this call — guard with a
|
||||||
# get() so we don't blow up on a stale FK.
|
# None check so we don't blow up on a stale FK.
|
||||||
if latest is not None:
|
if paired_remit is not None:
|
||||||
paired_remit = s.get(Remittance, latest.remittance_id)
|
paired_remit.claim_id = None
|
||||||
if paired_remit is not None:
|
|
||||||
paired_remit.claim_id = None
|
|
||||||
|
|
||||||
now = utcnow()
|
now = utcnow()
|
||||||
s.add(ActivityEvent(
|
s.add(ActivityEvent(
|
||||||
@@ -2161,8 +2192,19 @@ class CycloneStore:
|
|||||||
if claim.batch is not None
|
if claim.batch is not None
|
||||||
else now
|
else now
|
||||||
)
|
)
|
||||||
|
# ``paired_remit`` is the matched remittance we cleared in
|
||||||
|
# the unmatch; use its ``total_paid`` for the response shape
|
||||||
|
# so the UI sees what was paid before the unpair. May be
|
||||||
|
# ``None`` if the remittance was deleted since the match —
|
||||||
|
# default to 0.0 in that case.
|
||||||
|
received_total = (
|
||||||
|
float(paired_remit.total_paid or 0)
|
||||||
|
if paired_remit is not None
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
claim_dict = to_ui_claim_from_orm(
|
claim_dict = to_ui_claim_from_orm(
|
||||||
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
claim, batch_id=claim.batch_id, parsed_at=parsed_at,
|
||||||
|
received_total=received_total,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"claim": claim_dict,
|
"claim": claim_dict,
|
||||||
@@ -2277,7 +2319,7 @@ class CycloneStore:
|
|||||||
submitter_contact_email="tyler@dzinesco.com",
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
filename_block={
|
filename_block={
|
||||||
"tz": "America/Denver",
|
"tz": "America/Denver",
|
||||||
"outbound_template": "{tpid}-{tx}-{ts_mt}-1of1.{ext}",
|
"outbound_template": "tp{tpid}-{tx}-{ts_mt}-1of1.{ext}",
|
||||||
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
|
"inbound_template": "TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12",
|
||||||
},
|
},
|
||||||
sftp_block={
|
sftp_block={
|
||||||
|
|||||||
@@ -24,17 +24,34 @@ def _auto_init_db(tmp_path, monkeypatch):
|
|||||||
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
Also wires a fresh ``EventBus`` onto ``app.state`` because ``TestClient``
|
||||||
does not invoke the FastAPI lifespan handler unless used as a context
|
does not invoke the FastAPI lifespan handler unless used as a context
|
||||||
manager. The bus is reset between tests so subscribers don't leak.
|
manager. The bus is reset between tests so subscribers don't leak.
|
||||||
|
|
||||||
|
Auth gating is enabled at the router/endpoint level via the
|
||||||
|
``Depends(matrix_gate)`` wiring in ``cyclone.api``. The auth tests
|
||||||
|
(``test_auth_*``) explicitly flip ``AUTH_DISABLED = False`` and
|
||||||
|
authenticate via the public login route to exercise the real
|
||||||
|
auth path. Every other test — the original test suite predates
|
||||||
|
auth — gets ``AUTH_DISABLED = True`` here so the existing tests
|
||||||
|
keep working without each one having to login first.
|
||||||
"""
|
"""
|
||||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
from cyclone import db
|
from cyclone import db
|
||||||
from cyclone.api import app
|
|
||||||
from cyclone.pubsub import EventBus
|
from cyclone.pubsub import EventBus
|
||||||
|
from cyclone.auth import deps
|
||||||
|
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
db.init_db()
|
db.init_db()
|
||||||
app.state.event_bus = EventBus()
|
# Re-resolve `app` each fixture invocation because some tests
|
||||||
|
# (test_cors_extra_origins_via_env) call ``importlib.reload`` on
|
||||||
|
# ``cyclone.api`` to mutate the CORS allow-list. If we cached
|
||||||
|
# the app reference at conftest module load, we'd be setting
|
||||||
|
# ``event_bus`` on a stale instance that no test client is
|
||||||
|
# actually using.
|
||||||
|
from cyclone import api as _api_mod
|
||||||
|
_api_mod.app.state.event_bus = EventBus()
|
||||||
|
deps.AUTH_DISABLED = True
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
app.state.event_bus = None
|
deps.AUTH_DISABLED = False
|
||||||
|
_api_mod.app.state.event_bus = None
|
||||||
db._reset_for_tests()
|
db._reset_for_tests()
|
||||||
@@ -51,19 +51,21 @@ def test_migration_0002_creates_acks_table():
|
|||||||
|
|
||||||
def test_migration_latest_idempotent_on_fresh_db():
|
def test_migration_latest_idempotent_on_fresh_db():
|
||||||
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
"""Re-running the migration on the same DB must be a no-op (PRAGMA
|
||||||
user_version already at the latest version — currently 12 after
|
user_version already at the latest version — currently 15 after
|
||||||
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
0004-0006 line_reconciliation, 0005 ta1_acks, SP9's 0007
|
||||||
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
providers/payers/clearhouse, SP10's 0008 payer_rejected,
|
||||||
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
SP11's 0009 audit_log, SP14's 0010 payer_rejected_acknowledged,
|
||||||
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups)."""
|
SP16's 0011 processed_inbound_files, SP17's 0012 db_backups,
|
||||||
|
SP-auth's 0013 users + sessions, SP-audit's 0014 audit_log.user_id,
|
||||||
|
SP22's 0015 drop_claims_unique_constraint)."""
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v1 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v1 == 12
|
assert v1 == 15
|
||||||
# A second run should not raise and should not bump the version.
|
# A second run should not raise and should not bump the version.
|
||||||
db_migrate.run(db.engine())
|
db_migrate.run(db.engine())
|
||||||
with db.engine().begin() as c:
|
with db.engine().begin() as c:
|
||||||
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
v2 = c.exec_driver_sql("PRAGMA user_version").scalar() or 0
|
||||||
assert v2 == 12
|
assert v2 == 15
|
||||||
|
|
||||||
|
|
||||||
def test_add_ack_persists_row():
|
def test_add_ack_persists_row():
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ def test_parse_837_endpoint_streams_ndjson(client: TestClient):
|
|||||||
# Summary numbers match the JSON path.
|
# Summary numbers match the JSON path.
|
||||||
assert parsed[3]["data"]["total_claims"] == 2
|
assert parsed[3]["data"]["total_claims"] == 2
|
||||||
assert parsed[3]["data"]["passed"] == 2
|
assert parsed[3]["data"]["passed"] == 2
|
||||||
|
# The streaming summary carries the server-side batch id so the
|
||||||
|
# frontend can call /api/batches/{id}/export-837 without a separate
|
||||||
|
# GET /api/batches round-trip (regression: it used to be missing
|
||||||
|
# on the stream path, only present on the JSON path, which made the
|
||||||
|
# Upload page's Export button say "no batch to export").
|
||||||
|
assert parsed[3]["type"] == "summary"
|
||||||
|
assert isinstance(parsed[3]["data"]["batch_id"], str)
|
||||||
|
assert len(parsed[3]["data"]["batch_id"]) == 32 # uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
def test_parse_837_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||||
|
|||||||
@@ -82,6 +82,13 @@ def test_parse_835_endpoint_streams_ndjson(client: TestClient):
|
|||||||
# Summary numbers match the JSON path.
|
# Summary numbers match the JSON path.
|
||||||
assert parsed[7]["data"]["total_claims"] == 2
|
assert parsed[7]["data"]["total_claims"] == 2
|
||||||
assert parsed[7]["data"]["passed"] == 2
|
assert parsed[7]["data"]["passed"] == 2
|
||||||
|
# The streaming summary carries the server-side batch id so the
|
||||||
|
# frontend can call /api/batches/{id}/export-837 without a separate
|
||||||
|
# GET /api/batches round-trip (matches the parallel fix on
|
||||||
|
# /api/parse-837).
|
||||||
|
assert parsed[7]["type"] == "summary"
|
||||||
|
assert isinstance(parsed[7]["data"]["batch_id"], str)
|
||||||
|
assert len(parsed[7]["data"]["batch_id"]) == 32 # uuid4().hex
|
||||||
|
|
||||||
|
|
||||||
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
def test_parse_835_endpoint_streams_ndjson_without_raw_segments(client: TestClient):
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""Tests for POST /api/batches/{batch_id}/export-837.
|
||||||
|
|
||||||
|
Reads Claim.raw_json for each requested claim_id and returns a ZIP of
|
||||||
|
regenerated X12 837 files. No DB state mutation. Mirrors the
|
||||||
|
X-Cyclone-Serialize-Errors convention from /api/inbox/rejected/resubmit?download=true.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_batch(client: TestClient, filename: str = "claim.txt") -> dict:
|
||||||
|
"""Parse a single 837P file and return a dict with ``batch_id`` and
|
||||||
|
``claims``.
|
||||||
|
|
||||||
|
The parse-837 happy-path response does not currently surface
|
||||||
|
``batch_id`` at the top level (it's a server-side UUID, surfaced in
|
||||||
|
409 errors but not in 200s). We look it up from the DB — the Batch
|
||||||
|
row is already persisted by the time the parse endpoint returns.
|
||||||
|
"""
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Batch
|
||||||
|
|
||||||
|
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
|
||||||
|
r = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": (filename, io.BytesIO(fixture.encode()), "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
most_recent = s.query(Batch).order_by(Batch.parsed_at.desc()).first()
|
||||||
|
assert most_recent is not None, "expected at least one Batch row after parse"
|
||||||
|
batch_id = most_recent.id
|
||||||
|
return {"batch_id": batch_id, "claims": body["claims"]}
|
||||||
|
|
||||||
|
|
||||||
|
def _claim_ids_from_seed(seeded: dict) -> list[str]:
|
||||||
|
return [c["claim_id"] for c in seeded["claims"]]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_happy_path_returns_zip_with_one_x12_per_claim():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
claim_ids = _claim_ids_from_seed(seeded)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={"claim_ids": claim_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.headers["content-type"].startswith("application/zip")
|
||||||
|
assert "attachment" in r.headers["content-disposition"]
|
||||||
|
assert (
|
||||||
|
f"batch-{batch_id}-{len(claim_ids)}-claims.zip"
|
||||||
|
in r.headers["content-disposition"]
|
||||||
|
)
|
||||||
|
# Per-claim failures header absent on full success.
|
||||||
|
assert "x-cyclone-serialize-errors" not in {k.lower() for k in r.headers.keys()}
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
assert len(names) == len(claim_ids)
|
||||||
|
# Each entry follows the HCPF outbound naming template
|
||||||
|
# "{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12" (the seeded
|
||||||
|
# clearhouse TPID is "11525703"). Every name must be unique.
|
||||||
|
from cyclone.edi.filenames import is_outbound_filename
|
||||||
|
seen = set()
|
||||||
|
for name in names:
|
||||||
|
assert is_outbound_filename(name), (
|
||||||
|
f"expected HCPF outbound filename, got {name!r}"
|
||||||
|
)
|
||||||
|
assert name not in seen, f"duplicate filename: {name}"
|
||||||
|
seen.add(name)
|
||||||
|
with zf.open(name) as f:
|
||||||
|
first_line = f.readline().decode("ascii", errors="replace")
|
||||||
|
assert first_line.startswith("ISA*"), f"{name} didn't start with ISA"
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_x12_in_zip_uses_clearhouse_submit_and_payer_receiver():
|
||||||
|
"""Regression: regenerated 837s used to emit 'CYCLONE' / 'RECEIVER'
|
||||||
|
placeholders. The export endpoint must thread the clearhouse
|
||||||
|
submitter (dzinesco's TPID 11525703) and payer receiver
|
||||||
|
(COMEDASSISTPROG) through to the serializer."""
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
claim_ids = _claim_ids_from_seed(seeded)
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={"claim_ids": claim_ids},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
with zf.open(name) as f:
|
||||||
|
text = f.read().decode("ascii")
|
||||||
|
# Submitter block must use the clearhouse TPID + name, not
|
||||||
|
# the 'CYCLONE' placeholder.
|
||||||
|
assert "CYCLONE" not in text, f"{name} still emits CYCLONE placeholder"
|
||||||
|
assert "11525703" in text, f"{name} missing clearhouse TPID"
|
||||||
|
assert "Dzinesco" in text, f"{name} missing clearhouse name"
|
||||||
|
# Receiver block must use the CO_TXIX payer config
|
||||||
|
# (COMEDASSISTPROG), not the 'RECEIVER' placeholder.
|
||||||
|
assert "RECEIVER" not in text, f"{name} still emits RECEIVER placeholder"
|
||||||
|
assert "COMEDASSISTPROG" in text, f"{name} missing receiver id"
|
||||||
|
# Loop 1000A requires PER — must be present, not omitted.
|
||||||
|
assert "PER*IC*" in text, f"{name} missing required PER segment"
|
||||||
|
# SBR09 should be 'MC' (Medicaid claim filing indicator),
|
||||||
|
# not the member id.
|
||||||
|
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
|
||||||
|
sbr09 = sbr_line.rstrip("~").split("*")[9]
|
||||||
|
assert sbr09 == "MC", f"{name} SBR09 expected 'MC', got {sbr09!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_x12_in_zip_round_trips_through_parser():
|
||||||
|
"""Fidelity check: each .x12 must parse back to a ClaimOutput deep-equal
|
||||||
|
to the source row's raw_json (modulo recomputed validation)."""
|
||||||
|
from cyclone.parsers.parse_837 import parse
|
||||||
|
from cyclone.parsers.payer import PayerConfig
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
claim_ids = _claim_ids_from_seed(seeded)
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={"claim_ids": claim_ids},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
by_id = {c["claim_id"]: c for c in seeded["claims"]}
|
||||||
|
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
with zf.open(name) as f:
|
||||||
|
text = f.read().decode("ascii")
|
||||||
|
result = parse(text, PayerConfig(name="CO_MEDICAID"))
|
||||||
|
assert result.claims, f"{name} didn't parse back to any claims"
|
||||||
|
# Claim ids must round-trip (proves the serializer didn't drop
|
||||||
|
# or rewrite the id).
|
||||||
|
assert result.claims[0].claim.claim_id in by_id, (
|
||||||
|
f"{name} parsed to an unknown claim_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Partial-failure surface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_failure_one_claim_with_no_raw_json_returns_zip_and_errors_header():
|
||||||
|
"""If one claim has raw_json=None, the ZIP still returns for the others
|
||||||
|
and the failure is surfaced via X-Cyclone-Serialize-Errors."""
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.edi.filenames import is_outbound_filename
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
claim_ids = _claim_ids_from_seed(seeded)
|
||||||
|
assert len(claim_ids) >= 2, "fixture must produce at least 2 claims"
|
||||||
|
|
||||||
|
# Wipe raw_json on one claim to simulate a corrupted row.
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
from cyclone.db import Claim
|
||||||
|
target = s.get(Claim, claim_ids[0])
|
||||||
|
target.raw_json = None
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={"claim_ids": claim_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# Filename uses SUCCESS count, not requested count.
|
||||||
|
expected_success = len(claim_ids) - 1
|
||||||
|
assert (
|
||||||
|
f"batch-{batch_id}-{expected_success}-claims.zip"
|
||||||
|
in r.headers["content-disposition"]
|
||||||
|
)
|
||||||
|
err_header = r.headers.get("x-cyclone-serialize-errors")
|
||||||
|
assert err_header, "expected X-Cyclone-Serialize-Errors header"
|
||||||
|
errs = json.loads(err_header)
|
||||||
|
assert len(errs) == 1
|
||||||
|
assert errs[0]["claim_id"] == claim_ids[0]
|
||||||
|
assert "raw_json" in errs[0]["reason"].lower()
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
assert len(names) == expected_success
|
||||||
|
# Every entry follows HCPF outbound template; none is the
|
||||||
|
# 'claim-CLM-X.x12' placeholder from the old endpoint.
|
||||||
|
for name in names:
|
||||||
|
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_claims_fail_to_serialize_returns_422():
|
||||||
|
from cyclone import db
|
||||||
|
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
claim_ids = _claim_ids_from_seed(seeded)
|
||||||
|
assert len(claim_ids) >= 1
|
||||||
|
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
from cyclone.db import Claim
|
||||||
|
for cid in claim_ids:
|
||||||
|
c = s.get(Claim, cid)
|
||||||
|
c.raw_json = None
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={"claim_ids": claim_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
body = r.json()
|
||||||
|
# The 422 body surfaces the failure list so the UI can show details
|
||||||
|
# without parsing a header.
|
||||||
|
errs = body.get("detail", {}).get("serialize_errors") or body.get("serialize_errors")
|
||||||
|
assert errs is not None
|
||||||
|
assert len(errs) == len(claim_ids)
|
||||||
|
for entry in errs:
|
||||||
|
assert entry["claim_id"] in claim_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Error cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_claim_ids_returns_400():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={"claim_ids": []},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_claim_ids_key_returns_400():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_batch_id_returns_404():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
r = client.post(
|
||||||
|
"/api/batches/BATCH-DOES-NOT-EXIST/export-837",
|
||||||
|
json={"claim_ids": ["CLM-1"]},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert "BATCH-DOES-NOT-EXIST" in (body.get("detail") or "")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_claim_id_silently_omitted():
|
||||||
|
"""A claim_id that doesn't exist is omitted from the ZIP and surfaced
|
||||||
|
in the errors header — same convention as the resubmit endpoint."""
|
||||||
|
with TestClient(app) as client:
|
||||||
|
seeded = _seed_batch(client)
|
||||||
|
batch_id = seeded["batch_id"]
|
||||||
|
real_ids = _claim_ids_from_seed(seeded)
|
||||||
|
assert real_ids, "fixture must produce at least 1 claim"
|
||||||
|
|
||||||
|
requested = real_ids + ["CLM-GHOST"]
|
||||||
|
r = client.post(
|
||||||
|
f"/api/batches/{batch_id}/export-837",
|
||||||
|
json={"claim_ids": requested},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# Filename uses success count = len(real_ids), not len(requested).
|
||||||
|
assert (
|
||||||
|
f"batch-{batch_id}-{len(real_ids)}-claims.zip"
|
||||||
|
in r.headers["content-disposition"]
|
||||||
|
)
|
||||||
|
err_header = r.headers.get("x-cyclone-serialize-errors")
|
||||||
|
assert err_header
|
||||||
|
errs = json.loads(err_header)
|
||||||
|
assert any(e["claim_id"] == "CLM-GHOST" for e in errs)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
# HCPF outbound filenames, one per real claim. We don't pin the
|
||||||
|
# exact ts (it depends on the wall clock at test time), but the
|
||||||
|
# count and the HCPF template must match.
|
||||||
|
from cyclone.edi.filenames import is_outbound_filename
|
||||||
|
assert len(names) == len(real_ids)
|
||||||
|
for name in names:
|
||||||
|
assert is_outbound_filename(name), f"non-HCPF name: {name!r}"
|
||||||
@@ -65,6 +65,42 @@ def test_batches_returns_summary_after_parse(seeded_store):
|
|||||||
assert body["items"][0]["inputFilename"] == "x.txt"
|
assert body["items"][0]["inputFilename"] == "x.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_batches_includes_claim_ids_for_837p(seeded_store):
|
||||||
|
"""The Upload page's History tab renders a Re-export ZIP button per
|
||||||
|
row that fires ``POST /api/batches/{id}/export-837`` with the row's
|
||||||
|
claim ids. Carry those ids in the list response so the UI doesn't
|
||||||
|
need an extra round-trip per row to fetch them."""
|
||||||
|
batches = seeded_store.get("/api/batches", headers=JSON).json()["items"]
|
||||||
|
assert len(batches) == 1
|
||||||
|
item = batches[0]
|
||||||
|
assert item["kind"] == "837p"
|
||||||
|
# claimIds is a non-empty list of the same claim ids that live
|
||||||
|
# inside the parsed result — see `seeded_store` in conftest.py.
|
||||||
|
assert isinstance(item["claimIds"], list)
|
||||||
|
assert len(item["claimIds"]) == 2
|
||||||
|
full = seeded_store.get(f"/api/batches/{item['id']}").json()
|
||||||
|
assert sorted(item["claimIds"]) == sorted(c["claim_id"] for c in full["claims"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_batches_claim_ids_empty_for_835(client: TestClient, tmp_path):
|
||||||
|
"""835 has no re-export endpoint — the field is always ``[]`` so the
|
||||||
|
History tab can use it as the signal to hide the Re-export button."""
|
||||||
|
src = Path(__file__).parent / "fixtures" / "minimal_835.txt"
|
||||||
|
files = {"file": ("minimal_835.txt", src.read_bytes(), "text/plain")}
|
||||||
|
r = client.post(
|
||||||
|
"/api/parse-835",
|
||||||
|
params={"payer": "co_medicaid_835"},
|
||||||
|
files=files,
|
||||||
|
headers=JSON,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = client.get("/api/batches", headers=JSON).json()
|
||||||
|
assert body["total"] == 1
|
||||||
|
item = body["items"][0]
|
||||||
|
assert item["kind"] == "835"
|
||||||
|
assert item["claimIds"] == []
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# /api/batches/{id}
|
# /api/batches/{id}
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Tests that /api/parse-837 includes the persisted batch_id in its
|
||||||
|
JSON response, so the frontend can correlate its in-memory batch with
|
||||||
|
the server's row (and later call /api/batches/{id}/export-837).
|
||||||
|
|
||||||
|
The 409 (duplicate) path has always included batch_id; the 200 path
|
||||||
|
did not. This brings the 200 path in line so the frontend doesn't have
|
||||||
|
to query listBatches after every parse just to learn the server's id.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_837_happy_path_includes_batch_id_in_response():
|
||||||
|
with TestClient(app) as client:
|
||||||
|
fixture = Path("tests/fixtures/co_medicaid_837p.txt").read_text()
|
||||||
|
r = client.post(
|
||||||
|
"/api/parse-837",
|
||||||
|
files={"file": ("claim.txt", io.BytesIO(fixture.encode()), "text/plain")},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
body = r.json()
|
||||||
|
assert "batch_id" in body, "parse-837 response must include batch_id so the frontend can call /api/batches/{id}/export-837"
|
||||||
|
# Sanity: the batch_id should be a non-empty string (UUID-shaped).
|
||||||
|
assert isinstance(body["batch_id"], str) and body["batch_id"]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Audit log entries record the acting user_id.
|
||||||
|
|
||||||
|
Schema: the ``audit_log`` table has a ``user_id INTEGER`` column added
|
||||||
|
by migration 0011; the SQLAlchemy ``AuditLog`` model itself does not
|
||||||
|
declare it yet, so ``append_event`` cannot pass it through. These tests
|
||||||
|
fail before the model + dataclass are updated, and pass after.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.audit_log import AuditEvent, append_event
|
||||||
|
from cyclone.db import AuditLog, SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(AuditLog))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_event_accepts_user_id_kwarg():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
append_event(
|
||||||
|
db,
|
||||||
|
AuditEvent(
|
||||||
|
event_type="test",
|
||||||
|
entity_type="x",
|
||||||
|
entity_id="y",
|
||||||
|
user_id=42,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
row = db.execute(select(AuditLog)).scalars().one()
|
||||||
|
assert row.user_id == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_event_without_user_id_is_null():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
append_event(
|
||||||
|
db,
|
||||||
|
AuditEvent(
|
||||||
|
event_type="test",
|
||||||
|
entity_type="x",
|
||||||
|
entity_id="y",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
row = db.execute(select(AuditLog)).scalars().one()
|
||||||
|
assert row.user_id is None
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Admin-only user management endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-admin tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def admin_client():
|
||||||
|
client = TestClient(app)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="root", password="rootpassword1", role="admin")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "root", "password": "rootpassword1"},
|
||||||
|
)
|
||||||
|
assert login.status_code == 200
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_client():
|
||||||
|
client = TestClient(app)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="plain", password="plainpassword1", role="user")
|
||||||
|
login = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "plain", "password": "plainpassword1"},
|
||||||
|
)
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_as_admin(admin_client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="alice", password="hunter2hunter2", role="user")
|
||||||
|
resp = admin_client.get("/api/admin/users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
usernames = {u["username"] for u in body}
|
||||||
|
assert {"root", "alice"} <= usernames
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_as_nonadmin_returns_403(user_client):
|
||||||
|
resp = user_client.get("/api/admin/users")
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_as_admin(admin_client):
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"username": "newbie", "password": "newbiepassword1", "role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.json()["username"] == "newbie"
|
||||||
|
assert resp.json()["role"] == "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_invalid_role(admin_client):
|
||||||
|
resp = admin_client.post(
|
||||||
|
"/api/admin/users",
|
||||||
|
json={"username": "badrole", "password": "hunter2hunter2", "role": "owner"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_patch_user_role_and_password(admin_client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="subject", password="hunter2hunter2", role="viewer")
|
||||||
|
resp = admin_client.patch(
|
||||||
|
f"/api/admin/users/{u.id}",
|
||||||
|
json={"role": "user", "password": "newpassword1"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["role"] == "user"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_demote_self(admin_client):
|
||||||
|
me = admin_client.get("/api/auth/me").json()
|
||||||
|
resp = admin_client.patch(
|
||||||
|
f"/api/admin/users/{me['id']}",
|
||||||
|
json={"role": "viewer"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Bootstrap admin user on backend startup."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.auth import bootstrap, users
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# Reset the bootstrap-side AUTH_DISABLED flag so tests don't leak state
|
||||||
|
# into each other. The conftest fixture flips this back to True at the
|
||||||
|
# start of every test, but bootstrap.run() mutates this module-level
|
||||||
|
# value, so we restore it here too.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
# conftest sets CYCLONE_AUTH_DISABLED=1 at module import. Drop it
|
||||||
|
# by default so tests exercise the real bootstrap path; the
|
||||||
|
# AUTH_DISABLED-specific test re-sets it explicitly.
|
||||||
|
monkeypatch.delenv("CYCLONE_AUTH_DISABLED", raising=False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_creates_admin_when_users_empty_and_env_set(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "firstadmin")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "firstadminpw1")
|
||||||
|
bootstrap.run()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = db.execute(select(User).where(User.username == "firstadmin")).scalar_one()
|
||||||
|
assert u.role == Role.ADMIN.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_noop_when_users_exist(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "ignored")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "ignoredignored1")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="existing", password="hunter2hunter2", role="admin")
|
||||||
|
bootstrap.run()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
all_users = db.execute(select(User)).scalars().all()
|
||||||
|
usernames = {u.username for u in all_users}
|
||||||
|
assert usernames == {"existing"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_refuses_to_run_without_env(monkeypatch):
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
with pytest.raises(RuntimeError, match="CYCLONE_ADMIN_USERNAME"):
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_rejects_short_password(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "weak")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "short")
|
||||||
|
with pytest.raises(RuntimeError, match="12 characters"):
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_skips_when_auth_disabled(monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_AUTH_DISABLED", "1")
|
||||||
|
# Even without env vars, bootstrap should NOT raise when AUTH_DISABLED=1.
|
||||||
|
bootstrap.run()
|
||||||
|
# And it must flip the deps flag so the API skips auth checks.
|
||||||
|
from cyclone.auth import deps as _deps
|
||||||
|
assert _deps.AUTH_DISABLED is True
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Auth bootstrap reads CYCLONE_ADMIN_*_FILE env vars when set.
|
||||||
|
|
||||||
|
Mirrors the Docker-secret posture in ``docker-compose.yml``: secrets
|
||||||
|
mounted at ``/run/secrets/<name>`` with the ``*_FILE`` env var pointing
|
||||||
|
at the path. The bootstrap should prefer the file over the bare env var
|
||||||
|
when both are present (file = Docker secret wins).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.auth import bootstrap, users
|
||||||
|
from cyclone.auth.permissions import Role
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tmp_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{db_path}")
|
||||||
|
db.init_db()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_secrets(tmp_path: Path, *, username: str, password: str) -> tuple[Path, Path]:
|
||||||
|
user_file = tmp_path / "admin_username"
|
||||||
|
pw_file = tmp_path / "admin_pw"
|
||||||
|
user_file.write_text(f"{username}\n")
|
||||||
|
pw_file.write_text(f"{password}\n")
|
||||||
|
return user_file, pw_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_creates_admin_from_file_env_vars(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
user_file, pw_file = _write_secrets(
|
||||||
|
tmp_path, username="deploy-admin", password="super-secret-password-123"
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
user = users.get_by_username(session, "deploy-admin")
|
||||||
|
assert user is not None
|
||||||
|
assert user.role == Role.ADMIN.value
|
||||||
|
assert users.verify_password("super-secret-password-123", user.password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_env_var_overrides_plain_env_var(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""When both _FILE and bare env vars are set, _FILE wins."""
|
||||||
|
user_file, pw_file = _write_secrets(
|
||||||
|
tmp_path, username="file-user", password="file-password-12345"
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
file_user = users.get_by_username(session, "file-user")
|
||||||
|
env_user = users.get_by_username(session, "env-user")
|
||||||
|
assert file_user is not None
|
||||||
|
assert env_user is None, "bare env var should be ignored when _FILE is set"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_falls_back_to_plain_env_var(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME_FILE", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD_FILE", raising=False)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME", "env-user")
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD", "env-password-12345")
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
user = users.get_by_username(session, "env-user")
|
||||||
|
assert user is not None
|
||||||
|
assert user.role == Role.ADMIN.value
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_strips_trailing_whitespace_from_file(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Secret files often have a trailing newline from `printf`/`echo`.
|
||||||
|
The bootstrap must strip it so bcrypt verify doesn't see whitespace."""
|
||||||
|
user_file = tmp_path / "admin_username"
|
||||||
|
pw_file = tmp_path / "admin_pw"
|
||||||
|
user_file.write_text("deploy-admin\n\n")
|
||||||
|
pw_file.write_text("super-secret-password-123\n")
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_USERNAME_FILE", str(user_file))
|
||||||
|
monkeypatch.setenv("CYCLONE_ADMIN_PASSWORD_FILE", str(pw_file))
|
||||||
|
|
||||||
|
bootstrap.run()
|
||||||
|
|
||||||
|
with db.SessionLocal()() as session:
|
||||||
|
user = users.get_by_username(session, "deploy-admin")
|
||||||
|
assert user is not None
|
||||||
|
# Should verify cleanly with no trailing whitespace.
|
||||||
|
assert users.verify_password("super-secret-password-123", user.password_hash)
|
||||||
|
assert not users.verify_password(
|
||||||
|
"super-secret-password-123\n", user.password_hash
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bootstrap_raises_when_file_path_missing(
|
||||||
|
tmp_path: Path, tmp_db: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_USERNAME", raising=False)
|
||||||
|
monkeypatch.delenv("CYCLONE_ADMIN_PASSWORD", raising=False)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"CYCLONE_ADMIN_USERNAME_FILE", str(tmp_path / "does-not-exist")
|
||||||
|
)
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"CYCLONE_ADMIN_PASSWORD_FILE", str(tmp_path / "also-missing")
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="failed to read"):
|
||||||
|
bootstrap.run()
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Login rate limit: 5 fails / 5 min per username."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import rate_limit, users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-login-rate-limit tests need the real
|
||||||
|
# auth path exercised (the rate limiter sits in front of /login),
|
||||||
|
# so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
# Reset the in-memory rate-limit counter so a previous test's 5
|
||||||
|
# failures don't poison this test. The plan recipe calls this out.
|
||||||
|
rate_limit.reset("victim")
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
rate_limit.reset("victim")
|
||||||
|
|
||||||
|
|
||||||
|
def test_5_fails_then_429():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="victim", password="hunter2hunter2", role="user")
|
||||||
|
client = TestClient(app)
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# 6th should be 429.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert "Retry-After" in r.headers
|
||||||
|
# And the module-level helper confirms we're now over the threshold.
|
||||||
|
assert rate_limit.check("victim") > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_successful_login_resets_counter():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="victim", password="hunter2hunter2", role="user")
|
||||||
|
client = TestClient(app)
|
||||||
|
for _ in range(4):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# Successful login — should reset the counter.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
# Counter reset — 5 more fails allowed.
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
# 6th is now throttled.
|
||||||
|
r = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "victim", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""API tests for /api/auth/login, /api/auth/logout, /api/auth/me."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-route tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def seeded_admin(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="admin", password="adminpassword1", role="admin")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_success_returns_user_and_cookie(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="alice", password="hunter2hunter2", role="user")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "alice", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["username"] == "alice"
|
||||||
|
assert body["role"] == "user"
|
||||||
|
assert "password_hash" not in body
|
||||||
|
assert "cyclone_session" in resp.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_bad_password_returns_401(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="bob", password="hunter2hunter2", role="user")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "bob", "password": "WRONG"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["error"] == "invalid_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_unknown_user_returns_401(client):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "ghost", "password": "whatever"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
assert resp.json()["error"] == "invalid_credentials"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_disabled_user_returns_403(client):
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="carol", password="hunter2hunter2", role="user")
|
||||||
|
users.disable(db, u.id)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "carol", "password": "hunter2hunter2"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
assert resp.json()["error"] == "account_disabled"
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_clears_session_and_cookie(seeded_admin):
|
||||||
|
login = seeded_admin.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "adminpassword1"},
|
||||||
|
)
|
||||||
|
cookie = login.cookies.get("cyclone_session")
|
||||||
|
assert cookie
|
||||||
|
resp = seeded_admin.post("/api/auth/logout", cookies={"cyclone_session": cookie})
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_returns_current_user(seeded_admin):
|
||||||
|
login = seeded_admin.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "adminpassword1"},
|
||||||
|
)
|
||||||
|
cookie = login.cookies.get("cyclone_session")
|
||||||
|
resp = seeded_admin.get("/api/auth/me", cookies={"cyclone_session": cookie})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_without_cookie_returns_401(seeded_admin):
|
||||||
|
resp = seeded_admin.get("/api/auth/me")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Unit tests for cyclone.auth.sessions — Session create/validate/expire/touch."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.auth import sessions, users
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-sessions tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
return users.create(db, username="sessuser", password="hunter2hunter2", role="user")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_session_returns_id_and_session():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, sess = sessions.create(db, user_id=user.id)
|
||||||
|
assert len(sid) >= 32
|
||||||
|
assert sess.user_id == user.id
|
||||||
|
assert sess.expires_at > datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_session_for_active():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
got = sessions.get_valid(db, sid)
|
||||||
|
assert got is not None
|
||||||
|
assert got.user_id == user.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_none_for_missing():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, "does-not-exist") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_valid_returns_none_for_expired():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sess = sessions.get_valid(db, sid)
|
||||||
|
sess.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1)
|
||||||
|
db.commit()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_removes_session():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, _ = sessions.create(db, user_id=user.id)
|
||||||
|
sessions.delete(db, sid)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert sessions.get_valid(db, sid) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_touch_extends_expiry():
|
||||||
|
user = _make_user()
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
sid, sess = sessions.create(db, user_id=user.id)
|
||||||
|
original_expiry = sess.expires_at
|
||||||
|
sessions.touch(db, sid)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = sessions.get_valid(db, sid)
|
||||||
|
assert refreshed.expires_at >= original_expiry
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Unit tests for cyclone.auth.users — User CRUD + bcrypt hashing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_users(monkeypatch):
|
||||||
|
# conftest sets AUTH_DISABLED=True so pre-auth tests keep passing
|
||||||
|
# without a login. The auth-users tests need the real auth path
|
||||||
|
# exercised, so flip it back off here.
|
||||||
|
monkeypatch.setattr("cyclone.auth.deps.AUTH_DISABLED", False)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_returns_bcrypt():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert h.startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_password_produces_unique_salts():
|
||||||
|
a = users.hash_password("same-password")
|
||||||
|
b = users.hash_password("same-password")
|
||||||
|
assert a != b
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_correct():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert users.verify_password("hunter2hunter2", h) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_password_incorrect():
|
||||||
|
h = users.hash_password("hunter2hunter2")
|
||||||
|
assert users.verify_password("WRONG", h) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_persists_with_hashed_password():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="alice", password="hunter2hunter2", role="admin")
|
||||||
|
assert u.id is not None
|
||||||
|
assert u.username == "alice"
|
||||||
|
assert u.role == "admin"
|
||||||
|
assert u.disabled_at is None
|
||||||
|
assert u.password_hash != "hunter2hunter2"
|
||||||
|
assert u.password_hash.startswith("$2")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_rejects_duplicate_username():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="bob", password="hunter2hunter2", role="user")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
users.create(db, username="bob", password="anotherone", role="user")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_username_returns_user():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="carol", password="hunter2hunter2", role="viewer")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "carol")
|
||||||
|
assert u is not None
|
||||||
|
assert u.username == "carol"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_by_username_returns_none_for_missing():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
assert users.get_by_username(db, "ghost") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_user_sets_disabled_at():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="dave", password="hunter2hunter2", role="user")
|
||||||
|
users.disable(db, u.id)
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = users.get_by_username(db, "dave")
|
||||||
|
assert refreshed.disabled_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_public_shape_omits_password_hash():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="eve", password="hunter2hunter2", role="admin")
|
||||||
|
shape = users.to_public(u)
|
||||||
|
assert "password_hash" not in shape
|
||||||
|
assert shape["username"] == "eve"
|
||||||
|
assert shape["role"] == "admin"
|
||||||
|
assert "id" in shape and "createdAt" in shape
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_password_actually_rehashes_and_verifies():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.create(db, username="frank", password="hunter2hunter2", role="user")
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.update_password(db, u.id, "newpassword1")
|
||||||
|
# Old password no longer verifies.
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
refreshed = users.get_by_username(db, "frank")
|
||||||
|
assert not users.verify_password("hunter2hunter2", refreshed.password_hash)
|
||||||
|
assert users.verify_password("newpassword1", refreshed.password_hash)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""CLI: python -m cyclone users {create,list,disable,reset-password,set-role}.
|
||||||
|
|
||||||
|
Uses Click's CliRunner (matches the existing parse-837/parse-835 CLI tests).
|
||||||
|
The full CLI group lives in ``cyclone.cli.main`` — we exercise the
|
||||||
|
``users`` subgroup through the top-level ``main`` so the wiring is real.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from cyclone.auth import users
|
||||||
|
from cyclone.cli import main as cli_main
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "create", "cli-user",
|
||||||
|
"--role", "viewer",
|
||||||
|
"--password", "clipassword1",
|
||||||
|
],
|
||||||
|
input="", # don't prompt
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "cli-user")
|
||||||
|
assert u is not None
|
||||||
|
assert u.role == "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="listed", password="hunter2hunter2", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "list"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "listed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_user_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="todie", password="hunter2hunter2", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "disable", "todie"])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "todie")
|
||||||
|
assert u.disabled_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_password_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="pwchange", password="oldpassword1", role="user")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "reset-password", "pwchange",
|
||||||
|
"--password", "newpassword1",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "pwchange")
|
||||||
|
assert users.verify_password("newpassword1", u.password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_role_via_cli():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
users.create(db, username="promote", password="hunter2hunter2", role="viewer")
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
["users", "set-role", "promote", "--role", "admin"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
u = users.get_by_username(db, "promote")
|
||||||
|
assert u.role == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_short_password():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli_main,
|
||||||
|
[
|
||||||
|
"users", "create", "weak",
|
||||||
|
"--role", "viewer",
|
||||||
|
"--password", "short",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
# Click surfaces validation failures with a non-zero exit code.
|
||||||
|
assert result.exit_code != 0, result.output
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
rows = db.execute(select(User).where(User.username == "weak")).scalars().all()
|
||||||
|
assert rows == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_disable_unknown_user_exits_nonzero():
|
||||||
|
from click.testing import CliRunner
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli_main, ["users", "disable", "ghost"])
|
||||||
|
assert result.exit_code != 0, result.output
|
||||||
@@ -112,4 +112,70 @@ def test_run_ignores_non_sql_files(
|
|||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='should_not_exist'"
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='should_not_exist'"
|
||||||
).all()
|
).all()
|
||||||
assert len(rows) == 0
|
assert len(rows) == 0
|
||||||
assert _user_version(engine) == 1
|
assert _user_version(engine) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_migration_latest_idempotent_on_fresh_db(tmp_path: Path) -> None:
|
||||||
|
"""All migrations up to the current head run cleanly on a fresh DB,
|
||||||
|
and a second run is a no-op (no version bump). SP22 bumped the
|
||||||
|
expected head from 14 to 15 with the new UNIQUE-drop migration.
|
||||||
|
"""
|
||||||
|
engine = _fresh_engine(tmp_path)
|
||||||
|
db_migrate.run(engine)
|
||||||
|
v_after_first = _user_version(engine)
|
||||||
|
assert v_after_first == 15, f"expected head=15, got {v_after_first}"
|
||||||
|
|
||||||
|
db_migrate.run(engine)
|
||||||
|
assert _user_version(engine) == 15, "second run should not bump version"
|
||||||
|
|
||||||
|
|
||||||
|
def test_drop_claims_unique_constraint_migration(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""SP22: migration 0015 recreates the `claims` table without the inline
|
||||||
|
`UNIQUE(batch_id, patient_control_number)` constraint, so two claims in
|
||||||
|
one batch can share a patient_control_number (real 837P multi-claim
|
||||||
|
subscriber loops do this).
|
||||||
|
|
||||||
|
Discovery (2026-06-23): the inline UNIQUE does not exist in the current
|
||||||
|
production DB or in main's fresh-DB schema, so this migration is a
|
||||||
|
defensive no-op against the current state. The test still proves
|
||||||
|
migration correctness: (a) all migrations up to v15 run cleanly,
|
||||||
|
(b) two rows with the same (batch_id, patient_control_number) can
|
||||||
|
be inserted after the migration (proving no UNIQUE was re-introduced
|
||||||
|
by the table recreation).
|
||||||
|
"""
|
||||||
|
# Real migrations dir so the test exercises the actual 0015 file.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
db_migrate,
|
||||||
|
"MIGRATIONS_DIR",
|
||||||
|
Path(__file__).parent.parent / "src" / "cyclone" / "migrations",
|
||||||
|
)
|
||||||
|
engine = _fresh_engine(tmp_path)
|
||||||
|
|
||||||
|
db_migrate.run(engine)
|
||||||
|
assert _user_version(engine) == 15, f"expected head=15, got {_user_version(engine)}"
|
||||||
|
|
||||||
|
# Two claims in one batch with the same patient_control_number
|
||||||
|
# must be insertable. If 0015's table recreation re-introduced a
|
||||||
|
# UNIQUE(batch_id, patient_control_number), this would raise
|
||||||
|
# IntegrityError. (The test also implicitly asserts the FK from
|
||||||
|
# claims to batches still works after the recreation.)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO batches (id, kind, input_filename, parsed_at) "
|
||||||
|
"VALUES ('B1', '837p', 'test.txt', '2026-01-01 00:00:00')"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO claims (id, batch_id, patient_control_number, charge_amount) "
|
||||||
|
"VALUES ('CLM-1', 'B1', 'SAME-PCN', 100)"
|
||||||
|
)
|
||||||
|
conn.exec_driver_sql(
|
||||||
|
"INSERT INTO claims (id, batch_id, patient_control_number, charge_amount) "
|
||||||
|
"VALUES ('CLM-2', 'B1', 'SAME-PCN', 200)"
|
||||||
|
)
|
||||||
|
rows = conn.exec_driver_sql(
|
||||||
|
"SELECT id, charge_amount FROM claims "
|
||||||
|
"WHERE patient_control_number='SAME-PCN' ORDER BY id"
|
||||||
|
).all()
|
||||||
|
assert [r[0] for r in rows] == ["CLM-1", "CLM-2"]
|
||||||
|
assert [float(r[1]) for r in rows] == [100.0, 200.0]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Dockerfile + compose-config smoke tests for SP23.
|
||||||
|
|
||||||
|
These tests do NOT require a running Docker daemon (the compose-up test
|
||||||
|
is gated on ``DOCKER_TESTS=1`` so it can be skipped on bare CI without
|
||||||
|
Docker). They validate that:
|
||||||
|
|
||||||
|
* ``docker-compose.yml`` at the repo root is syntactically valid and that
|
||||||
|
the shape we expect (services, secrets, volumes, networks) is present.
|
||||||
|
* Both Dockerfiles parse with ``docker build --check`` if Docker is on PATH.
|
||||||
|
* The named-volume mount paths match what ``cyclone.db`` + the BackupService
|
||||||
|
expect at runtime.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
COMPOSE_FILE = REPO_ROOT / "docker-compose.yml"
|
||||||
|
BACKEND_DOCKERFILE = REPO_ROOT / "backend" / "Dockerfile"
|
||||||
|
FRONTEND_DOCKERFILE = REPO_ROOT / "Dockerfile.frontend"
|
||||||
|
FRONTEND_NGINX_CONF = REPO_ROOT / "nginx.conf"
|
||||||
|
|
||||||
|
|
||||||
|
def _has_docker() -> bool:
|
||||||
|
return shutil.which("docker") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _has_docker_compose() -> bool:
|
||||||
|
if shutil.which("docker") is None:
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
subprocess.run(
|
||||||
|
["docker", "compose", "version"],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
).returncode
|
||||||
|
== 0
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _volume_source(v) -> str:
|
||||||
|
"""Normalize a compose volume entry to the source name (str form or 'source' key)."""
|
||||||
|
if isinstance(v, str):
|
||||||
|
# Long form: "named_volume:/container/path" — split on ':'.
|
||||||
|
return v.split(":", 1)[0]
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return v.get("source", "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_file_exists():
|
||||||
|
assert COMPOSE_FILE.exists(), f"missing {COMPOSE_FILE}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_config_validates():
|
||||||
|
"""``docker compose config`` should exit 0 with no stderr."""
|
||||||
|
if not _has_docker_compose():
|
||||||
|
pytest.skip("docker compose not on PATH")
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"-f",
|
||||||
|
str(COMPOSE_FILE),
|
||||||
|
"config",
|
||||||
|
"--quiet",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"compose config failed: stderr={result.stderr!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_declares_required_services():
|
||||||
|
compose = yaml.safe_load(COMPOSE_FILE.read_text())
|
||||||
|
services = compose.get("services", {})
|
||||||
|
assert "backend" in services, "compose must declare a 'backend' service"
|
||||||
|
assert "frontend" in services, "compose must declare a 'frontend' service"
|
||||||
|
|
||||||
|
backend = services["backend"]
|
||||||
|
backend_volume_sources = {_volume_source(v) for v in backend.get("volumes", [])}
|
||||||
|
assert "cyclone_db" in backend_volume_sources, (
|
||||||
|
"backend must mount the cyclone_db volume"
|
||||||
|
)
|
||||||
|
assert backend.get("restart") == "unless-stopped", (
|
||||||
|
"backend must restart: unless-stopped so healthcheck failures recover"
|
||||||
|
)
|
||||||
|
assert "healthcheck" in backend, "backend must declare a healthcheck"
|
||||||
|
|
||||||
|
frontend = services["frontend"]
|
||||||
|
assert "8080:8080" in frontend.get("ports", []), (
|
||||||
|
"frontend must publish 8080:8080 for LAN access"
|
||||||
|
)
|
||||||
|
depends_on = frontend.get("depends_on") or {}
|
||||||
|
if isinstance(depends_on, dict):
|
||||||
|
backend_dep = depends_on.get("backend") or {}
|
||||||
|
assert backend_dep.get("condition") == "service_healthy", (
|
||||||
|
"frontend must wait for backend healthy before starting"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Short-form `depends_on: [backend]` is acceptable too — it implies
|
||||||
|
# service_started, not service_healthy. Flag a soft warning.
|
||||||
|
pytest.skip(
|
||||||
|
"frontend uses short-form depends_on; switch to long-form for service_healthy"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_declares_required_secrets_and_volumes():
|
||||||
|
compose = yaml.safe_load(COMPOSE_FILE.read_text())
|
||||||
|
secrets = compose.get("secrets", {})
|
||||||
|
for required in ("cyclone_db_key", "cyclone_admin_password"):
|
||||||
|
assert required in secrets, f"compose must declare secret {required!r}"
|
||||||
|
volumes = compose.get("volumes", {})
|
||||||
|
for required in (
|
||||||
|
"cyclone_db",
|
||||||
|
"cyclone_backups",
|
||||||
|
"cyclone_prodfiles",
|
||||||
|
"cyclone_sftp_staging",
|
||||||
|
"cyclone_logs",
|
||||||
|
):
|
||||||
|
assert required in volumes, f"compose must declare volume {required!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_backend_wires_backup_autostart():
|
||||||
|
"""The existing BackupService (SP17) needs CYCLONE_BACKUP_AUTOSTART=1
|
||||||
|
on container boot. The compose env block must include it."""
|
||||||
|
compose = yaml.safe_load(COMPOSE_FILE.read_text())
|
||||||
|
env = compose["services"]["backend"].get("environment", {})
|
||||||
|
assert str(env.get("CYCLONE_BACKUP_AUTOSTART")) == "1", (
|
||||||
|
"backend must autostart the backup scheduler (CYCLONE_BACKUP_AUTOSTART=1)"
|
||||||
|
)
|
||||||
|
assert "CYCLONE_BACKUP_INTERVAL_HOURS" in env
|
||||||
|
assert "CYCLONE_BACKUP_RETENTION_DAYS" in env
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
|
||||||
|
)
|
||||||
|
def test_backend_dockerfile_parses():
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"--check",
|
||||||
|
"-f",
|
||||||
|
str(BACKEND_DOCKERFILE),
|
||||||
|
str(REPO_ROOT / "backend"),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"backend Dockerfile failed to parse: stderr={result.stderr!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _has_docker(), reason="docker not on PATH; skipping Dockerfile parse check"
|
||||||
|
)
|
||||||
|
def test_frontend_dockerfile_parses():
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"build",
|
||||||
|
"--check",
|
||||||
|
"-f",
|
||||||
|
str(FRONTEND_DOCKERFILE),
|
||||||
|
str(REPO_ROOT),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
assert result.returncode == 0, (
|
||||||
|
f"frontend Dockerfile failed to parse: stderr={result.stderr!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
not _has_docker_compose()
|
||||||
|
or not os.environ.get("DOCKER_TESTS"),
|
||||||
|
reason="DOCKER_TESTS=1 + docker compose required for live bring-up",
|
||||||
|
)
|
||||||
|
def test_compose_up_brings_up_healthy_stack():
|
||||||
|
"""Gated live test — only runs when DOCKER_TESTS=1 and docker compose
|
||||||
|
is available. Builds + brings up the full stack and waits up to 120s
|
||||||
|
for the backend healthcheck to come up healthy. Uses the override
|
||||||
|
file (docker-compose.override.yml) when present so the test doesn't
|
||||||
|
require sudo to create /etc/cyclone/secrets/."""
|
||||||
|
# The stack publishes host port 8080. If something else on this host
|
||||||
|
# is already using it (e.g. nocodb on a dev box) the test can't run
|
||||||
|
# here but will run cleanly on a fresh CI worker. Skip with a clear
|
||||||
|
# message rather than failing with a confusing port-bind error.
|
||||||
|
import socket
|
||||||
|
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
if s.connect_ex(("127.0.0.1", 8080)) == 0:
|
||||||
|
pytest.skip("host port 8080 already bound — rerun on a fresh host")
|
||||||
|
|
||||||
|
override = REPO_ROOT / "docker-compose.override.yml"
|
||||||
|
cmd_base = ["docker", "compose", "-f", str(COMPOSE_FILE)]
|
||||||
|
if override.exists():
|
||||||
|
cmd_base.extend(["-f", str(override)])
|
||||||
|
subprocess.run(
|
||||||
|
cmd_base + ["up", "-d", "--build"],
|
||||||
|
check=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
|
||||||
|
for _ in range(60):
|
||||||
|
ps = subprocess.run(
|
||||||
|
cmd_base + ["ps", "--format", "json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
|
if "healthy" in ps.stdout:
|
||||||
|
return
|
||||||
|
time.sleep(2)
|
||||||
|
pytest.fail("compose stack did not become healthy within 120s")
|
||||||
|
finally:
|
||||||
|
subprocess.run(
|
||||||
|
cmd_base + ["down", "-v"],
|
||||||
|
check=False,
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Spot-check that existing endpoints now require auth (when AUTH_DISABLED is not set).
|
||||||
|
|
||||||
|
The conftest in ``tests/conftest.py`` flips ``AUTH_DISABLED=True`` for
|
||||||
|
every test module that does NOT start with ``test_auth`` — this module
|
||||||
|
deliberately is NOT named ``test_auth_*`` so it inherits the default
|
||||||
|
disabled posture... wait, that's the opposite of what we want.
|
||||||
|
|
||||||
|
This module is named ``test_existing_endpoints_require_auth`` so the
|
||||||
|
conftest will treat it as a legacy test and set ``AUTH_DISABLED=True``,
|
||||||
|
bypassing the auth check entirely. To actually verify the gate, this
|
||||||
|
test's ``client`` fixture flips ``AUTH_DISABLED`` to ``False``
|
||||||
|
*just for this test*, then restores it afterwards. This way the
|
||||||
|
rest of the suite still sees the disabled posture and existing
|
||||||
|
tests keep passing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from cyclone.api import app
|
||||||
|
from cyclone.auth import deps as _auth_deps
|
||||||
|
from cyclone.auth.deps import AUTH_DISABLED
|
||||||
|
from cyclone.db import Session as DbSession
|
||||||
|
from cyclone.db import SessionLocal, User
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear():
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
yield
|
||||||
|
with SessionLocal()() as db:
|
||||||
|
db.execute(delete(DbSession))
|
||||||
|
db.execute(delete(User))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
# Force AUTH_DISABLED=False for these tests so the dep actually checks the cookie.
|
||||||
|
original = AUTH_DISABLED
|
||||||
|
_auth_deps.AUTH_DISABLED = False
|
||||||
|
try:
|
||||||
|
yield TestClient(app)
|
||||||
|
finally:
|
||||||
|
_auth_deps.AUTH_DISABLED = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,path", [
|
||||||
|
("GET", "/api/claims"),
|
||||||
|
("GET", "/api/remittances"),
|
||||||
|
("GET", "/api/providers"),
|
||||||
|
("GET", "/api/batches"),
|
||||||
|
("GET", "/api/payers/p1/summary"),
|
||||||
|
("GET", "/api/activity"),
|
||||||
|
])
|
||||||
|
def test_existing_get_endpoints_require_auth(client, method, path):
|
||||||
|
resp = client.request(method, path)
|
||||||
|
assert resp.status_code == 401, f"{method} {path} returned {resp.status_code}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_is_public(client):
|
||||||
|
"""``/api/health`` is the public healthcheck and must remain reachable."""
|
||||||
|
resp = client.get("/api/health")
|
||||||
|
assert resp.status_code != 401, f"/api/health returned {resp.status_code}"
|
||||||
@@ -27,7 +27,8 @@ MT = ZoneInfo("America/Denver")
|
|||||||
def test_build_outbound_with_explicit_mt():
|
def test_build_outbound_with_explicit_mt():
|
||||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||||
name = build_outbound_filename("11525703", "837P", now_mt=now)
|
name = build_outbound_filename("11525703", "837P", now_mt=now)
|
||||||
assert name == "11525703-837P-20260620132243505-1of1.x12"
|
# HCPF outbound format: tp prefix on the tpid
|
||||||
|
assert name == "tp11525703-837P-20260620132243505-1of1.x12"
|
||||||
|
|
||||||
|
|
||||||
def test_build_outbound_default_extension():
|
def test_build_outbound_default_extension():
|
||||||
@@ -39,15 +40,17 @@ def test_build_outbound_default_extension():
|
|||||||
def test_build_outbound_custom_extension():
|
def test_build_outbound_custom_extension():
|
||||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||||
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
|
name = build_outbound_filename("11525703", "837P", ext="txt", now_mt=now)
|
||||||
assert name == "11525703-837P-20260620132243505-1of1.txt"
|
assert name == "tp11525703-837P-20260620132243505-1of1.txt"
|
||||||
|
|
||||||
|
|
||||||
def test_build_outbound_uses_mt_when_no_arg():
|
def test_build_outbound_uses_mt_when_no_arg():
|
||||||
# Snapshot test — the timestamp will be very recent; check format only
|
# Snapshot test — the timestamp will be very recent; check format only
|
||||||
name = build_outbound_filename("11525703", "837P")
|
name = build_outbound_filename("11525703", "837P")
|
||||||
assert OUTBOUND_RE.match(name), name
|
assert OUTBOUND_RE.match(name), name
|
||||||
|
# tp11525703-837P-YYYYMMDDhhmmssSSS-1of1.x12 — 4 dash-separated parts
|
||||||
parts = name.split("-")
|
parts = name.split("-")
|
||||||
assert len(parts) == 4
|
assert len(parts) == 4
|
||||||
|
assert parts[0] == "tp11525703"
|
||||||
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
|
assert len(parts[2]) == 17 # yyyymmddhhmmssSSS
|
||||||
|
|
||||||
|
|
||||||
@@ -128,8 +131,9 @@ def test_parse_inbound_rejects_non_x12_ext():
|
|||||||
|
|
||||||
|
|
||||||
def test_roundtrip_outbound_to_inbound():
|
def test_roundtrip_outbound_to_inbound():
|
||||||
# Outbound tpid is bare (no TP); inbound tpid is bare inside TP{...}
|
# Outbound uses tp{...}, inbound uses TP{...} (case differs but both
|
||||||
# The two regexes use different shapes — round-trip via tpid only.
|
# prefixes are required). The two regexes use different shapes —
|
||||||
|
# round-trip via tpid only.
|
||||||
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
now = datetime(2026, 6, 20, 13, 22, 43, 505_000, tzinfo=MT)
|
||||||
out = build_outbound_filename("11525703", "837P", now_mt=now)
|
out = build_outbound_filename("11525703", "837P", now_mt=now)
|
||||||
assert OUTBOUND_RE.match(out)
|
assert OUTBOUND_RE.match(out)
|
||||||
@@ -142,13 +146,19 @@ def test_roundtrip_outbound_to_inbound():
|
|||||||
|
|
||||||
|
|
||||||
def test_is_outbound_filename():
|
def test_is_outbound_filename():
|
||||||
assert is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
# HCPF outbound always has the lowercase "tp" prefix
|
||||||
|
assert is_outbound_filename("tp11525703-837P-20260620132243505-1of1.x12")
|
||||||
|
# Bare tpid (no tp prefix) is no longer a valid outbound filename
|
||||||
|
assert not is_outbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||||
|
# Uppercase TP prefix is the inbound shape, not outbound
|
||||||
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
|
assert not is_outbound_filename("TP11525703-837P-20260620132243505-1of1.x12")
|
||||||
assert not is_outbound_filename("not-a-filename")
|
assert not is_outbound_filename("not-a-filename")
|
||||||
|
|
||||||
|
|
||||||
def test_is_inbound_filename():
|
def test_is_inbound_filename():
|
||||||
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
assert is_inbound_filename("TP11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
||||||
|
# Lowercase tp prefix is the outbound shape, not inbound
|
||||||
|
assert not is_inbound_filename("tp11525703-837P_M019048402-20260520231513488-1of1_999.x12")
|
||||||
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
assert not is_inbound_filename("11525703-837P-20260620132243505-1of1.x12")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Tests that ``CycloneStore.iter_claims`` populates ``receivedAmount``
|
||||||
|
from the matched ``Remittance.total_paid``.
|
||||||
|
|
||||||
|
Before SP_Auth follow-up: every claim came back with
|
||||||
|
``receivedAmount: 0.0`` regardless of whether it had been paired with
|
||||||
|
a paid remittance, so the Dashboard's "Received" KPI was always $0
|
||||||
|
even when claims had been paid and reconciled.
|
||||||
|
|
||||||
|
The fix: ``iter_claims`` bulk-loads ``Remittance.total_paid`` for every
|
||||||
|
matched claim id in the result set (single SQL, no N+1) and stamps
|
||||||
|
the sum onto each claim dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import ActivityEvent, Batch, Claim, ClaimState, Remittance
|
||||||
|
from cyclone.store import store as global_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _setup(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||||
|
db._reset_for_tests()
|
||||||
|
db.init_db()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_batch(s, batch_id: str) -> None:
|
||||||
|
s.add(Batch(
|
||||||
|
id=batch_id,
|
||||||
|
kind="837p",
|
||||||
|
input_filename="seed.edi",
|
||||||
|
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
|
||||||
|
totals_json={"total_claims": 3},
|
||||||
|
validation_json={"passed": True, "warnings": [], "errors": []},
|
||||||
|
raw_result_json={"_": "stub"},
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_claim(s, claim_id: str, batch_id: str, *, matched_remit_id: str | None = None) -> None:
|
||||||
|
s.add(Claim(
|
||||||
|
id=claim_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
patient_control_number=claim_id,
|
||||||
|
service_date_from=date(2026, 6, 1),
|
||||||
|
service_date_to=date(2026, 6, 1),
|
||||||
|
charge_amount=Decimal("200.00"),
|
||||||
|
provider_npi="1234567890",
|
||||||
|
payer_id="SKCO0",
|
||||||
|
state=ClaimState.PAID,
|
||||||
|
matched_remittance_id=matched_remit_id,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_remit(s, remit_id: str, batch_id: str, *, total_paid: Decimal) -> None:
|
||||||
|
s.add(Remittance(
|
||||||
|
id=remit_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
payer_claim_control_number=remit_id,
|
||||||
|
claim_id=None,
|
||||||
|
status_code="1",
|
||||||
|
total_charge=Decimal("200.00"),
|
||||||
|
total_paid=total_paid,
|
||||||
|
adjustment_amount=Decimal("0"),
|
||||||
|
received_at=datetime(2026, 6, 20, 12, 0, tzinfo=timezone.utc),
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_populates_received_amount_from_matched_remittance():
|
||||||
|
"""A matched claim should reflect its remittance's ``total_paid``."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_remit(s, "r1", "b1", total_paid=Decimal("180.00"))
|
||||||
|
_make_claim(s, "CLM-A", "b1", matched_remit_id="r1")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-A"]["receivedAmount"] == pytest.approx(180.00)
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_unmatched_claim_has_zero_received():
|
||||||
|
"""Claims with no matched remittance still get 0.0, not stale data."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_claim(s, "CLM-U", "b1", matched_remit_id=None)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-U"]["receivedAmount"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_handles_orphan_match_fk():
|
||||||
|
"""A claim with a stale ``matched_remittance_id`` whose remittance row
|
||||||
|
was deleted should default to 0.0 rather than blow up."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_claim(s, "CLM-ORPHAN", "b1", matched_remit_id="r-deleted")
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
# No remittance row exists — the FK is dangling, which can happen
|
||||||
|
# if the remittance was deleted between match and now.
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-ORPHAN"]["receivedAmount"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_iter_claims_bulk_loads_multiple_matches_in_one_pass():
|
||||||
|
"""All matched claims in a page reflect their distinct remittance
|
||||||
|
totals — the bulk load must aggregate per remittance id."""
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
_make_batch(s, "b1")
|
||||||
|
_make_remit(s, "r1", "b1", total_paid=Decimal("120.00"))
|
||||||
|
_make_remit(s, "r2", "b1", total_paid=Decimal("175.50"))
|
||||||
|
_make_remit(s, "r3", "b1", total_paid=Decimal("0"))
|
||||||
|
_make_claim(s, "CLM-1", "b1", matched_remit_id="r1")
|
||||||
|
_make_claim(s, "CLM-2", "b1", matched_remit_id="r2")
|
||||||
|
_make_claim(s, "CLM-3", "b1", matched_remit_id="r3")
|
||||||
|
_make_claim(s, "CLM-4", "b1", matched_remit_id=None)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
items = global_store.iter_claims(limit=10)
|
||||||
|
by_id = {c["id"]: c for c in items}
|
||||||
|
assert by_id["CLM-1"]["receivedAmount"] == pytest.approx(120.00)
|
||||||
|
assert by_id["CLM-2"]["receivedAmount"] == pytest.approx(175.50)
|
||||||
|
assert by_id["CLM-3"]["receivedAmount"] == pytest.approx(0.0)
|
||||||
|
assert by_id["CLM-4"]["receivedAmount"] == 0.0
|
||||||
@@ -65,6 +65,21 @@ def test_build_gs_emits_gs_segment_with_hc_functional_id():
|
|||||||
assert parts[6] == "1"
|
assert parts[6] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_gs_uses_gs04_yyyymmdd_8_digits():
|
||||||
|
"""GS-04 must be CCYYMMDD (8 digits) per X12; ISA uses YYMMDD (6).
|
||||||
|
|
||||||
|
A 6-digit value like '260622' is rejected by EDI validators with
|
||||||
|
'Element GS-04 must use CCYYMMDD date format'.
|
||||||
|
"""
|
||||||
|
gs = _build_gs("SENDER", "RECEIVER", "1")
|
||||||
|
parts = gs.rstrip("~").split("*")
|
||||||
|
# parts[4] = GS-04 date
|
||||||
|
assert len(parts[4]) == 8, f"expected 8-digit CCYYMMDD, got {parts[4]!r}"
|
||||||
|
# Must parse as a CCYYMMDD date
|
||||||
|
from datetime import datetime
|
||||||
|
datetime.strptime(parts[4], "%Y%m%d")
|
||||||
|
|
||||||
|
|
||||||
def test_build_st_emits_837_segment():
|
def test_build_st_emits_837_segment():
|
||||||
st = _build_st("0001")
|
st = _build_st("0001")
|
||||||
assert st.startswith("ST*837*0001*005010X222A1~")
|
assert st.startswith("ST*837*0001*005010X222A1~")
|
||||||
@@ -100,12 +115,15 @@ def test_build_nm1_person_entity_splits_first_last():
|
|||||||
assert parts[4] == "Jane"
|
assert parts[4] == "Jane"
|
||||||
|
|
||||||
|
|
||||||
def test_build_per_returns_empty_when_no_contact():
|
def test_build_per_emits_per01_even_with_no_contact():
|
||||||
assert _build_per(None, None) == ""
|
"""PER is required by X12 Loop 1000A — at least PER01 must be present."""
|
||||||
assert _build_per("", "") == ""
|
per = _build_per(None, None)
|
||||||
|
assert per == "PER*IC~"
|
||||||
|
per = _build_per("", "")
|
||||||
|
assert per == "PER*IC~"
|
||||||
|
|
||||||
|
|
||||||
def test_build_per_emits_segment_with_contact():
|
def test_build_per_emits_segment_with_phone_contact():
|
||||||
per = _build_per("Jane Doe", "5551234567")
|
per = _build_per("Jane Doe", "5551234567")
|
||||||
parts = per.rstrip("~").split("*")
|
parts = per.rstrip("~").split("*")
|
||||||
assert parts[0] == "PER"
|
assert parts[0] == "PER"
|
||||||
@@ -115,6 +133,25 @@ def test_build_per_emits_segment_with_contact():
|
|||||||
assert parts[4] == "5551234567"
|
assert parts[4] == "5551234567"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_per_emits_segment_with_email_contact():
|
||||||
|
"""When email is given, it wins over phone (HCPF expects email)."""
|
||||||
|
per = _build_per("Tyler Martinez", None, contact_email="tyler@dzinesco.com")
|
||||||
|
parts = per.rstrip("~").split("*")
|
||||||
|
assert parts[0] == "PER"
|
||||||
|
assert parts[1] == "IC"
|
||||||
|
assert parts[2] == "Tyler Martinez"
|
||||||
|
assert parts[3] == "EM"
|
||||||
|
assert parts[4] == "tyler@dzinesco.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_per_email_takes_precedence_over_phone():
|
||||||
|
"""If both phone and email are given, email is emitted (PER04)."""
|
||||||
|
per = _build_per("Tyler", "555-1234", contact_email="t@example.com")
|
||||||
|
parts = per.rstrip("~").split("*")
|
||||||
|
assert parts[3] == "EM"
|
||||||
|
assert parts[4] == "t@example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_build_n3_returns_empty_when_no_address():
|
def test_build_n3_returns_empty_when_no_address():
|
||||||
assert _build_n3(None, None) == ""
|
assert _build_n3(None, None) == ""
|
||||||
assert _build_n3("", "") == ""
|
assert _build_n3("", "") == ""
|
||||||
@@ -133,12 +170,33 @@ def test_build_hl_emits_segment():
|
|||||||
assert hl == "HL*1**20*1~"
|
assert hl == "HL*1**20*1~"
|
||||||
|
|
||||||
|
|
||||||
def test_build_sbr_emits_segment():
|
def test_build_sbr_emits_segment_with_correct_slots():
|
||||||
sbr = _build_sbr("18", "M123", "PAYER")
|
"""SBR01=Payer Responsibility Seq Code (default 'P'),
|
||||||
|
SBR02=Individual Relationship Code (e.g. '18' for self),
|
||||||
|
SBR09=Claim Filing Indicator Code (e.g. 'MC' for Medicaid)."""
|
||||||
|
sbr = _build_sbr("18", "MC")
|
||||||
parts = sbr.rstrip("~").split("*")
|
parts = sbr.rstrip("~").split("*")
|
||||||
assert parts[0] == "SBR"
|
assert parts[0] == "SBR"
|
||||||
assert parts[1] == "18"
|
# SBR01 — primary
|
||||||
assert parts[9] == "M123"
|
assert parts[1] == "P"
|
||||||
|
# SBR02 — individual relationship (self = 18)
|
||||||
|
assert parts[2] == "18"
|
||||||
|
# SBR09 — claim filing indicator
|
||||||
|
assert parts[9] == "MC"
|
||||||
|
# Member ID and payer name do NOT belong in SBR — they live in
|
||||||
|
# NM109 and NM1*PR.NM103 respectively.
|
||||||
|
assert "M123" not in sbr
|
||||||
|
assert "PAYER" not in sbr
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sbr_defaults_relationship_to_self_and_filing_to_empty():
|
||||||
|
"""When called with all-None, SBR01/02 fall back to safe defaults
|
||||||
|
and SBR09 is left empty (the validator's R202 rule will then skip)."""
|
||||||
|
sbr = _build_sbr(None, None)
|
||||||
|
parts = sbr.rstrip("~").split("*")
|
||||||
|
assert parts[1] == "P"
|
||||||
|
assert parts[2] == "18"
|
||||||
|
assert parts[9] == ""
|
||||||
|
|
||||||
|
|
||||||
def test_build_ref_returns_empty_when_no_value():
|
def test_build_ref_returns_empty_when_no_value():
|
||||||
@@ -200,6 +258,34 @@ def test_build_clm_emits_clm01_to_clm05():
|
|||||||
assert parts[5] == "11:1"
|
assert parts[5] == "11:1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_clm_emits_clm08_defaulting_to_y():
|
||||||
|
"""CLM-08 (Benefits Assignment Certification) is required by X12
|
||||||
|
837P when CLM-07 = 'Y'. Default to 'Y' when the source didn't
|
||||||
|
capture one (matches what 99% of HCPF files look like).
|
||||||
|
"""
|
||||||
|
claim = _stub_claim_header()
|
||||||
|
clm = _build_clm(claim)
|
||||||
|
parts = clm.rstrip("~").split("*")
|
||||||
|
# parts[8] = CLM-08
|
||||||
|
assert parts[8] == "Y", f"CLM-08 should default to 'Y', got {parts[8]!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_clm_propagates_captured_clm08():
|
||||||
|
from cyclone.parsers.models import ClaimHeader
|
||||||
|
claim = ClaimHeader(
|
||||||
|
claim_id="CLM-1",
|
||||||
|
total_charge=Decimal("100.00"),
|
||||||
|
place_of_service="11",
|
||||||
|
frequency_code="1",
|
||||||
|
assignment="Y",
|
||||||
|
benefits_assignment_certification="N",
|
||||||
|
release_of_info="Y",
|
||||||
|
)
|
||||||
|
clm = _build_clm(claim)
|
||||||
|
parts = clm.rstrip("~").split("*")
|
||||||
|
assert parts[8] == "N"
|
||||||
|
|
||||||
|
|
||||||
def test_build_ref_g1_returns_empty_when_no_prior_auth():
|
def test_build_ref_g1_returns_empty_when_no_prior_auth():
|
||||||
assert _build_ref_g1(None) == ""
|
assert _build_ref_g1(None) == ""
|
||||||
assert _build_ref_g1("") == ""
|
assert _build_ref_g1("") == ""
|
||||||
@@ -242,6 +328,57 @@ def test_build_sv1_emits_procedure_modifiers_charge_units():
|
|||||||
assert parts[4] == "1"
|
assert parts[4] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sv1_emits_sv1_06_and_sv1_07_when_dx_pointer_given():
|
||||||
|
"""SV1-07 (Diagnosis Code Pointer) is required by X12/HCPF when the
|
||||||
|
parent claim has an HI segment (i.e. has at least one diagnosis).
|
||||||
|
|
||||||
|
Per 005010X222A1, SV1-06 is "Not Used" by the guide and MUST be
|
||||||
|
empty. Unit basis (UN/MJ/...) goes only in SV1-03.
|
||||||
|
"""
|
||||||
|
line = _stub_service_line()
|
||||||
|
sv1 = _build_sv1(line, dx_pointer="1")
|
||||||
|
parts = sv1.rstrip("~").split("*")
|
||||||
|
# parts layout: SV1, comp(SV1-01), charge(02), unit_basis(03),
|
||||||
|
# units(04), pos(05), ""(06 NOT USED), sv1_07(07)
|
||||||
|
assert len(parts) == 8, f"expected 8 elements, got {parts}"
|
||||||
|
# SV1-03 = unit basis
|
||||||
|
assert parts[3] == "UN"
|
||||||
|
# SV1-06 = "" (Not Used by 837P guide)
|
||||||
|
assert parts[6] == "", f"SV1-06 must be empty (Not Used by 837P), got {parts[6]!r}"
|
||||||
|
# SV1-07 = pointer
|
||||||
|
assert parts[7] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sv1_omits_sv1_07_when_no_dx_pointer():
|
||||||
|
"""When the claim has no HI segment, SV1-07 should be empty."""
|
||||||
|
line = _stub_service_line()
|
||||||
|
sv1 = _build_sv1(line) # no dx_pointer kwarg
|
||||||
|
parts = sv1.rstrip("~").split("*")
|
||||||
|
assert len(parts) == 8
|
||||||
|
assert parts[6] == "" # SV1-06 still empty
|
||||||
|
assert parts[7] == "" # SV1-07 empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_sv1_matches_goodclaim_layout():
|
||||||
|
"""Layout must match the known-good reference at docs/goodclaim.x12:
|
||||||
|
|
||||||
|
SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~
|
||||||
|
|
||||||
|
i.e. 8 fields total: comp, charge, UN, units, '', '', '1'.
|
||||||
|
"""
|
||||||
|
from cyclone.parsers.models import Procedure, ServiceLine
|
||||||
|
line = ServiceLine(
|
||||||
|
line_number=1,
|
||||||
|
procedure=Procedure(qualifier="HC", code="T1019", modifiers=["U1", "KX"]),
|
||||||
|
charge=Decimal("125.40"),
|
||||||
|
units=Decimal("19.00"),
|
||||||
|
unit_type="UN",
|
||||||
|
place_of_service=None,
|
||||||
|
)
|
||||||
|
sv1 = _build_sv1(line, dx_pointer="1")
|
||||||
|
assert sv1 == "SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~"
|
||||||
|
|
||||||
|
|
||||||
def test_build_dtp_472_emits_service_date():
|
def test_build_dtp_472_emits_service_date():
|
||||||
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
|
assert _build_dtp_472(date(2026, 6, 15)) == "DTP*472*D8*20260615~"
|
||||||
|
|
||||||
@@ -369,6 +506,76 @@ def test_serialize_837_uses_custom_sender_receiver_ids():
|
|||||||
parse(text, _CFG)
|
parse(text, _CFG)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_837_emits_per_segment_in_submitter_block():
|
||||||
|
"""X12 Loop 1000A (Submitter Name) requires a PER segment after
|
||||||
|
NM1*41. The serializer must emit one even with no contact info
|
||||||
|
(PER01='IC' is the only required element)."""
|
||||||
|
claim = _load_claim()
|
||||||
|
text = serialize_837(claim)
|
||||||
|
# The first NM1*41 should be followed immediately by a PER segment.
|
||||||
|
seg_ids = [seg.split("*")[0] for seg in text.split("~") if seg]
|
||||||
|
nm1_41_idx = seg_ids.index("NM1") # first NM1 is the submitter
|
||||||
|
assert nm1_41_idx >= 0
|
||||||
|
# The very next segment must be PER (PER01='IC' is required by spec).
|
||||||
|
assert seg_ids[nm1_41_idx + 1] == "PER"
|
||||||
|
per_line = next(seg for seg in text.split("~") if seg.startswith("PER*IC"))
|
||||||
|
assert per_line.startswith("PER*IC")
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_837_per_segment_includes_email_from_kwargs():
|
||||||
|
"""Passing submitter_contact_email should emit PER*IC*<name>*EM*<email>."""
|
||||||
|
claim = _load_claim()
|
||||||
|
text = serialize_837(
|
||||||
|
claim,
|
||||||
|
sender_id="DZINESCO",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
)
|
||||||
|
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
|
||||||
|
# And the ISA sender id should be the clearhouse TPID, not "CYCLONE".
|
||||||
|
assert "ZZ*DZINESCO" in text
|
||||||
|
assert "ZZ*CYCLONE" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_837_sbr09_uses_claim_filing_indicator_code_kwarg():
|
||||||
|
"""SBR09 must be the claim filing indicator (e.g. 'MC' for Medicaid),
|
||||||
|
not the member id. The serializer takes it from the kwarg."""
|
||||||
|
claim = _load_claim()
|
||||||
|
text = serialize_837(claim, claim_filing_indicator_code="MC")
|
||||||
|
sbr_line = next(seg for seg in text.split("~") if seg.startswith("SBR*"))
|
||||||
|
parts = sbr_line.rstrip("~").split("*")
|
||||||
|
# SBR01 = P (primary), SBR02 = 18 (self), SBR09 = MC
|
||||||
|
assert parts[1] == "P"
|
||||||
|
assert parts[2] == "18"
|
||||||
|
assert parts[9] == "MC"
|
||||||
|
# And the member id should NOT be in SBR.
|
||||||
|
assert claim.subscriber.member_id not in sbr_line
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_837_for_resubmit_forwards_kwargs_to_serialize_837():
|
||||||
|
"""serialize_837_for_resubmit is a thin wrapper — it must forward
|
||||||
|
clearhouse + payer kwargs so the export endpoint can use it."""
|
||||||
|
claim = _load_claim()
|
||||||
|
text = serialize_837_for_resubmit(
|
||||||
|
claim,
|
||||||
|
interchange_index=7,
|
||||||
|
sender_id="DZINESCO",
|
||||||
|
submitter_name="Dzinesco",
|
||||||
|
submitter_contact_name="Tyler Martinez",
|
||||||
|
submitter_contact_email="tyler@dzinesco.com",
|
||||||
|
receiver_id="COMEDASSISTPROG",
|
||||||
|
receiver_name="COLORADO MEDICAL ASSISTANCE PROGRAM",
|
||||||
|
claim_filing_indicator_code="MC",
|
||||||
|
)
|
||||||
|
assert "ZZ*DZINESCO" in text
|
||||||
|
assert "ZZ*COMEDASSISTPROG" in text
|
||||||
|
assert "PER*IC*Tyler Martinez*EM*tyler@dzinesco.com" in text
|
||||||
|
# Control numbers reflect the resubmit index.
|
||||||
|
isa = next(seg for seg in text.split("~") if seg.startswith("ISA*"))
|
||||||
|
assert "000000007" in isa
|
||||||
|
|
||||||
|
|
||||||
def test_serialize_error_is_an_exception():
|
def test_serialize_error_is_an_exception():
|
||||||
assert issubclass(SerializeError, Exception)
|
assert issubclass(SerializeError, Exception)
|
||||||
|
|
||||||
|
|||||||
@@ -31,13 +31,13 @@ def sftp_block(tmp_path):
|
|||||||
|
|
||||||
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
|
def test_stub_writes_preserving_remote_path(sftp_block, tmp_path):
|
||||||
client = SftpClient(sftp_block)
|
client = SftpClient(sftp_block)
|
||||||
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
remote = "/CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
|
||||||
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
|
target = client.write_file(remote, b"ISA*00*...~IEA*1*1~")
|
||||||
assert target.exists()
|
assert target.exists()
|
||||||
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
|
assert target.read_bytes() == b"ISA*00*...~IEA*1*1~"
|
||||||
# Confirm the full nested MFT path is preserved under staging
|
# Confirm the full nested MFT path is preserved under staging
|
||||||
rel = target.relative_to(sftp_block.staging_dir)
|
rel = target.relative_to(sftp_block.staging_dir)
|
||||||
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/11525703-837P-20260620132243505-1of1.x12"
|
assert str(rel) == "CO XIX/PROD/coxix_prod_11525703/FromHPE/tp11525703-837P-20260620132243505-1of1.x12"
|
||||||
|
|
||||||
|
|
||||||
def test_stub_creates_parent_dirs(sftp_block):
|
def test_stub_creates_parent_dirs(sftp_block):
|
||||||
|
|||||||
Generated
+47
-64
@@ -44,72 +44,21 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bcrypt"
|
name = "bcrypt"
|
||||||
version = "5.0.0"
|
version = "4.0.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/8c/ae/3af7d006aacf513975fd1948a6b4d6f8b4a307f8a244e1a3d3774b297aad/bcrypt-4.0.1.tar.gz", hash = "sha256:27d375903ac8261cfe4047f6709d16f7d18d39b1ec92aaf72af989552a650ebd", size = 25498, upload-time = "2022-10-09T15:36:49.775Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/d4/3b2657bd58ef02b23a07729b0df26f21af97169dbd0b5797afa9e97ebb49/bcrypt-4.0.1-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:b1023030aec778185a6c16cf70f359cbb6e0c289fd564a7cfa29e727a1c38f8f", size = 473446, upload-time = "2022-10-09T15:36:25.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/0a/1582790232fef6c2aa201f345577306b8bfe465c2c665dec04c86a016879/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:08d2947c490093a11416df18043c27abe3921558d2c03e2076ccb28a116cb6d0", size = 583044, upload-time = "2022-10-09T15:37:09.447Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/16/49ff5146fb815742ad58cafb5034907aa7f166b1344d0ddd7fd1c818bd17/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0eaa47d4661c326bfc9d08d16debbc4edf78778e6aaba29c1bc7ce67214d4410", size = 583189, upload-time = "2022-10-09T15:37:10.69Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/48/fd2b197a9741fa790ba0b88a9b10b5e88e62ff5cf3e1bc96d8354d7ce613/bcrypt-4.0.1-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae88eca3024bb34bb3430f964beab71226e761f51b912de5133470b649d82344", size = 593473, upload-time = "2022-10-09T15:36:27.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/50/e683d8418974a602ba40899c8a5c38b3decaf5a4d36c32fc65dce454d8a8/bcrypt-4.0.1-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:a522427293d77e1c29e303fc282e2d71864579527a04ddcfda6d4f8396c6c36a", size = 593249, upload-time = "2022-10-09T15:36:28.481Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/a7/ee4561fd9b78ca23c8e5591c150cc58626a5dfb169345ab18e1c2c664ee0/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fbdaec13c5105f0c4e5c52614d04f0bca5f5af007910daa8b6b12095edaa67b3", size = 583586, upload-time = "2022-10-09T15:37:11.962Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/fe/da28a5916128d541da0993328dc5cf4b43dfbf6655f2c7a2abe26ca2dc88/bcrypt-4.0.1-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ca3204d00d3cb2dfed07f2d74a25f12fc12f73e606fcaa6975d1f7ae69cacbb2", size = 593659, upload-time = "2022-10-09T15:36:30.049Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/4f/3632a69ce344c1551f7c9803196b191a8181c6a1ad2362c225581ef0d383/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:089098effa1bc35dc055366740a067a2fc76987e8ec75349eb9484061c54f535", size = 613116, upload-time = "2022-10-09T15:37:14.107Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/69/edacb37481d360d06fc947dab5734aaf511acb7d1a1f9e2849454376c0f8/bcrypt-4.0.1-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:e9a51bbfe7e9802b5f3508687758b564069ba937748ad7b9e890086290d2f79e", size = 624290, upload-time = "2022-10-09T15:36:31.251Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
|
{ url = "https://files.pythonhosted.org/packages/aa/ca/6a534669890725cbb8c1fb4622019be31813c8edaa7b6d5b62fc9360a17e/bcrypt-4.0.1-cp36-abi3-win32.whl", hash = "sha256:2caffdae059e06ac23fce178d31b4a702f2a3264c20bfb5ff541b338194d8fab", size = 159428, upload-time = "2022-10-09T15:36:32.893Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/81/d8c22cd7e5e1c6a7d48e41a1d1d46c92f17dae70a54d9814f746e6027dec/bcrypt-4.0.1-cp36-abi3-win_amd64.whl", hash = "sha256:8a68f4341daf7522fe8d73874de8906f3a339048ba406be6ddc1b3ccb16fc0d9", size = 152930, upload-time = "2022-10-09T15:36:34.635Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/75/4aa9f5a4d40d762892066ba1046000b329c7cd58e888a6db878019b282dc/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:7edda91d5ab52b15636d9c30da87d2cc84f426c72b9dba7a9b4fe142ba11f534", size = 271180, upload-time = "2025-09-25T19:50:38.575Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/79/875f9558179573d40a9cc743038ac2bf67dfb79cecb1e8b5d70e88c94c3d/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:046ad6db88edb3c5ece4369af997938fb1c19d6a699b9c1b27b0db432faae4c4", size = 273791, upload-time = "2025-09-25T19:50:39.913Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/fe/975adb8c216174bf70fc17535f75e85ac06ed5252ea077be10d9cff5ce24/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dcd58e2b3a908b5ecc9b9df2f0085592506ac2d5110786018ee5e160f28e0911", size = 270746, upload-time = "2025-09-25T19:50:43.306Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/f8/972c96f5a2b6c4b3deca57009d93e946bbdbe2241dca9806d502f29dd3ee/bcrypt-5.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:6b8f520b61e8781efee73cba14e3e8c9556ccfb375623f4f97429544734545b4", size = 273375, upload-time = "2025-09-25T19:50:45.43Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -377,9 +326,12 @@ name = "cyclone"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
{ name = "click" },
|
{ name = "click" },
|
||||||
|
{ name = "cryptography" },
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "keyring" },
|
{ name = "keyring" },
|
||||||
|
{ name = "passlib", extra = ["bcrypt"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
@@ -393,6 +345,7 @@ dev = [
|
|||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pytest-randomly" },
|
||||||
]
|
]
|
||||||
sftp = [
|
sftp = [
|
||||||
{ name = "paramiko" },
|
{ name = "paramiko" },
|
||||||
@@ -403,15 +356,19 @@ sqlcipher = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "bcrypt", specifier = "<4.1" },
|
||||||
{ name = "click", specifier = ">=8.1,<9" },
|
{ name = "click", specifier = ">=8.1,<9" },
|
||||||
|
{ name = "cryptography", specifier = ">=49.0,<50" },
|
||||||
{ name = "fastapi", specifier = ">=0.110,<1" },
|
{ name = "fastapi", specifier = ">=0.110,<1" },
|
||||||
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
{ name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27,<1" },
|
||||||
{ name = "keyring", specifier = ">=25.0,<26" },
|
{ name = "keyring", specifier = ">=25.0,<26" },
|
||||||
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
{ name = "paramiko", marker = "extra == 'sftp'", specifier = ">=3.4,<6" },
|
||||||
|
{ name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" },
|
||||||
{ name = "pydantic", specifier = ">=2.6,<3" },
|
{ name = "pydantic", specifier = ">=2.6,<3" },
|
||||||
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
|
||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23,<1" },
|
||||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1" },
|
||||||
|
{ name = "pytest-randomly", marker = "extra == 'dev'", specifier = ">=4.1" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
|
{ name = "python-multipart", specifier = ">=0.0.9,<1" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||||
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
|
{ name = "sqlalchemy", specifier = ">=2.0,<3" },
|
||||||
@@ -714,6 +671,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "passlib"
|
||||||
|
version = "1.7.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
bcrypt = [
|
||||||
|
{ name = "bcrypt" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pluggy"
|
name = "pluggy"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -935,6 +906,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-randomly"
|
||||||
|
version = "4.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-dotenv"
|
name = "python-dotenv"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Local override for `docker compose up` testing on a host without root.
|
||||||
|
# Repoints the secrets at /tmp/cyclone-test-secrets so the operator
|
||||||
|
# (me, running as a non-root user) can verify the stack comes up
|
||||||
|
# healthy without needing sudo to create /etc/cyclone/secrets.
|
||||||
|
# Also remaps the frontend host port 8080 → 8090 so it doesn't collide
|
||||||
|
# with nocodb on this dev host.
|
||||||
|
#
|
||||||
|
# DO NOT ship this in production. In production, the secrets section
|
||||||
|
# in docker-compose.yml points at /etc/cyclone/secrets/ (host-managed,
|
||||||
|
# chmod 600, root:root) and no override is used.
|
||||||
|
secrets:
|
||||||
|
cyclone_db_key:
|
||||||
|
file: /tmp/cyclone-test-secrets/db.key
|
||||||
|
cyclone_admin_username:
|
||||||
|
file: /tmp/cyclone-test-secrets/admin_username
|
||||||
|
cyclone_admin_password:
|
||||||
|
file: /tmp/cyclone-test-secrets/admin_pw
|
||||||
|
|
||||||
|
services:
|
||||||
|
frontend:
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
name: cyclone
|
||||||
|
|
||||||
|
# Two-container production stack for Cyclone.
|
||||||
|
#
|
||||||
|
# backend FastAPI on python:3.11-slim-bookworm, internal port 8000.
|
||||||
|
# frontend nginx:1.27-alpine serving the built React SPA + reverse-
|
||||||
|
# proxying /api/* to the backend, host port 8080.
|
||||||
|
#
|
||||||
|
# Tag strategy:
|
||||||
|
# - Images are tagged semver from package.json / pyproject.toml.
|
||||||
|
# - Two aliases per image: `:latest` (set automatically by `docker
|
||||||
|
# compose build`) and `:stable` (manually promoted after a release
|
||||||
|
# has been running in prod for >=1 week).
|
||||||
|
# - Compose defaults to `:stable` so a fresh `docker compose build`
|
||||||
|
# never auto-rolls a new release.
|
||||||
|
# - Override with `TAG=0.0.9 docker compose up -d` to roll back.
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: cyclone-backend:${TAG:-stable}
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# SQLite path lives on the named `cyclone_db` volume.
|
||||||
|
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
|
||||||
|
# Wire the existing BackupService (SP17) to autostart on container boot.
|
||||||
|
CYCLONE_BACKUP_AUTOSTART: "1"
|
||||||
|
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
|
||||||
|
CYCLONE_BACKUP_RETENTION_DAYS: "14"
|
||||||
|
# Logging — JSON to stdout (collected by `docker logs`) and to the
|
||||||
|
# bind-mounted file (collected by host-side logrotate).
|
||||||
|
CYCLONE_LOG_LEVEL: "INFO"
|
||||||
|
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
|
||||||
|
CYCLONE_LOG_JSON: "1"
|
||||||
|
# Cookie signing key defaults to the SQLCipher key. Override in
|
||||||
|
# production if you want to rotate them independently.
|
||||||
|
CYCLONE_SECRET_KEY_FILE: "/run/secrets/cyclone_db_key"
|
||||||
|
CYCLONE_COOKIE_SECURE: "1"
|
||||||
|
# First-admin bootstrap — populated by scripts/cyclone-init.sh.
|
||||||
|
# The `_FILE` variants are the standard Docker-secret pattern:
|
||||||
|
# the env var points at the mounted secret file rather than
|
||||||
|
# embedding the secret in the compose file.
|
||||||
|
CYCLONE_ADMIN_USERNAME_FILE: "/run/secrets/cyclone_admin_username"
|
||||||
|
CYCLONE_ADMIN_PASSWORD_FILE: "/run/secrets/cyclone_admin_password"
|
||||||
|
# Bind 0.0.0.0 so the frontend container on the compose bridge
|
||||||
|
# network can reach us. The bridge network provides isolation —
|
||||||
|
# only the `frontend` service is on it; the host firewall still
|
||||||
|
# blocks anything that isn't on the LAN.
|
||||||
|
CYCLONE_HOST: "0.0.0.0"
|
||||||
|
secrets:
|
||||||
|
- cyclone_db_key
|
||||||
|
- cyclone_admin_username
|
||||||
|
- cyclone_admin_password
|
||||||
|
volumes:
|
||||||
|
- cyclone_db:/var/lib/cyclone/db
|
||||||
|
- cyclone_backups:/var/lib/cyclone/backups
|
||||||
|
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
|
||||||
|
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
|
||||||
|
- cyclone_logs:/var/log/cyclone
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
networks:
|
||||||
|
- cyclone_network
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: cyclone-frontend:${TAG:-stable}
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
# LAN-bind: only this port is published to the host. The operator
|
||||||
|
# is expected to firewall this to the LAN subnet (or rely on VPN
|
||||||
|
# for outside access). NO public TLS in v1.
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- cyclone_network
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
cyclone_db_key:
|
||||||
|
file: /etc/cyclone/secrets/db.key
|
||||||
|
cyclone_admin_username:
|
||||||
|
file: /etc/cyclone/secrets/admin_username
|
||||||
|
cyclone_admin_password:
|
||||||
|
file: /etc/cyclone/secrets/admin_pw
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cyclone_db:
|
||||||
|
cyclone_backups:
|
||||||
|
cyclone_prodfiles:
|
||||||
|
cyclone_sftp_staging:
|
||||||
|
cyclone_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cyclone_network:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,762 @@
|
|||||||
|
# Cyclone — Technical Architecture
|
||||||
|
|
||||||
|
> **Day-1 read for engineers joining Cyclone.** This is the top-level technical design: how the system fits together, what the boundaries are, and why it looks the way it does.
|
||||||
|
>
|
||||||
|
> **Companion docs:**
|
||||||
|
> - **Requirements:** [`docs/REQUIREMENTS.md`](REQUIREMENTS.md) — what Cyclone does (FRs + NFRs + DoD + traceability).
|
||||||
|
> - **Specs:** [`docs/superpowers/specs/`](superpowers/specs/) — design specs per sub-project (22 specs).
|
||||||
|
> - **Plans:** [`docs/superpowers/plans/`](superpowers/plans/) — TDD-shaped implementation plans per sub-project (27 plans).
|
||||||
|
>
|
||||||
|
> **Scope of this doc:** architecture and design. The spec files own per-SP design; the plan files own the task order. This doc explains the *whole* — the boundaries between modules, the data flow across them, the lifecycle of an inbound file from upload to reconciled claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System overview
|
||||||
|
|
||||||
|
Cyclone is a self-hosted X12 EDI claims-management suite for a single billing office. The first deployment target is Colorado Medicaid (one operator, one trading partner). The system is local-only on purpose: the backend binds to `127.0.0.1` and requires login (bcrypt + HttpOnly session cookie). The threat model is still a stolen or imaged drive, not a remote attacker — SQLCipher at rest and the macOS Keychain handle that. The auth boundary is the HTTP layer; the file-system posture is unchanged. The SP23 product fork changes the threat model to "remote operator on LAN."
|
||||||
|
|
||||||
|
Cyclone processes three X12 transaction pairs end-to-end:
|
||||||
|
|
||||||
|
| Inbound | Outbound | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| **837P** (005010X222A1) | 837P (resubmit) | Professional claim submission, acknowledgment tracking |
|
||||||
|
| **835** (005010X221A1) | — | ERA remittance ingestion, auto-reconciliation |
|
||||||
|
| **999 / TA1 / 270 / 271 / 277CA** | 999 (after submit) | Acknowledgments, eligibility, claim status |
|
||||||
|
|
||||||
|
The authoritative store is a single SQLite file at `~/.local/share/cyclone/cyclone.db` (or, with `sqlcipher3` installed and a Keychain entry, a SQLCipher-encrypted file at the same path). Every claim, remittance, audit event, and reconciliation decision is in that one file. The 6-year HIPAA retention expectation is met by automated daily encrypted backups (SP17) and a tamper-evident hash-chained audit log (SP11).
|
||||||
|
|
||||||
|
The whole stack is a single Python process (FastAPI + uvicorn on port 8000) plus a single Node process in dev (Vite on port 5173). No message broker, no separate worker, no database server. The v1 deployment model tolerates this because the operator is one person, the host is one machine, and the failure mode is "open the Activity log and check what broke."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Process & network topology
|
||||||
|
|
||||||
|
```
|
||||||
|
+---------------------+ +-----------------------+
|
||||||
|
| Browser | HTTP | Vite dev server |
|
||||||
|
| (localhost:5173) | <----> | (Node 20+) |
|
||||||
|
| | Vite | |
|
||||||
|
| React 18 + TanStack | proxy | src/ |
|
||||||
|
| Query, Zustand, | /api | - pages, components, |
|
||||||
|
| Radix UI, Tailwind | -----> | hooks, lib |
|
||||||
|
+---------------------+ +-----------------------+
|
||||||
|
|
|
||||||
|
| HTTP (localhost)
|
||||||
|
v
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
| Python process: `python -m cyclone serve` |
|
||||||
|
| |
|
||||||
|
| FastAPI app (uvicorn, 127.0.0.1:8000) |
|
||||||
|
| +-----------------------------------------------------+ |
|
||||||
|
| | cyclone.api:app (3,548 LOC) | |
|
||||||
|
| | - routers: claims, remits, providers, acks, | |
|
||||||
|
| | activity, inbox, upload, download, batches, | |
|
||||||
|
| | admin, scheduler, health, ta1_acks | |
|
||||||
|
| | - live-tail: NDJSON pubsub (pubsub.py) | |
|
||||||
|
| | - lifespan: init db -> seed SP9 -> start SP16 + | |
|
||||||
|
| | SP17 schedulers -> load PayerConfig cache | |
|
||||||
|
| +-----------------------------------------------------+ |
|
||||||
|
| | cyclone.store (facade, 2,423 LOC) | |
|
||||||
|
| | - add/get/list/manual_match/iter_claims/... | |
|
||||||
|
| | - delegates to: db.SessionLocal() | |
|
||||||
|
| +-----------------------------------------------------+ |
|
||||||
|
| | cyclone.parsers.* (X12 837P/835/999/TA1/270/271/ | |
|
||||||
|
| | 277CA: tokenize -> segmentize -> model -> validate | |
|
||||||
|
| | -> write to store) | |
|
||||||
|
| +-----------------------------------------------------+ |
|
||||||
|
| | cyclone.reconcile, scoring, batch_diff, audit_log, | |
|
||||||
|
| | backup_service, scheduler, db_crypto, secrets | |
|
||||||
|
| +-----------------------------------------------------+ |
|
||||||
|
+----------------------------------------------------------+
|
||||||
|
| | |
|
||||||
|
v v v
|
||||||
|
+----------------+ +-------------------+ +----------------+
|
||||||
|
| SQLite / | | macOS Keychain | | Trading |
|
||||||
|
| SQLCipher | | (keyring) | | partner SFTP |
|
||||||
|
| cyclone.db | | - SQLCipher key | | (paramiko, SP13)|
|
||||||
|
| (12 migrations)| | - SFTP password | | |
|
||||||
|
| | | - backup pass | | |
|
||||||
|
+----------------+ +-------------------+ +----------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
**One process, one file, three side-channels.** The Keychain (secrets) and SFTP (outbound to the payer) are side-channels; the database is the central state.
|
||||||
|
|
||||||
|
The `cyclone-pipeline/` sibling project (not in this repo) is a separate Python CLI that drives the full 7-phase round-trip: preflight → browser upload → parse verification → SFTP submit → TA1 wait → 999 wait → scan + report. It is not part of the in-process architecture; it sits alongside the backend and uses its API endpoints.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tech stack
|
||||||
|
|
||||||
|
### 3.1 Backend
|
||||||
|
|
||||||
|
| Layer | Technology | Version | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Language | Python | 3.11+ (3.13 in dev) | `requires-python = ">=3.11"` |
|
||||||
|
| Web framework | FastAPI | ≥0.110, <1 | `python -m cyclone serve` boots uvicorn |
|
||||||
|
| ASGI server | uvicorn[standard] | ≥0.27, <1 | `--host 127.0.0.1 --port 8000` |
|
||||||
|
| ORM | SQLAlchemy | ≥2.0, <3 | 2.x style; engine + SessionLocal() function-accessor |
|
||||||
|
| Validation | Pydantic | ≥2.6, <3 | v2 model_validator + ConfigDict |
|
||||||
|
| CLI | Click | ≥8.1, <9 | `cyclone` console script |
|
||||||
|
| YAML | PyYAML | ≥6.0, <7 | PayerConfig (SP9) |
|
||||||
|
| Secrets | keyring | ≥25.0, <26 | macOS Keychain on darwin |
|
||||||
|
| Crypto | cryptography | ≥49.0, <50 | AES-256-GCM for SP17 backups (hard dep) |
|
||||||
|
| SQLCipher (opt) | sqlcipher3 | ≥0.6, <1 | `pip install -e .[sqlcipher]` |
|
||||||
|
| SFTP (opt) | paramiko | ≥3.4, <6 | `pip install -e .[sftp]` |
|
||||||
|
| Tests | pytest + pytest-cov + pytest-asyncio + pytest-randomly | latest | 964 tests, 2026-06-23 |
|
||||||
|
| Test client | httpx | ≥0.27, <1 | dev only |
|
||||||
|
|
||||||
|
### 3.2 Frontend
|
||||||
|
|
||||||
|
| Layer | Technology | Version | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Build | Vite | latest | `npm run dev` (port 5173) |
|
||||||
|
| Language | TypeScript | latest | `tsc -b --noEmit` is the typecheck script |
|
||||||
|
| UI | React | 18.3.1 | function components + hooks |
|
||||||
|
| Routing | react-router-dom | 6.27.0 | SPA, 11 top-level routes |
|
||||||
|
| Data | @tanstack/react-query | 5.101+ | server-state cache |
|
||||||
|
| Client state | Zustand | 4.5.5 | small, ephemeral state (live tail, drill stack) |
|
||||||
|
| UI primitives | @radix-ui/* | latest | dialog, label, select, slot, tabs |
|
||||||
|
| Styling | Tailwind CSS | latest | utility-first |
|
||||||
|
| Icons | lucide-react | 0.453+ | |
|
||||||
|
| Toasts | sonner | 1.5+ | |
|
||||||
|
| Test runtime | vitest | 4.1.9 | Node 18+ has Response/fetch natively |
|
||||||
|
| Test DOM | happy-dom | 20.10+ | not jsdom (NFR-2) |
|
||||||
|
| Component tests | @testing-library/react | 16.3+ | 73 test files |
|
||||||
|
|
||||||
|
### 3.3 External / operational
|
||||||
|
|
||||||
|
- **macOS Keychain** — secret store for SQLCipher key, SFTP password, backup passphrase. No secrets on disk in plaintext (NFR-4).
|
||||||
|
- **X12 005010 implementation guides** — the parsers follow the CMS / X12 published guides. The 837P parser is the most heavily tested; 835 is the second; 999/TA1/270/271/277CA are lighter.
|
||||||
|
- **One trading partner** — Colorado Medicaid via the Gainwell SFTP endpoint. Other payers are configured via `cyclone/providers.py` `PayerConfig` rows (SP9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Backend architecture
|
||||||
|
|
||||||
|
### 4.1 Package layout
|
||||||
|
|
||||||
|
The `cyclone` package is a single namespace rooted at `backend/src/cyclone/`. 22 top-level modules + 4 subpackages. The store (SP21 in-flight split) is the largest; everything else is small and focused.
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/cyclone/
|
||||||
|
├── __main__.py — entry point; `python -m cyclone` → CLI, `python -m cyclone serve` → uvicorn
|
||||||
|
├── __init__.py — `__version__`, package init
|
||||||
|
├── api.py — FastAPI app, route includes, lifespan (3,548 LOC — the only large file)
|
||||||
|
├── api_helpers.py — request/response formatters, error → HTTP mapping
|
||||||
|
├── api_routers/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── acks.py — 999/TA1/271/277CA acknowledgments
|
||||||
|
│ ├── admin.py — admin-only endpoints (validate-provider, scheduler, backup)
|
||||||
|
│ ├── health.py — GET /api/health (SP19 deep snapshot)
|
||||||
|
│ └── ta1_acks.py — TA1-specific (split from acks for the SP3 lifecycle)
|
||||||
|
├── audit_log.py — SHA-256 hash-chained append-only log (SP11)
|
||||||
|
├── backup.py — SP17 primitives: derive_key, encrypt, decrypt, BackupFile
|
||||||
|
├── backup_scheduler.py — SP17 BackupScheduler wrapper
|
||||||
|
├── backup_service.py — SP17 coordinator: create/list/restore/verify/prune
|
||||||
|
├── batch_diff.py — 837 ↔ 835 diff (SP4)
|
||||||
|
├── clearhouse/
|
||||||
|
│ └── __init__.py — Clearhouse, SftpClient (SP9, SP13)
|
||||||
|
├── cli.py — Click CLI: `cyclone serve`, `cyclone validate-npi`, `cyclone backup`, etc.
|
||||||
|
├── db.py — SQLAlchemy engine, SessionLocal, ORM models, init_db, reinit_engine
|
||||||
|
├── db_crypto.py — SP12 SQLCipher connect-creator + is_encryption_enabled()
|
||||||
|
├── db_migrate.py — runs the 12 SQL migrations in order
|
||||||
|
├── edi/
|
||||||
|
│ └── filenames.py — X12 filename convention helpers (SP9)
|
||||||
|
├── inbox_lanes.py — 5-lane Inbox computation (SP14)
|
||||||
|
├── inbox_state.py — claim ↔ inbox state mapping helpers
|
||||||
|
├── inbox_state_277ca.py — SP10 277CA inbox state integration
|
||||||
|
├── logging_config.py — SP18 structured JSON + PII scrubber
|
||||||
|
├── migrations/ — 12 SQL files (0001_initial through 0012_backups)
|
||||||
|
├── npi.py — SP20 NPI Luhn + Tax ID validation
|
||||||
|
├── parsers/ — X12 parser subpackage
|
||||||
|
│ ├── parse_837.py — 837P ingest
|
||||||
|
│ ├── parse_835.py — 835 ingest
|
||||||
|
│ ├── parse_999.py / parse_ta1.py
|
||||||
|
│ ├── parse_270.py / parse_271.py
|
||||||
|
│ ├── parse_277ca.py — SP10 277CA ingest
|
||||||
|
│ ├── models*.py — one Pydantic model module per transaction type
|
||||||
|
│ ├── segments.py — X12 segment tokenizer
|
||||||
|
│ ├── validator.py — 837P rule registry (R-codes)
|
||||||
|
│ ├── validator_835.py — 835 validator
|
||||||
|
│ ├── serialize_837.py — SP8 outbound 837P serializer
|
||||||
|
│ ├── serialize_270.py / serialize_999.py
|
||||||
|
│ ├── cas_codes.py — CAS reason-code table
|
||||||
|
│ ├── batch_ack_builder.py
|
||||||
|
│ ├── writer.py / writer_835.py
|
||||||
|
│ └── exceptions.py
|
||||||
|
├── payers.py — Payer ORM row accessor (SP9)
|
||||||
|
├── providers.py — Payer / Clearhouse / Provider ORM row DTOs (SP9)
|
||||||
|
├── pubsub.py — NDJSON live-tail EventBus
|
||||||
|
├── reconcile.py — 835 → 837 auto-reconciliation engine
|
||||||
|
├── scheduler.py — SP16 MFT polling scheduler
|
||||||
|
├── scoring.py — 4-field lane scoring (40/25/20/15, hard-coded — backlog item)
|
||||||
|
├── secrets.py — Keychain access wrapper (SP9)
|
||||||
|
├── security.py — security headers, HealthSnapshot, deep health probe (SP19)
|
||||||
|
└── store.py — CycloneStore facade (2,423 LOC; SP21 in-flight split)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Module responsibility map (one line each)
|
||||||
|
|
||||||
|
| Module | Responsibility |
|
||||||
|
|---|---|
|
||||||
|
| `__main__` | Dispatch `serve` vs CLI; honor `CYCLONE_PORT` / `CYCLONE_RELOAD`; emit `AUTH_DISABLED` WARNING if `cyclone.auth.deps.AUTH_DISABLED` is True at boot |
|
||||||
|
| `api` | FastAPI app, lifespan, route includes, 999/270/271 endpoints, exception → HTTP |
|
||||||
|
| `auth.*` | HTTP-layer login (bcrypt + HttpOnly session cookie). Submodules: `deps` (the `matrix_gate` FastAPI dependency, `AUTH_DISABLED` flag), `routes` (`/api/auth/login`, `/api/auth/logout`, `/api/auth/me`), `users`, `sessions`, `permissions` (role matrix), `bootstrap` (env-var first-admin), `cli` (`cyclone admin create-user` / `rotate-password`) |
|
||||||
|
| `api_helpers` | Error → status code mapping, common response shapes |
|
||||||
|
| `audit_log` | Append + verify a SHA-256 hash-chained log (SP11) |
|
||||||
|
| `backup` | Pure crypto + file I/O for SP17 backup format |
|
||||||
|
| `backup_scheduler` | Wrap `BackupService` in a `tick()` loop (SP17) |
|
||||||
|
| `backup_service` | Create / list / restore / verify / prune (SP17) |
|
||||||
|
| `batch_diff` | Pairwise diff of two 837 batches (SP4) |
|
||||||
|
| `clearhouse` | `Clearhouse` + `SftpClient` (SP9 stub, SP13 paramiko) |
|
||||||
|
| `cli` | `cyclone` console script: serve, validate-npi, backup, list-batches, … |
|
||||||
|
| `db` | Engine factory, `SessionLocal()`, all ORM rows, `init_db()`, `reinit_engine()` |
|
||||||
|
| `db_crypto` | SQLCipher branch + `is_encryption_enabled()` (SP12) |
|
||||||
|
| `db_migrate` | Walk `migrations/*.sql` in order, apply pending |
|
||||||
|
| `edi.filenames` | Parse / construct X12 file names (SP9) |
|
||||||
|
| `inbox_lanes` | Compute the 5 lanes (rejected, payer_rejected, candidates, unmatched, done_today) |
|
||||||
|
| `inbox_state` | Pure helpers for state ↔ lane mapping |
|
||||||
|
| `inbox_state_277ca` | SP10 277CA state integration (monotonic rule) |
|
||||||
|
| `logging_config` | SP18 JSON formatter + PII scrubber |
|
||||||
|
| `npi` | SP20 pure validators (Luhn over 80840+body, EIN prefix check) |
|
||||||
|
| `parsers.*` | X12 transaction set parsers (one per type) |
|
||||||
|
| `payers` / `providers` | SP9 ORM row DTOs |
|
||||||
|
| `pubsub` | In-process NDJSON EventBus for live tail |
|
||||||
|
| `reconcile` | 835 → 837 auto-match + per-line CAS reconciliation |
|
||||||
|
| `scheduler` | SP16 MFT polling scheduler (per-payer tick) |
|
||||||
|
| `scoring` | 4-field lane scoring weights (40/25/20/15) |
|
||||||
|
| `secrets` | Keychain get/set/delete via `keyring` |
|
||||||
|
| `security` | Security headers, deep `HealthSnapshot` (SP19) |
|
||||||
|
| `store` | `CycloneStore` facade: `add` / `get` / `list` / `manual_match` / `iter_*` / `recent_activity` |
|
||||||
|
|
||||||
|
### 4.3 The `CycloneStore` facade
|
||||||
|
|
||||||
|
The store is the single read/write surface for the database. Every endpoint that mutates state goes through `cyclone.store` (eventually the `cyclone.store.*` subpackage after SP21 lands). The facade pattern preserves public API:
|
||||||
|
|
||||||
|
- `store.add(record)` — add a parsed batch
|
||||||
|
- `store.get(batch_id)`, `get_batch(batch_id)`, `list(limit)`, `all()`
|
||||||
|
- `store.manual_match(claim_id, remit_id)` — raises `AlreadyMatchedError` (→ 409)
|
||||||
|
- `store.manual_unmatch(claim_id)` — raises `NotMatchedError` (→ 409)
|
||||||
|
- `store.iter_claims(...)`, `iter_remittances(...)`
|
||||||
|
- `store.distinct_providers()`, `recent_activity(limit)`
|
||||||
|
- `store.list_unmatched(kind="both")`
|
||||||
|
- `store.export_*()` — CSV/JSON dumpers
|
||||||
|
|
||||||
|
All persistence flows through SQLAlchemy sessions via `db.SessionLocal()()`. The engine is single-connection-per-request via FastAPI dependency injection. SP21 splits `store.py` (2,423 LOC) into a `cyclone/store/` subpackage with one module per domain (batches, inbox, claim_detail, acks, backups, providers, etc.) and a thin facade that delegates every method to its domain module. After SP21, the public API of `cyclone.store` is unchanged; the only thing that changes is the file layout.
|
||||||
|
|
||||||
|
### 4.4 The parser pipeline
|
||||||
|
|
||||||
|
All inbound files follow the same 5-stage pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
X12 .txt on disk
|
||||||
|
↓
|
||||||
|
[1] tokenize (parsers/segments.py: split on ~, then ^, then *:)
|
||||||
|
↓
|
||||||
|
[2] segmentize (build Segment records with elements + repeats)
|
||||||
|
↓
|
||||||
|
[3] model (parsers/models_*.py: Pydantic models per transaction type)
|
||||||
|
↓
|
||||||
|
[4] validate (parsers/validator.py: R-codes; raise ParseError on fatal)
|
||||||
|
↓
|
||||||
|
[5] write (store.add(record); record → DB rows)
|
||||||
|
```
|
||||||
|
|
||||||
|
The 837P validator has the most rules (R020, R021, R034, R035, R200, R210 — see [837P parser spec](superpowers/specs/2026-06-19-cyclone-837p-parser-design.md) §2.4 for the registry). The 835 validator is simpler (no R-codes, just a model completeness check + a status_code allowlist). 999/TA1/270/271/277CA have no validators — they trust the upstream.
|
||||||
|
|
||||||
|
The writer is responsible for: (a) creating the `batches` row, (b) creating N `claims` / `remittances` / `cas_adjustments` rows, (c) emitting an `activity_events` row, (d) for 837P, the synthetic claim-status from the 999 envelope, (e) for 835, triggering `reconcile.apply_payment` to auto-match. If any step raises, the entire batch is rolled back (single SQLAlchemy transaction).
|
||||||
|
|
||||||
|
### 4.5 The reconciliation engine
|
||||||
|
|
||||||
|
`cyclone.reconcile` is a pure-functions module. The store calls into it for every 835 ingest:
|
||||||
|
|
||||||
|
- `apply_payment(claim, remit, strategy) -> ApplyIntent` — happy path; returns the new `Match` row + state transition
|
||||||
|
- `apply_reversal(...)` — handles a reversal 835 (the original payment + the reversal both stay in the DB; the `is_reversal` flag on the `Match` row disambiguates)
|
||||||
|
- `recompute_line_totals(claim)` — for SP7's per-line audit, when an 835 carries CAS adjustments
|
||||||
|
|
||||||
|
The engine is pure (no DB access). The store orchestrates the SQL writes after a successful `apply_*` returns. The 4-field scoring (40/25/20/15 for charge amount / NPI / patient ID / service date) lives in `cyclone.scoring` and is invoked by `inbox_lanes.compute_lanes` for the `candidates` and `unmatched` lanes.
|
||||||
|
|
||||||
|
### 4.6 The audit chain
|
||||||
|
|
||||||
|
`cyclone.audit_log` is a separate, append-only log table (`audit_log` — distinct from `activity_events` which is the un-chained `/activity` feed). Every event type the system can emit has a key in the chain:
|
||||||
|
|
||||||
|
- `claim.submitted`, `claim.rejected`, `claim.matched`, `claim.unmatched`
|
||||||
|
- `claim.payer_rejected`, `claim.payer_rejected_acknowledged`
|
||||||
|
- `clearhouse.submitted`
|
||||||
|
- `key.rotated` (SP15), `backup.created`, `backup.restored`, `backup.verified` (SP17)
|
||||||
|
- `api.request_rejected` (SP19)
|
||||||
|
|
||||||
|
Each row carries the SHA-256 of `(prev_hash || kind || actor || payload_json || ts)`. The first row has `prev_hash = 0x00 * 32`. `audit_log.verify_chain()` walks the table top-to-bottom, recomputing each hash, and raises `AuditChainError` on the first mismatch with the row id and the expected vs. actual hash. SP19's deep health probe calls `verify_chain()` on every `/api/health` request so a broken chain surfaces in the operator's status badge within one tick.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Frontend architecture
|
||||||
|
|
||||||
|
### 5.1 Tech stack
|
||||||
|
|
||||||
|
See §3.2. The frontend is a Vite SPA with TypeScript strict mode, Tailwind utility classes, and Radix primitives. There is no SSR, no Next.js, no router data-loading — every page is a function component that calls `useQuery` and renders.
|
||||||
|
|
||||||
|
### 5.2 Route map
|
||||||
|
|
||||||
|
| Path | Page | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/` | `Dashboard` | KPI tiles + ticker tape + recent activity |
|
||||||
|
| `/claims` | `Claims` | Paginated claim list, filters, drill to claim drawer |
|
||||||
|
| `/remittances` | `Remittances` | Paginated remit list, drill to remit drawer |
|
||||||
|
| `/providers` | `Providers` | Provider rollup (NPIs, charge totals) |
|
||||||
|
| `/activity` | `ActivityLog` | Append-only `activity_events` feed (NDJSON) |
|
||||||
|
| `/upload` | `Upload` | Drag-drop or click to upload an 837P / 835 / 999 / 277CA |
|
||||||
|
| `/reconciliation` | `ReconciliationPage` | Cross-claim reconciliation view (4-field score) |
|
||||||
|
| `/inbox` | `Inbox` | 5-lane inbox (SP14) with BulkBar |
|
||||||
|
| `/acks` | `Acks` | 999/TA1/270/271/277CA acknowledgment list |
|
||||||
|
| `/batches` | `Batches` | Batch list with totals + validation summary |
|
||||||
|
| `/batch-diff` | `BatchDiff` | Pairwise 837 batch diff |
|
||||||
|
| `*` | `NotFound` | 404 |
|
||||||
|
|
||||||
|
### 5.3 State management
|
||||||
|
|
||||||
|
Two layers:
|
||||||
|
|
||||||
|
- **Server state** — `@tanstack/react-query`. Every page uses one or more `useQuery` calls. The query key shape is `["domain", id?, filters?]`. Mutations use `useMutation` and invalidate the relevant keys. There is no global refetch strategy; each page controls its own stale time.
|
||||||
|
|
||||||
|
- **Client state** — `zustand`. Two stores:
|
||||||
|
- `tail-store.ts` — the live-tail connection state (reconnecting, last event id, subscriber counts). Powers the bottom-right TailStatusPill.
|
||||||
|
- `DrillStackProvider` (in `src/components/drill/`) — the SP22 universal-drilldown stack. A LIFO of drill contexts (claim → claim line → match → CAS adjustment) so the back button works across drawers.
|
||||||
|
|
||||||
|
There is no Redux, no Context for global state, no MobX. The combination of react-query for server state + zustand for client state is intentional — see [universal-drilldown spec](superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md) for the rationale.
|
||||||
|
|
||||||
|
### 5.4 Live tail (NDJSON pubsub)
|
||||||
|
|
||||||
|
The Activity feed and the TailStatusPill share a single connection: `GET /api/tail?since=<last_event_id>`. The server returns `Content-Type: application/x-ndjson` and streams one JSON object per line. Each line is one of:
|
||||||
|
|
||||||
|
- `{kind: "heartbeat", ts: "..."}` — every 5s when no events
|
||||||
|
- `{kind: "event", id: <int>, event_type: "...", payload: {...}}` — when something happens
|
||||||
|
- `{kind: "error", message: "..."}` — server-initiated close (rare)
|
||||||
|
|
||||||
|
The client uses `fetch` + `ReadableStream` to consume the body and dispatches each line to the relevant `useQuery` cache via `queryClient.setQueryData`. The connection auto-reconnects on `error` with exponential backoff. See [live-tail spec](superpowers/specs/2026-06-20-cyclone-live-tail-design.md) for the wire format and reconnection policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Data model
|
||||||
|
|
||||||
|
### 6.1 Migrations 0001–0014
|
||||||
|
|
||||||
|
The schema evolves in ordered SQL files under `backend/src/cyclone/migrations/`. Each file starts with `-- version: N` and is applied in order by `db_migrate.py`. There is no migration framework — `db_migrate.py` is a 30-line file that checks the `schema_version` row and applies everything newer. The first 12 ship on `main`; 13 + 14 are the auth migrations that landed in `main` on 2026-06-23.
|
||||||
|
|
||||||
|
| # | File | What it adds |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `0001_initial.sql` | `batches`, `claims`, `remittances`, `cas_adjustments`, `matches`, `activity_events` (6 tables) |
|
||||||
|
| 2 | `0002_acks.sql` | `acks` table (999/271) |
|
||||||
|
| 3 | `0003_drop_claims_remits_unique_constraints.sql` | Spec-bug fix: drop the UNIQUE constraints that prevented reversal flows |
|
||||||
|
| 4 | `0004_rejections_and_state_history.sql` | `rejections` table + `claim_state_history` |
|
||||||
|
| 5 | `0005_create_ta1_acks.sql` | `ta1_acks` table |
|
||||||
|
| 6 | `0006_line_reconciliation.sql` | `line_reconciliations` (SP7) |
|
||||||
|
| 7 | `0007_providers_payers_clearhouse.sql` | `providers`, `payers`, `clearhouses` (SP9) |
|
||||||
|
| 8 | `0008_payer_rejected_columns.sql` | `claim.payer_rejected_*` columns (SP10) |
|
||||||
|
| 9 | `0009_audit_log.sql` | `audit_log` hash-chained table (SP11) |
|
||||||
|
| 10 | `0010_payer_rejected_acknowledged.sql` | `claim.payer_rejected_acknowledged_*` columns (SP14 hand-off) |
|
||||||
|
| 11 | `0011_processed_inbound_files.sql` | `processed_inbound_files` idempotency table (SP16) |
|
||||||
|
| 12 | `0012_backups.sql` | `db_backups` table (SP17) |
|
||||||
|
| 13 | `0013_auth_users_and_sessions.sql` | `users` + `sessions` tables, `users.password_hash` (bcrypt) — landed 2026-06-23 |
|
||||||
|
| 14 | `0014_audit_log_user_id.sql` | `audit_log.user_id` FK to `users.id` (forward reference to 0013) — landed 2026-06-23 |
|
||||||
|
|
||||||
|
The SP22 parse-then-decide migrations (drop claims UNIQUE constraint + relax PK to `(batch_id, id)`) are renumbered to 0015 + 0016 on the `claims-unique-fix` worktree so the auth migrations stay at the front of the chain; they'll keep those numbers when SP22 lands.
|
||||||
|
|
||||||
|
### 6.2 ERD (high level)
|
||||||
|
|
||||||
|
```
|
||||||
|
batches (1) ─┬─< (N) claims ─┬─< (N) matches >─ (N) ─┬─ (1) remittances ─< (N) cas_adjustments
|
||||||
|
│ │ │
|
||||||
|
│ └─< (N) line_reconciliations (SP7)
|
||||||
|
│ └─< (N) rejections / claim_state_history
|
||||||
|
│
|
||||||
|
├─< (N) acks (999/271)
|
||||||
|
├─< (N) ta1_acks
|
||||||
|
└─< (N) activity_events
|
||||||
|
|
||||||
|
claims (1) ─< (N) line_reconciliations ─> (1) cas_adjustments
|
||||||
|
|
||||||
|
audit_log (no FKs — append-only, hash-chained)
|
||||||
|
db_backups (no FKs — directory + filename, status row)
|
||||||
|
processed_inbound_files (no FKs — path + hash, idempotency key)
|
||||||
|
|
||||||
|
providers (1) ─< (N) claims
|
||||||
|
payers (1) ─< (N) claims
|
||||||
|
clearhouses (1) ─< (N) payers
|
||||||
|
```
|
||||||
|
|
||||||
|
The `batches` row is the parent of every parsed X12 file. A batch is the unit of atomicity for parsing: either every claim/remit/ack in the file lands, or the file rolls back. The `claims.state` column drives the lifecycle (§6.3); the `matches` table is the join that says "this 835 paid this 837" (with a `strategy` field for audit: auto vs. manual).
|
||||||
|
|
||||||
|
### 6.3 Claim lifecycle (7 states)
|
||||||
|
|
||||||
|
The `claims.state` column is the canonical state of a claim in the operator's workflow:
|
||||||
|
|
||||||
|
```
|
||||||
|
submitted ──> accepted ──> paid
|
||||||
|
│ │ │
|
||||||
|
│ │ └─> reversed
|
||||||
|
│ │
|
||||||
|
│ └─> denied ──> appealed ──> paid
|
||||||
|
│
|
||||||
|
└─> rejected (envelope 999)
|
||||||
|
│
|
||||||
|
└─> payer_rejected (277CA A4/A6/A7)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `submitted` — 837P ingested, awaiting 999 ACK
|
||||||
|
- `accepted` — 999 ACK came back clean; sitting in the trading partner's queue
|
||||||
|
- `paid` — 835 matched, payment posted
|
||||||
|
- `reversed` — 835 reversal applied (the original payment + the reversal are both preserved; the `state` reflects the most recent)
|
||||||
|
- `denied` — 835 came back with a denial status code (CAS group CO)
|
||||||
|
- `appealed` — operator flagged for appeal workflow
|
||||||
|
- `rejected` — envelope-level 999 rejection (syntactic / structural)
|
||||||
|
- `payer_rejected` — semantic 277CA rejection (A4/A6/A7 — claim accepted by envelope but rejected at the line level). SP10 introduced this state and the `claim.payer_rejected_*` columns.
|
||||||
|
|
||||||
|
State transitions are explicit functions in `cyclone.store` (e.g. `mark_accepted`, `mark_paid`, `mark_rejected`). Each transition emits an `activity_events` row and, for the security-relevant ones, an `audit_log` row.
|
||||||
|
|
||||||
|
### 6.4 Audit chain integrity
|
||||||
|
|
||||||
|
The `audit_log` table is a separate chain from `activity_events`:
|
||||||
|
|
||||||
|
- `activity_events` — un-chained, powers the `/activity` feed. The activity feed can be repopulated from the underlying state if rows are missing; integrity is informational.
|
||||||
|
- `audit_log` — SHA-256 hash-chained, powers compliance / forensics. A row cannot be deleted without breaking the chain. `audit_log.verify_chain()` is the integrity check; the SP19 deep health probe runs it on every `/api/health` call.
|
||||||
|
|
||||||
|
The two are intentionally separate because they have different retention and integrity requirements. The convention-vs-trigger tradeoff is documented in the [SP11 spec](superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md) §7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Data flow
|
||||||
|
|
||||||
|
### 7.1 File ingest (manual upload)
|
||||||
|
|
||||||
|
```
|
||||||
|
Operator ──drag-drop──> /upload (React)
|
||||||
|
│
|
||||||
|
│ POST /api/upload (multipart, .txt file)
|
||||||
|
v
|
||||||
|
FastAPI route
|
||||||
|
│
|
||||||
|
│ save to /tmp/cyclone-uploads/<uuid>.txt
|
||||||
|
v
|
||||||
|
parser.dispatch(kind) (auto-detect 837/835/999/TA1/270/271/277CA by ISA segment)
|
||||||
|
│
|
||||||
|
│ (the 5-stage pipeline from §4.4)
|
||||||
|
v
|
||||||
|
CycloneStore.add(record) (single SQLAlchemy transaction)
|
||||||
|
│
|
||||||
|
├─> emit activity_events row
|
||||||
|
├─> for 835: trigger reconcile.apply_payment
|
||||||
|
├─> for 999/TA1/277CA: mark the source claim
|
||||||
|
└─> commit
|
||||||
|
│
|
||||||
|
│ emit NDJSON event on the pubsub bus
|
||||||
|
v
|
||||||
|
live-tail subscribers (Activity feed, TailStatusPill)
|
||||||
|
```
|
||||||
|
|
||||||
|
The HTTP response is the new `batch_id` + a counts summary (e.g. `{"batch_id": "...", "claims": 12, "remittances": 0, "validation_issues": 0}`). The 999 is the only case where the response carries an ack payload — the operator can read the parsed 999 directly without a second request.
|
||||||
|
|
||||||
|
### 7.2 File ingest (SFTP polling — SP16)
|
||||||
|
|
||||||
|
```
|
||||||
|
every N seconds (default 30s, per-payer config):
|
||||||
|
Scheduler.tick()
|
||||||
|
│
|
||||||
|
├─> for each clearhouse:
|
||||||
|
│ ├─> SftpClient.list_inbound() (paramiko, SP13)
|
||||||
|
│ ├─> for each new .x12 file:
|
||||||
|
│ │ ├─> check processed_inbound_files (idempotency)
|
||||||
|
│ │ ├─> SftpClient.read_file(path) (to /tmp/...)
|
||||||
|
│ │ ├─> parser.dispatch(...)
|
||||||
|
│ │ ├─> CycloneStore.add(record)
|
||||||
|
│ │ ├─> INSERT processed_inbound_files (path, sha256, ts)
|
||||||
|
│ │ └─> emit activity_events row
|
||||||
|
│ └─> log tick result (count, errors, ms)
|
||||||
|
└─> heartbeat to /api/health (SP19 reports last tick ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
The idempotency table is the linchpin: a crash between "read file" and "INSERT processed" results in the same file being processed again on the next tick. The 5-step ordering (SELECT → INSERT side-effects → INSERT idempotency; IntegrityError as the correctness backstop) is detailed in the [SP16 spec](superpowers/specs/2026-06-23-cyclone-sp16-mft-polling-design.md) §3.4.
|
||||||
|
|
||||||
|
### 7.3 File ingest (manual upload, replayed by pipeline)
|
||||||
|
|
||||||
|
`cyclone-pipeline/` (sibling project) does the same as the operator dragging-and-dropping — it `POST`s to `/api/upload` with the file. There's no separate "pipeline" ingestion path. The pipeline agent is a thin wrapper that adds preflight + SFTP submit + wait-for-999 around the Cyclone API.
|
||||||
|
|
||||||
|
### 7.4 Auto-match on 835 ingest
|
||||||
|
|
||||||
|
When an 835 lands, the writer calls `reconcile.apply_payment(claim, remit, strategy)` for each `(claim, remit)` pair in the file. The strategy is one of:
|
||||||
|
|
||||||
|
- `auto:pcn_npi_charge` — patient_control_number + provider_npi + charge_amount match (the strongest)
|
||||||
|
- `auto:pcn_charge` — patient_control_number + charge_amount (the most common in practice)
|
||||||
|
- `manual` — the operator uses `/reconciliation` to pair by hand; the strategy is recorded for audit
|
||||||
|
|
||||||
|
Each auto-match creates a `matches` row + transitions the claim state + emits an `activity_events` row + emits an `audit_log` row. The match rate is visible in the Dashboard KPI tile.
|
||||||
|
|
||||||
|
### 7.5 Outbound 837P (resubmit)
|
||||||
|
|
||||||
|
Two paths:
|
||||||
|
|
||||||
|
- **Single claim** — `GET /api/claims/{id}/serialize-837` (SP8). The serializer round-trips the claim through `serialize_837.py`, validates with the R-code registry, and returns the X12 text. The Claim drawer's "Download 837" button calls this and triggers a browser download.
|
||||||
|
- **Bundle ZIP** — `POST /api/claims/resubmit?download=zip` (SP8). The serializer rebuilds N 837P files (one per claim in the request), zips them with a manifest, and returns the ZIP. The Inbox BulkBar's "Resubmit selected" calls this.
|
||||||
|
|
||||||
|
Both paths are read-only against the DB (they don't mutate the claim state). The operator is expected to download the regenerated file, then submit it manually via SFTP (or via `cyclone-pipeline run`).
|
||||||
|
|
||||||
|
### 7.6 Acknowledgement ingestion (999, TA1, 277CA)
|
||||||
|
|
||||||
|
These are the three "ack" transaction types the operator's trading partner sends back after a submission:
|
||||||
|
|
||||||
|
- **999** — functional ack (envelope-level). Triggers `claim.state = accepted | rejected`.
|
||||||
|
- **TA1** — interchange ack. Triggers an activity_events row; no state change.
|
||||||
|
- **277CA** — claim status (line-level). Triggers `claim.payer_rejected` if any line is A4/A6/A7. The `claim.payer_rejected_*` columns carry the status code + the 277CA's batch id for the audit trail.
|
||||||
|
|
||||||
|
All three are ingested via the same `/api/upload` path; the parser auto-detects the transaction type from the ISA segment. The SP10 spec describes the monotonic rule: a later 277CA cannot clear a previous rejection. This is the regulatory guarantee the SP11 audit chain relies on.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. API surface
|
||||||
|
|
||||||
|
### 8.1 REST endpoints (by domain)
|
||||||
|
|
||||||
|
The full list is in [REQUIREMENTS.md §6.3 traceability matrix](REQUIREMENTS.md). The structure is:
|
||||||
|
|
||||||
|
```
|
||||||
|
/api/health (SP19 deep health probe)
|
||||||
|
/api/tail?since=<id> (NDJSON live tail)
|
||||||
|
|
||||||
|
/api/upload (multipart, any X12 type)
|
||||||
|
/api/parse-837 (parse + return Pydantic, do not persist — pipeline agent)
|
||||||
|
|
||||||
|
/api/batches (list, with totals + validation)
|
||||||
|
/api/batches/{id} (detail)
|
||||||
|
/api/batches/{id}/diff?against={other_id} (batch_diff)
|
||||||
|
/api/batches/{id}/serialize-837 (SP8 — round-trip)
|
||||||
|
|
||||||
|
/api/claims (list, paginated, filterable)
|
||||||
|
/api/claims/{id} (detail, with remits + matches + line_recons)
|
||||||
|
/api/claims/{id}/serialize-837 (SP8)
|
||||||
|
/api/claims/resubmit?download=zip (SP8 bulk)
|
||||||
|
|
||||||
|
/api/remittances (list)
|
||||||
|
/api/remittances/{id} (detail)
|
||||||
|
|
||||||
|
/api/providers (rollup)
|
||||||
|
/api/providers/{npi} (single provider, all claims)
|
||||||
|
|
||||||
|
/api/activity?since=<id> (un-chained feed, paginated)
|
||||||
|
|
||||||
|
/api/inbox (SP14 5-lane view, filterable)
|
||||||
|
/api/inbox/payer-rejected/acknowledge (SP10 + SP14)
|
||||||
|
|
||||||
|
/api/acks (999/271/277CA list)
|
||||||
|
/api/acks/{id} (detail)
|
||||||
|
|
||||||
|
/api/ta1-acks (TA1 list)
|
||||||
|
|
||||||
|
/api/admin/validate-provider?npi=&tax_id= (SP20)
|
||||||
|
/api/admin/db/rotate-key (SP15)
|
||||||
|
/api/admin/db/fingerprint (SP12)
|
||||||
|
/api/admin/backup/create (SP17)
|
||||||
|
/api/admin/backup/list (SP17)
|
||||||
|
/api/admin/backup/{id}/restore?confirm=true (SP17, two-step)
|
||||||
|
/api/admin/backup/{id}/verify (SP17)
|
||||||
|
/api/admin/scheduler/status (SP16)
|
||||||
|
/api/admin/scheduler/tick (SP16 — manual trigger)
|
||||||
|
/api/admin/scheduler/start (SP16)
|
||||||
|
/api/admin/scheduler/stop (SP16)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Live-tail NDJSON format
|
||||||
|
|
||||||
|
`GET /api/tail?since=<last_event_id>` returns `Content-Type: application/x-ndjson` and streams:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"kind":"event","id":1234,"event_type":"claim.submitted","payload":{"claim_id":"...","batch_id":"...","ts":"2026-06-23T..."}}
|
||||||
|
{"kind":"event","id":1235,"event_type":"claim.matched","payload":{"claim_id":"...","remit_id":"...","strategy":"auto:pcn_npi_charge"}}
|
||||||
|
{"kind":"heartbeat","ts":"2026-06-23T..."}
|
||||||
|
{"kind":"event","id":1236,"event_type":"claim.payer_rejected","payload":{"claim_id":"...","status_code":"A7","source_277ca_id":"..."}}
|
||||||
|
```
|
||||||
|
|
||||||
|
Heartbeats every 5s when no events. The client reconnects on `error` with exponential backoff capped at 30s. See [live-tail spec](superpowers/specs/2026-06-20-cyclone-live-tail-design.md) for the full wire format.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Cross-cutting concerns
|
||||||
|
|
||||||
|
### 9.1 Logging (SP18)
|
||||||
|
|
||||||
|
`cyclone.logging_config` configures a single JSON formatter that:
|
||||||
|
|
||||||
|
- Emits one JSON object per line to stdout (12-factor)
|
||||||
|
- Includes `ts`, `level`, `logger`, `msg`, `module`, `line`, `process`, `thread`
|
||||||
|
- Scrubs PHI/PII fields by key name (`npi`, `member_id`, `patient_name`, `ssn_last4`, `charge_amount`, `paid_amount`, `tax_id`) — replaced with `***`
|
||||||
|
- Redacts secrets by string match (anything matching `password=...&` or `Bearer ...`)
|
||||||
|
|
||||||
|
The PII scrubber is a best-effort defense-in-depth, not a security boundary. Real PHI protection comes from the SQLCipher layer (SP12) and the Keychain (no secrets in env). The scrubber exists so an operator tailing logs to debug doesn't accidentally paste a log line into a chat.
|
||||||
|
|
||||||
|
### 9.2 Audit chain (SP11)
|
||||||
|
|
||||||
|
See §4.6. The chain is checked on every `/api/health` request and exposes `status="degraded"` if broken. The operator sees the status in the TailStatusPill and the Dashboard KPI tile.
|
||||||
|
|
||||||
|
### 9.3 PHI/PII handling
|
||||||
|
|
||||||
|
- **Storage** — SQLCipher at rest (opt-in, SP12). Daily encrypted backups (SP17). Tamper-evident audit chain (SP11). 6-year retention via automated backup rotation.
|
||||||
|
- **In transit (SFTP)** — TLS to the trading partner; no plaintext EDI over the wire.
|
||||||
|
- **In the UI** — no PHI is shown on the Dashboard or Inbox (only counts + KPIs). The Claim drawer and Remit drawer show full PHI; these pages are not in the route map's preview and require an explicit navigation click.
|
||||||
|
- **In logs** — scrubbed by key name (see §9.1).
|
||||||
|
- **In analytics** — no analytics. No third-party tracking. No telemetry. The frontend makes no requests outside `127.0.0.1:8000`.
|
||||||
|
|
||||||
|
### 9.4 Error model
|
||||||
|
|
||||||
|
Every API error is a JSON object with `{"error": "<code>", "message": "<human>", "detail": <optional>}`. The mapping is in `api_helpers.py`:
|
||||||
|
|
||||||
|
| Error class | HTTP status | `error` code |
|
||||||
|
|---|---|---|
|
||||||
|
| `AlreadyMatchedError` | 409 | `already_matched` |
|
||||||
|
| `NotMatchedError` | 409 | `not_matched` |
|
||||||
|
| `InvalidStateError` | 409 | `invalid_state` |
|
||||||
|
| `ParseError` | 422 | `parse_error` |
|
||||||
|
| `ValidationError` (R-codes) | 422 | `validation_failed` (issues in `detail`) |
|
||||||
|
| `FileNotFoundError` | 404 | `not_found` |
|
||||||
|
| `ValueError` | 400 | `bad_request` |
|
||||||
|
| `PermissionError` | 403 | `forbidden` (raised by `matrix_gate` when the role is below the endpoint's required role) |
|
||||||
|
| (anything else) | 500 | `internal` (with a request id for log correlation) |
|
||||||
|
|
||||||
|
The 409-vs-422-vs-400 split is intentional: 409 for state-machine violations (the operator's input is well-formed but conflicts with current state), 422 for parse / validation, 400 for bad input shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Operational concerns
|
||||||
|
|
||||||
|
### 10.1 Startup order
|
||||||
|
|
||||||
|
`python -m cyclone serve` → `cyclone.api.lifespan` runs:
|
||||||
|
|
||||||
|
1. `db.init_db()` — apply pending migrations (0001-0012, plus 0013/0014 on the `claims-unique-fix` worktree)
|
||||||
|
2. Seed `cyclone.providers` with the SP9 defaults if the table is empty (PayerConfig for Colorado Medicaid + Gainwell)
|
||||||
|
3. Configure `BackupService` (SP17) — open backup dir, read passphrase from Keychain
|
||||||
|
4. Configure `Scheduler` (SP16) — wire MFT polling for each clearhouse
|
||||||
|
5. If `CYCLONE_SCHEDULER_AUTOSTART=true` (default), start both schedulers
|
||||||
|
6. Mount the FastAPI app and start uvicorn
|
||||||
|
|
||||||
|
The ordering is load-bearing: the store facade cannot be used until `init_db` finishes, and the schedulers cannot query the DB until the seed step populates the clearhouse rows.
|
||||||
|
|
||||||
|
### 10.2 MFT scheduler (SP16)
|
||||||
|
|
||||||
|
`cyclone.scheduler.Scheduler` runs a per-clearhouse tick on a configurable interval (default 30s, per `CYCLONE_SCHEDULER_INTERVAL` env var). The tick is described in §7.2. The scheduler is opt-in via `CYCLONE_SCHEDULER_AUTOSTART`; the operator can start/stop it from the admin endpoints or via `POST /api/admin/scheduler/tick` for a manual tick.
|
||||||
|
|
||||||
|
The 4-step idempotency sequence (SELECT → INSERT side-effects → INSERT idempotency; IntegrityError as the correctness backstop) is the linchpin. The scheduler's `_already_processed` + `_record` pair wraps the INSERT to handle the case where two ticks race (an extremely rare event given the 30s interval, but possible during operator-driven manual ticks).
|
||||||
|
|
||||||
|
### 10.3 Backup scheduler (SP17)
|
||||||
|
|
||||||
|
`cyclone.backup_scheduler.BackupScheduler` wraps `BackupService` and ticks on its own interval (default 24h, per `CYCLONE_BACKUP_INTERVAL` env var). On each tick: create a backup, prune anything older than `retention_days` (default 30). Auto-start opt-in via `CYCLONE_BACKUP_AUTOSTART`.
|
||||||
|
|
||||||
|
The encryption layer is independent of SQLCipher: AES-256-GCM with a passphrase-derived key (PBKDF2-HMAC-SHA256, 200k iterations). The passphrase lives in the macOS Keychain. If the passphrase is missing, the backup layer falls back to deriving a key from the SQLCipher key (less ideal but never silently broken — the operator's `/api/health` reports `status="degraded"` with a `backup.passphrase_missing` flag).
|
||||||
|
|
||||||
|
### 10.4 Health probe (SP19)
|
||||||
|
|
||||||
|
`GET /api/health` is a deep snapshot:
|
||||||
|
|
||||||
|
- **db** — open a session, `SELECT 1`
|
||||||
|
- **scheduler** — last tick ts, is alive
|
||||||
|
- **pubsub** — current subscriber counts per event kind
|
||||||
|
- **batch** — most recent batch id + ts
|
||||||
|
- **audit_log** — `verify_chain()` result (deep check on every call; 1ms on a healthy chain, fails loudly on a broken one)
|
||||||
|
- **backup** — last backup ts, last verify result, passphrase presence
|
||||||
|
- **encryption** — `is_encryption_enabled()` boolean
|
||||||
|
|
||||||
|
Returns `status="ok"` only when every subsystem is healthy. `status="degraded"` if any subsystem is unhappy but the API itself is responsive. The status is shown in the TailStatusPill (bottom-right) and the Dashboard KPI tile.
|
||||||
|
|
||||||
|
### 10.5 Shutdown
|
||||||
|
|
||||||
|
`SIGTERM` (or Ctrl-C) → uvicorn drains in-flight requests → cancels the MFT scheduler (in-flight tick completes via `asyncio.to_thread`) → cancels the backup scheduler (in-flight tick completes) → closes the SQLAlchemy engine → exits 0. There is no checkpoint; the in-flight tick is allowed to complete naturally because the idempotency table makes a partial-tick restart safe.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Deployment
|
||||||
|
|
||||||
|
### 11.1 Local dev (default)
|
||||||
|
|
||||||
|
The default deployment. Two terminals:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1 — backend on 127.0.0.1:8000
|
||||||
|
cd backend
|
||||||
|
.venv/bin/python -m cyclone serve
|
||||||
|
|
||||||
|
# Terminal 2 — frontend on localhost:5173
|
||||||
|
cd ..
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend reads its backend URL from `VITE_API_BASE_URL` (default empty). Create `.env.local` at the repo root with `VITE_API_BASE_URL=http://127.0.0.1:8000`. Without it, the UI falls back to its in-memory sample store via the `data` adapter (parses are disabled).
|
||||||
|
|
||||||
|
### 11.2 Optional Docker (SP23 — product fork)
|
||||||
|
|
||||||
|
A Ubuntu + Docker + auth + RBAC + LAN-bind spec exists at [`docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md`](superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md). This is a **product fork** awaiting user decision (per [REQUIREMENTS.md §6.2](REQUIREMENTS.md)). The v1 single-host single-operator posture assumes local-only; SP23 changes the threat model to "remote operator on LAN."
|
||||||
|
|
||||||
|
### 11.3 Single-host "production"
|
||||||
|
|
||||||
|
For a single operator on a single machine, the dev deployment IS the production deployment. There is no separate build / deploy pipeline; the v1 distribution model is `git clone && pip install -e '.[dev]' && npm install && python -m cyclone serve`. A release-time gate (not yet implemented — backlog item) would run `pytest` (backend) and `npm test` (frontend) and fail if any test is red.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope (v1)
|
||||||
|
|
||||||
|
The following are explicitly NOT built in v1, by design:
|
||||||
|
|
||||||
|
- **Multi-user / multi-host** — login is required (single-user model today; SP23 adds RBAC). LAN-bind, Docker, reverse proxy still out of scope. (SP23 covers the LAN-bind / Docker / RBAC product fork.)
|
||||||
|
- **Other trading partners** — only Colorado Medicaid via Gainwell is configured. The `PayerConfig` mechanism (SP9) supports adding more; the spec/plan/CLI work for that is on the backlog.
|
||||||
|
- **837I / 837D / 834 / 820 / 275 / 278 / 276-277** — only 837P and 835 are first-class. Other transaction types are listed in [REQUIREMENTS.md §6.2](REQUIREMENTS.md) as backlog items.
|
||||||
|
- **NPPES real-time lookup** — only local NPI checksum validation (SP20). The operator who wants real registry lookup has to wire it in later.
|
||||||
|
- **EHR / PM integration** — no webhook in/out, no callable `POST /api/parse-837` (the pipeline agent uses `/api/upload` for now).
|
||||||
|
- **COB / secondary claims** — no automatic coordination-of-benefits generator. The operator generates a secondary 837P by hand from the CAS adjustments.
|
||||||
|
- **Real-time eligibility** — no SFTP pickup + SOAP + 271 round-trip. The 270/271 parsers exist; the live cycle doesn't.
|
||||||
|
|
||||||
|
Each of these is a separate SP. The "v1 done" bar is the 22 shipped SPs (SP1–SP22) on `main` plus the [REQUIREMENTS.md §8 DoD](REQUIREMENTS.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. References
|
||||||
|
|
||||||
|
### 13.1 Top-level docs
|
||||||
|
|
||||||
|
- [Requirements](REQUIREMENTS.md) — FRs, NFRs, DoD, traceability
|
||||||
|
- [README](../../README.md) — install + dev + pipeline agent
|
||||||
|
|
||||||
|
### 13.2 Specs (per sub-project)
|
||||||
|
|
||||||
|
All in `docs/superpowers/specs/`:
|
||||||
|
|
||||||
|
- SP1 production-readiness: `2026-06-19-cyclone-production-readiness-design.md`
|
||||||
|
- SP2 DB + reconciliation: `2026-06-19-cyclone-db-reconciliation-design.md`
|
||||||
|
- SP3 EDI features: `2026-06-20-cyclone-edi-features-design.md`
|
||||||
|
- SP4 claim drawer: `2026-06-20-cyclone-claim-drawer-design.md`
|
||||||
|
- SP5 live tail: `2026-06-20-cyclone-live-tail-design.md`
|
||||||
|
- SP7 line reconciliation: `2026-06-20-cyclone-line-reconciliation-design.md`
|
||||||
|
- SP8 serialize 837: `2026-06-20-cyclone-serialize-837-design.md`
|
||||||
|
- SP9 multi-payer / NPI / SFTP stub: `2026-06-20-cyclone-multi-payer-npi-sftp-design.md`
|
||||||
|
- SP10 277CA: `2026-06-23-cyclone-sp10-277ca-payer-rejected-design.md`
|
||||||
|
- SP11 audit log: `2026-06-23-cyclone-sp11-hash-chained-audit-design.md`
|
||||||
|
- SP12 SQLCipher: `2026-06-23-cyclone-sp12-sqlcipher-encryption-design.md`
|
||||||
|
- SP13 SFTP client: `2026-06-23-cyclone-sp13-sftp-client-design.md`
|
||||||
|
- SP14 5-lane Inbox: `2026-06-23-cyclone-sp14-inbox-5lane-design.md`
|
||||||
|
- SP15 key rotation: `2026-06-23-cyclone-sp15-key-rotation-design.md`
|
||||||
|
- SP16 MFT scheduler: `2026-06-23-cyclone-sp16-mft-polling-design.md`
|
||||||
|
- SP17 encrypted backup: `2026-06-21-cyclone-encrypted-backup-design.md`
|
||||||
|
- SP18 structured logging: `2026-06-21-cyclone-structured-logging-design.md`
|
||||||
|
- SP19 security hardening: `2026-06-21-cyclone-security-hardening-design.md`
|
||||||
|
- SP20 NPI validation: `2026-06-21-cyclone-npi-validation-design.md`
|
||||||
|
- SP21 store split: `2026-06-21-cyclone-store-split-design.md`; universal drilldown: `2026-06-21-cyclone-universal-drilldown-design.md`
|
||||||
|
- SP22 parse-decide: `2026-06-21-cyclone-parse-decide-workflow-design.md`; pipeline agent: `2026-06-21-cyclone-pipeline-agent-design.md`
|
||||||
|
- SP23 (fork) Ubuntu + Docker: `2026-06-22-cyclone-ubuntu-docker-deployment-design.md`
|
||||||
|
|
||||||
|
### 13.3 Plans
|
||||||
|
|
||||||
|
All in `docs/superpowers/plans/`. 27 files — one per spec (plus the round-2 backfill for SP9–SP20 in the `2026-06-23-cyclone-sp*` naming).
|
||||||
|
|
||||||
|
### 13.4 Reference docs
|
||||||
|
|
||||||
|
- `docs/reference/co-medicaid.md` — operator setup for SQLCipher + SFTP + the Gainwell trading-partner details (KP-specific).
|
||||||
|
|
||||||
|
### 13.5 Sibling project
|
||||||
|
|
||||||
|
- `cyclone-pipeline/` — round-trip agent (sibling, not in this repo). See [README](../../README.md) §"Pipeline automation agent" for install + usage.
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
# Cyclone — Requirements, Architecture, Tasks, and Test Strategy
|
||||||
|
|
||||||
|
**Date:** 2026-06-23
|
||||||
|
**Status:** Round 2 (post-review corrections — ready for verification)
|
||||||
|
**Audience:** New engineer or AI agent who needs to understand Cyclone's contract, the work that's already shipped, the work that's not, and what "done" means before they write a single line of code.
|
||||||
|
**Scope:** Local-only, single-operator, single-host EDI claims-management suite for one billing office, currently trading with Colorado Medicaid (CO XIX).
|
||||||
|
**Sister docs:** [`README.md`](../README.md) is the operator-facing entry point; [`docs/ARCHITECTURE.md`](ARCHITECTURE.md) is the top-level technical design (process topology, package layout, data flow, lifecycle); [`docs/superpowers/specs/`](superpowers/specs/) holds per-SP design specs; [`docs/superpowers/plans/`](superpowers/plans/) holds per-SP implementation plans; [`docs/reviews/2026-06-20-cyclone-completeness-review.md`](reviews/2026-06-20-cyclone-completeness-review.md) is the industry-scope gap analysis; [`docs/reference/`](reference/) holds condensed X12 / payer notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. How to read this document
|
||||||
|
|
||||||
|
| If you want to know… | Go to |
|
||||||
|
|---|---|
|
||||||
|
| What Cyclone is and is not | §1, §2 |
|
||||||
|
| What it must do (functional) | §3 |
|
||||||
|
| What it must *not* fail at (non-functional) | §4 |
|
||||||
|
| How the system is built | §5 |
|
||||||
|
| Which sub-project (SP) owns which requirement | §3 + §6 traceability matrix |
|
||||||
|
| What "shipped" means | §7 |
|
||||||
|
| What "done" means for the project as a whole | §8 |
|
||||||
|
| How we test | §9 |
|
||||||
|
| What we're assuming to be true | §10 |
|
||||||
|
| What's still missing or risky | §11 |
|
||||||
|
|
||||||
|
Every requirement (§3, §4) has an ID; every ID is traceable to a sub-project (§6) which in turn points at a design spec and an implementation plan. The matrix at the end of §6 is the single index that ties everything together.
|
||||||
|
|
||||||
|
**Round history.** Round 1 (2026-06-23) produced this draft. Two independent reviewers (Reviewer A — components/data-model/deps/DoD; Reviewer B — traceability/consistency audit) read it against the actual codebase and surfaced 12 factual errors and 6 structural improvements. Round 2 (this version) applies every factual correction. Round 3 (pending) will re-verify reviewer agreement on the corrected doc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The contract — what Cyclone is
|
||||||
|
|
||||||
|
A self-hosted EDI claims-management suite for a single billing office. Parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 and 005010X221A1), with a local-only FastAPI backend and a React UI for browsing, filtering, and inspecting the parsed data.
|
||||||
|
|
||||||
|
- **Local-only on purpose:** binds `127.0.0.1`, requires login (auth boundary is HTTP; bcrypt + HttpOnly session cookie), no internet exposure, single operator, single machine, one trading partner (Colorado Medicaid, currently).
|
||||||
|
- **Single-process:** the backend is one Python process; the frontend is one Vite-served React app; there is no message broker, no separate worker, no queue.
|
||||||
|
- **Single source of truth:** SQLite at `~/.local/share/cyclone/cyclone.db` (overridable via `CYCLONE_DB_URL`); optionally encrypted at rest via SQLCipher (SP12).
|
||||||
|
- **Threat model:** a process on the same host, plus a deliberate HTTP-layer login (no remote unauthenticated read or write). File-system threats (stolen/imaged drive) are still the primary concern; SQLCipher at rest + the macOS Keychain handle those. The SP23 product fork changes the threat model to "remote operator on LAN."
|
||||||
|
|
||||||
|
The Cyclone codebase has honored this contract end-to-end across 22 shipped sub-projects. Every design spec re-asserts the local-only / single-operator / single-payer / no-auth posture and lists (often at length) what is intentionally out of scope.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope boundaries
|
||||||
|
|
||||||
|
### 2.1 In scope (today, after SP1–SP22)
|
||||||
|
|
||||||
|
| Capability | SP |
|
||||||
|
|---|---|
|
||||||
|
| 837P (005010X222A1) parse + serializer with round-trip guarantee | SP1 + SP8 |
|
||||||
|
| 835 (005010X221A1) parse + CAS deep-parsing | SP1 + SP3 |
|
||||||
|
| 999, TA1, 270, 271, 277CA parse / generate | SP3 + SP10 |
|
||||||
|
| Structural validation rules (R021, R034, R035, R200, R210, …) | SP3 + SP20 |
|
||||||
|
| SQLite persistence with forward-compatible migrations | SP2 + SP9–SP17 |
|
||||||
|
| Automatic 837P ↔ 835 reconciliation; manual reconciliation UI | SP2 + SP7 |
|
||||||
|
| 7-state claim lifecycle with reversal handling | SP2 |
|
||||||
|
| Tamper-evident hash-chained audit log | SP11 |
|
||||||
|
| NDJSON pubsub on store writes + live-tail UI with reconnect | SP5 |
|
||||||
|
| 5-lane inbox (Rejected / Payer-rejected / Candidates / Unmatched / Done today) | SP6 + SP10 + SP14 |
|
||||||
|
| Per-line 837 SV1 ↔ 835 SVC adjustment audit | SP7 |
|
||||||
|
| Per-claim and per-remit detail drawers + Cmd-K search + batch diff + CSV export | SP4 + SP21 |
|
||||||
|
| Universal drilldown (drawer + peek modal everywhere) | SP21 |
|
||||||
|
| Multi-payer / multi-NPI config + clearhouse stub | SP9 |
|
||||||
|
| `paramiko`-backed SFTP wire to Gainwell MFT | SP13 |
|
||||||
|
| 24h MFT inbound scheduler with idempotent file processing | SP16 |
|
||||||
|
| SQLCipher encryption at rest + key rotation | SP12 + SP15 |
|
||||||
|
| Automated AES-256-GCM encrypted DB backups | SP17 |
|
||||||
|
| Structured JSON logging + PII scrubber | SP18 |
|
||||||
|
| Security hardening (body size limit, rate limit, security headers) | SP19 |
|
||||||
|
| NPI Luhn checksum + Tax ID format validation | SP20 |
|
||||||
|
| CycloneStore split (refactor; **in-flight on `refactor/store-split`**) | SP21 |
|
||||||
|
| Parse-then-decide upload dedup (idempotency) | SP22 |
|
||||||
|
| Pipeline automation agent (sibling project at `cyclone-pipeline/`) | SP22 |
|
||||||
|
|
||||||
|
### 2.2 Out of scope (today, by design)
|
||||||
|
|
||||||
|
| Item | Why |
|
||||||
|
|---|---|
|
||||||
|
| AS2 / AS4 (EDIINT, signed, encrypted, MDN-back) connectivity | Single-host local tool; the operator hands the 837 ZIP to the Gainwell MFT UI manually |
|
||||||
|
| Real-time 270/271 round-trip over CAQH CORE Phase II/III SOAP envelopes | Out of single-host scope; builder + parser are X12-only |
|
||||||
|
| 837I (institutional), 837D (dental), 834, 820, 275, 278, 276/277 | Single-payer scope; not currently demanded by the CO XIX workflow |
|
||||||
|
| NPPES NPI registry lookup (offline validation only) | Single-host, no internet exposure |
|
||||||
|
| ICD-10 / HCPCS / NDC vocabulary tables | Structural validation only; the operator has the source vocab |
|
||||||
|
| COB / secondary-claim generator | Workflow scope |
|
||||||
|
| HITRUST / SOC 2 / BAA template | Single-operator; threat model doesn't require them |
|
||||||
|
| HA / DR / load balancing / multi-host replication | Single-host scope |
|
||||||
|
| Prometheus / Grafana / alerting | `curl health \| mail` cron is sufficient |
|
||||||
|
| 2FA / SSO / OAuth | Single-operator |
|
||||||
|
| LUKS full-disk encryption | Operator / host concern |
|
||||||
|
| Component / E2E browser tests (Playwright) | Local-only, manual smoke scripts are sufficient |
|
||||||
|
|
||||||
|
### 2.3 Deferred with rationale
|
||||||
|
|
||||||
|
- **Public TLS / public domain / Caddy reverse proxy** — LAN-only bind is enough for the v1 single-Ubuntu-server posture; VPN handles outside access. (Re-evaluated if the Ubuntu Docker plan ships.)
|
||||||
|
- **Off-box automated backup shipping** — host cron / rsync is documented in the operator runbook, not automated in code.
|
||||||
|
- **Self-service password reset** — admin can reset via CLI; self-service is v2 (would only be relevant if auth ships).
|
||||||
|
- **Per-file encryption of prodfiles / `sftp_staging`** — relies on volume-level filesystem permissions + the SQLCipher-encrypted DB + the host being physically secure.
|
||||||
|
- **Watchtower / automatic updates** — manual `docker compose pull` keeps the operator in the loop on schema migrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Functional requirements
|
||||||
|
|
||||||
|
Each requirement is `FR-NN`, traceable to one or more sub-projects. Verification is "shipped + tested" (see §6) or "planned" (see §6.2).
|
||||||
|
|
||||||
|
| ID | Requirement | SP |
|
||||||
|
|---|---|---|
|
||||||
|
| FR-1 | Parse an uploaded 837P file into structured `Claim` records. | SP1 |
|
||||||
|
| FR-2 | Parse an uploaded 835 file into structured `Remittance` records with per-CAS adjustment rows. | SP1 + SP3 |
|
||||||
|
| FR-3 | Persist every successful parse to SQLite; survive backend restart. | SP2 |
|
||||||
|
| FR-4 | Run a forward-compatible migration runner (`PRAGMA user_version`) so the schema can evolve without Alembic. | SP2 |
|
||||||
|
| FR-5 | Auto-match each 835 `CLP` back to a prior 837 `Claim` within ±7 days via `(claim_id, service_date)`; apply lifecycle transitions; write `Match` and `ActivityEvent` rows. | SP2 |
|
||||||
|
| FR-6 | Maintain a 7-state claim lifecycle (`submitted → received → paid|partial|denied → reconciled → reversed`) with `state_before_reversal` preserved on reversal. | SP2 |
|
||||||
|
| FR-7 | Provide a `/reconciliation` page that lists unmatched claims and unmatched remittances and supports manual pair/unpair. | SP2 (tests live in `test_reconcile.py`, `test_reconcile_line_level.py`, `test_inbox_endpoints.py`, `test_api_gets.py`) |
|
||||||
|
| FR-8 | Run structural 837P validation rules R021 (NPI checksum, warning-only), R034 (`REF*G1` on frequency 7/8), R035 (`BHT06` transaction-type allowlist), R200/R210. | SP3 + SP20 |
|
||||||
|
| FR-9 | Generate a 999 ACK transaction set on 837 ingest when `?ack=true` is passed to `POST /api/parse-837`; persist it to the `acks` table. | SP3 |
|
||||||
|
| FR-10 | Parse inbound 999 ACKs and surface them on a `/acks` page with regenerable raw text. | SP3 |
|
||||||
|
| FR-11 | Parse + persist TA1 interchange ACKs (separate from 999 transaction-set ACKs). | SP3 |
|
||||||
|
| FR-12 | Build a 270 from a JSON payload (`POST /api/eligibility/request`) and parse an inbound 271 (`POST /api/eligibility/parse-271`) into `coverage_benefits`. | SP3 |
|
||||||
|
| FR-13 | Parse inbound 277CA files and stamp `payer_rejected` on claims whose STC category is A4/A6/A7 — monotonic (a looser later 277CA cannot downgrade). Also persist 277CA ACK rows to `two77ca_acks`. | SP10 |
|
||||||
|
| FR-14 | Provide a `/inbox` page with five lanes ordered by urgency: Rejected (999 R/E), Payer-rejected (277CA A4/A6/A7), Candidates (CLP didn't auto-match), Unmatched (waiting), Done today (last 24h terminal transitions). | SP6 + SP10 + SP14 |
|
||||||
|
| FR-15 | Score each Candidate on 4 weighted fields (patient control number 40, service date 25, charge amount 20, provider NPI 15); tier strong (≥75, Match enabled), weak (50–74, dimmed), hidden (<50). | SP6 |
|
||||||
|
| FR-16 | Provide per-claim and per-remit detail drawers with full raw X12 segments, state-history timeline, validation panel, line-level reconciliation tab, and party grid. URL is synced (`?claim=…`, `?remit=…`). | SP4 + SP7 + SP21 |
|
||||||
|
| FR-17 | Provide a side-by-side Batch Diff view (added / removed / changed claims). | SP4 (logic lives in `cyclone/batch_diff.py`) |
|
||||||
|
| FR-18 | Provide a global Cmd-K search across claims, remittances, activity, and acks. | SP4 |
|
||||||
|
| FR-19 | Provide per-page CSV export for claims, remittances, and inbox lanes. | SP4 |
|
||||||
|
| FR-20 | Provide a live-tail NDJSON stream on `/api/{claims,remittances,activity}/stream` with snapshot-first, `snapshot_end`, heartbeat, and reconnect. | SP5 |
|
||||||
|
| FR-21 | Surface a `TailStatusPill` with states `live / connecting / reconnecting / stalled / error / closed`, with backoff ladder `1s → 2s → 4s → 8s → 16s → 30s` capped. | SP5 |
|
||||||
|
| FR-22 | Tie every 835 CAS adjustment back to the specific 837 SV1 service line it adjudicates; surface unmatched-line warnings. | SP7 (line-reconciliation rows in `line_reconciliations`) |
|
||||||
|
| FR-23 | Serialize a single edited claim back to a valid X12 837P file with round-trip guarantee (113 prodfiles parse → serialize → parse to the same `claim_id`). | SP8 |
|
||||||
|
| FR-24 | Export a ZIP bundle of selected rejected claims as a batch ready to hand to the MFT UI. | SP8 |
|
||||||
|
| FR-25 | Load payer / payer_config / clearhouse config from `config/payers.yaml` (parsed via `pyyaml`) and 3 new DB tables (`providers`, `payers`, `payer_configs`); replace the in-code `PAYER_FACTORIES` dict. | SP9 |
|
||||||
|
| FR-26 | Push a `cyclone clearhouse submit` to the Gainwell MFT inbound path via `paramiko` (`SftpClient` lives in `cyclone/clearhouse/__init__.py`). Credentials read from the macOS Keychain via the `keyring` library at call time. | SP13 |
|
||||||
|
| FR-27 | Poll the Gainwell MFT inbound path every tick (configurable); route downloaded files to the right parser (999 / 835 / 277CA / TA1); idempotent via `processed_inbound_files`; crash-safe via per-file try/except. | SP16 |
|
||||||
|
| FR-28 | Optionally encrypt the SQLite file via SQLCipher (AES-256); key fetched from the macOS Keychain via `keyring`. Falls back to plain SQLite if the Keychain entry is missing. | SP12 |
|
||||||
|
| FR-29 | Rotate the SQLCipher key in place via `PRAGMA rekey`, serialized through a `threading.Lock` and SQLAlchemy `NullPool`; write `db.key_rotated` audit event with old + new key fingerprints and post-rotation `table_count`. | SP15 |
|
||||||
|
| FR-30 | Produce AES-256-GCM encrypted DB backups (PBKDF2-HMAC-SHA256, 200k iters) via the existing `BackupService`; two-step restore (`initiate` → `confirm` with one-shot 64-char hex token); retention pruning with a 30-day default. Backups table is `db_backups`. | SP17 |
|
||||||
|
| FR-31 | Emit all API / CLI / scheduler / backup logs as newline-delimited JSON with ISO-8601 ms timestamps; redact obvious PHI (NPIs, SSNs, DOBs, patient names) via a `PiiScrubber` filter on message + extras. | SP18 (uses stdlib `logging` + a JSON formatter, not structlog) |
|
||||||
|
| FR-32 | Reject oversize request bodies (413), rate-limit requests (429), and add default security headers via pure-ASGI middlewares; emit a tamper-evident `api.request_rejected` audit event on 413/429. | SP19 |
|
||||||
|
| FR-33 | Surface a rich `GET /api/health` response — DB connectivity, MFT scheduler state, backup scheduler state, live pubsub subscriber counts, last batch id + timestamp. | SP19 (`api_routers/health.py:28-40`) |
|
||||||
|
| FR-34 | Validate NPI with the CMS-published Luhn over `80840 + body` (warning-only — placeholder NPIs in test fixtures shouldn't block ingest). Reject Tax ID (EIN) with reserved prefixes (`00`, `07`, `80`–`89`). | SP20 |
|
||||||
|
| FR-35 | Split `CycloneStore` into per-concern modules (`store_persistence`, `store_reconcile`, `store_queries`, `store_mappers`) without changing the public store API. | SP21 (**in-flight** on `refactor/store-split` branch) |
|
||||||
|
| FR-36 | De-duplicate 837 / 835 uploads via a parse-then-decide workflow (`find_existing_batch_for_claim` / `find_existing_batch_for_remit`); relax the claims/remits PK to `(batch_id, id)` via migration 0014 to support re-parses into different batches. | SP22 (migration 0014 in worktree; not yet on `main`) |
|
||||||
|
| FR-37 | (Sibling project, not in this repo) Drive the full 7-phase round-trip — preflight → browser upload → parse verify → SFTP submit → TA1 wait → 999 wait → scan + report — with crash-safe resume, structured JSON logging, idempotency dedup, per-run folder. | SP22 (`cyclone-pipeline/`) |
|
||||||
|
| FR-38 | Provide a `DrillStackProvider` + `DrillDrawerHeader` shell so every drillable cell across the app opens a consistent drawer or peek modal. | SP21 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Non-functional requirements
|
||||||
|
|
||||||
|
| ID | NFR | Source / SP |
|
||||||
|
|---|---|---|
|
||||||
|
| NFR-1 | **Local-only bind + auth.** Backend binds `127.0.0.1:8000` (overridable via `CYCLONE_PORT`); CORS allowlist is exact (`http://localhost:5173`); login required (bcrypt + HttpOnly session cookie; first admin bootstrapped from env vars `CYCLONE_ADMIN_USERNAME` + `CYCLONE_ADMIN_PASSWORD`); dev/test escape hatch via `CYCLONE_AUTH_DISABLED=1` (logs WARNING at boot). | SP1 + auth (2026-06-23 merge) + SP24 (doc reconciliation) |
|
||||||
|
| NFR-2 | **Determinism.** Parser / serializer round-trip is guaranteed on 113 real prodfiles (`docs/prodfiles/claims/*.x12`); canonical fields, not byte-identity (called out in the SP8 spec). | SP8 |
|
||||||
|
| NFR-3 | **Audit completeness.** Every reconciliation anomaly, every state transition, every 999/277CA reject writes an `ActivityEvent` (in `activity_events` table, un-chained; powers the `/activity` feed and inbox state transitions). | SP2 + SP10 |
|
||||||
|
| NFR-4 | **Tamper-evidence.** A separate `audit_log` table (SP11) carries SHA-256 hash-chained rows for security-sensitive events (login attempts, rejections, key rotations, backup lifecycle, rejected requests). `GET /api/admin/audit-log/verify` detects any break. **The `activity_events` (NFR-3) and `audit_log` (NFR-4) tables are distinct** — different semantics, different audiences. New code that needs an audit row must decide which one to write to. | SP11 |
|
||||||
|
| NFR-5 | **Encrypted at rest (optional).** SQLite is encrypted via SQLCipher AES-256 when the macOS Keychain entry exists and `sqlcipher3` is installed; plain SQLite otherwise. | SP12 |
|
||||||
|
| NFR-6 | **Encrypted backups.** Backups are AES-256-GCM encrypted at rest (PBKDF2-HMAC-SHA256, 200k iters); passphrase lives in the macOS Keychain (`cyclone backup init-passphrase`). | SP17 |
|
||||||
|
| NFR-7 | **Logging discipline.** API / CLI / scheduler / backup logs flow through a `JsonFormatter` (NDJSON, ISO-8601 ms) by default; `PiiScrubber` redacts obvious PHI; configurable via `CYCLONE_LOG_LEVEL` / `CYCLONE_LOG_FILE` / `CYCLONE_LOG_JSON` / `CYCLONE_LOG_NO_PII_SCRUB`. | SP18 |
|
||||||
|
| NFR-8 | **Operational hygiene.** Request body size limit, rate limit, and default security headers are middleware-enforced (SP19). `EXEMPT_PATHS = ("/api/health", "/healthz", "/readyz")` is the documented bypass list (`cyclone/security.py:201`). | SP19 |
|
||||||
|
| NFR-9 | **Thread-affinity.** SQLCipher operations are serialized through a `threading.Lock` + SQLAlchemy `NullPool` to keep SQLCipher thread-affine under FastAPI's per-request threadpool. | SP12 + SP15 |
|
||||||
|
| NFR-10 | **Live-tail resilience.** Heartbeat every 15s default (`CYCLONE_TAIL_HEARTBEAT_S`); client flips to `stalled` after 30s of total silence (heartbeat included); reconnect backoff capped at 30s. | SP5 |
|
||||||
|
| NFR-11 | **Payer config externalized.** Payer / payer_config / clearhouse config lives in `config/payers.yaml` + DB tables, not in code; a new payer is a YAML + DB row, not a code change. Note: `co_medicaid()` factory in `cyclone/parsers/payer.py:57-58` still exists as a fallback for tests and the default-payer selection (R-4 in §11). | SP9 |
|
||||||
|
| NFR-12 | **Idempotency.** Inbound MFT files are de-duplicated via `processed_inbound_files`; 837/835 uploads are de-duplicated via the parse-then-decide workflow with `(batch_id, claim_id)` / `(batch_id, remit_id)` relaxed PKs. | SP16 + SP22 |
|
||||||
|
| NFR-13 | **Crash-safety.** Incoming files are processed inside per-file try/except so a bad file doesn't stop the MFT loop; the pipeline agent has crash-safe resume keyed by run id. | SP16 + SP22 |
|
||||||
|
| NFR-14 | **Single-process.** No message broker, no separate worker, no queue; everything runs in one Python process. | (project-wide constraint) |
|
||||||
|
| NFR-15 | **Test density.** Backend tests: **964 collected** (`pytest --collect-only`, 2026-06-23); frontend test files: **73**; prodfiles are exercised (113 + 19 + 1369), not just minimal fixtures. | (project-wide) |
|
||||||
|
| NFR-16 | **Documentation discipline.** Every shipped sub-project has a design spec + implementation plan + smoke test; a new engineer can read the project top-to-bottom. (SPs 9–16 ship without on-disk plan files — see §6.1 — but their `feat(spN)` commits are on `main` and carry their own commit-message plan.) | (project-wide) |
|
||||||
|
| NFR-17 | **Fail-soft posture.** Reconciliation crashes don't lose the 835; the activity event records the failure. | SP2 |
|
||||||
|
| NFR-18 | **Migration safety.** Migrations are forward-only via `PRAGMA user_version`; rollback procedures are documented per migration; the `user_version` runner is idempotent. **No checksum manifest exists today** (R-13 in §11). | SP2 + SP22 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Architecture overview
|
||||||
|
|
||||||
|
### 5.1 Components
|
||||||
|
|
||||||
|
**Backend (one Python process, FastAPI on uvicorn) — modules under `backend/src/cyclone/`:**
|
||||||
|
|
||||||
|
| Module | Owns | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `cyclone.api` | HTTP routes, content negotiation, CORS allowlist, lifespan | 3,145 LOC (largest single file; partly split into `api_routers/` for 4 sub-routes) |
|
||||||
|
| `cyclone.api_helpers` | NDJSON / content-negotiation / live-tail helpers | |
|
||||||
|
| `cyclone.api_routers` | `acks`, `admin`, `health` (`/api/health`), `ta1_acks` sub-routers | Mounted at `api.py:270-273`; only 4 routes split out — bulk of routes still in `api.py` |
|
||||||
|
| `cyclone.store` | Public store facade; persistence, reconciliation, queries, mappers | 2,172 LOC; SP21 split is in-flight on `refactor/store-split` |
|
||||||
|
| `cyclone.db` | SQLAlchemy engine, session factory, ORM models (18 tables) | |
|
||||||
|
| `cyclone.db_migrate` | `PRAGMA user_version` migration runner | |
|
||||||
|
| `cyclone.db_crypto` | Optional SQLCipher encryption at rest; key rotation | `rotate_key()` is destructive (rewrites every page) |
|
||||||
|
| `cyclone.audit_log` | Hash-chained `audit_log` (SP11, SHA-256 chain) | Distinct from `cyclone.activity_events` written by other modules |
|
||||||
|
| `cyclone.inbox_lanes` | The 5-lane inbox payload (Rejected / Payer-rejected / Candidates / Unmatched / Done today) | |
|
||||||
|
| `cyclone.inbox_state` | 999 envelope reject → claim state transitions | |
|
||||||
|
| `cyclone.inbox_state_277ca` | 277CA STC A4/A6/A7 → payer_rejected stamp (monotonic) | |
|
||||||
|
| `cyclone.providers` | Multi-NPI provider lookups | |
|
||||||
|
| `cyclone.payers` | Payer / payer_config lookups | Loads `config/payers.yaml` via `pyyaml` |
|
||||||
|
| `cyclone.secrets` | macOS Keychain-backed secret fetcher (wraps `keyring`) | |
|
||||||
|
| `cyclone.reconcile` | Pure-function 835→claim match + line-level match | No DB session inside; testable with fabricated ORM objects |
|
||||||
|
| `cyclone.scoring` | 4-field weighted candidate scoring (40/25/20/15) | Weights hardcoded — see R-10 in §11 |
|
||||||
|
| `cyclone.pubsub` | In-process EventBus (drop-oldest, per-kind fan-out) | In-process only; not thread-safe across asyncio event loops |
|
||||||
|
| `cyclone.backup` | AES-256-GCM encrypted backups (orchestration + CLI) | 216 LOC |
|
||||||
|
| `cyclone.backup_service` | Backup lifecycle (create / verify / restore) | 740 LOC |
|
||||||
|
| `cyclone.backup_scheduler` | 24h backup autostart loop | 315 LOC |
|
||||||
|
| `cyclone.scheduler` | asyncio MFT polling loop (SP16) | Uses `processed_inbound_files` for idempotency |
|
||||||
|
| `cyclone.security` | Per-IP rate-limit, body-size limit, security headers (`SecurityHeadersMiddleware`, `BodySizeLimitMiddleware`, `RateLimitMiddleware`) | `EXEMPT_PATHS` list at `:201` |
|
||||||
|
| `cyclone.logging_config` | JSON formatter, PII scrubber | |
|
||||||
|
| `cyclone.npi` | NPI Luhn + EIN format validators | |
|
||||||
|
| `cyclone.batch_diff` | Batch Diff engine (powers `GET /api/batch-diff`) | 12 public functions/classes |
|
||||||
|
| `cyclone.clearhouse` (`__init__.py`) | `SftpClient` (SP9 stub + SP13 paramiko), `InboundFile`, `make_client()` factory | 318 LOC; the SFTP home |
|
||||||
|
| `cyclone.edi` (`filenames.py`) | HCPF X12 File Naming Standards helpers | Used by SP13/SP16 outbound filename construction |
|
||||||
|
| `cyclone.cli` | Click CLI | |
|
||||||
|
| `cyclone.__main__` | `python -m cyclone serve` | |
|
||||||
|
| `cyclone.parsers/` | X12 tokenizer, models, validator, writers, 277CA, 999, TA1, 270, 271, CAS, seg | 25 `.py` modules |
|
||||||
|
|
||||||
|
**Stale artifact (do not import):** `backend/src/cyclone/workflow/` contains only a `__pycache__/` subdir with stale `.pyc` files (no `__init__.py`, no source). The actual sources live at the top level. Recommend deleting the directory.
|
||||||
|
|
||||||
|
**Frontend (one Vite-served React app) — directories under `src/`:**
|
||||||
|
|
||||||
|
| Directory | Owns |
|
||||||
|
|---|---|
|
||||||
|
| `src/pages/` | Acks, ActivityLog, BatchDiff, Batches, Claims, Dashboard, Inbox, Providers, Reconciliation, Remittances, Upload (11 pages) |
|
||||||
|
| `src/hooks/` | TanStack Query hooks, URL-state hooks, live-tail hooks, keyboard hooks, batch-export, search, drawer keyboard, count-up, provider detail, payer summary, parse, ack detail (35+ files) |
|
||||||
|
| `src/lib/` | `api.ts`, `format.ts`, `utils.ts`, `csv.ts`, `download.ts`, `event-routing.ts`, `tail-stream.ts`, `inbox-api.ts` (all with paired `.test.ts`) |
|
||||||
|
| `src/components/` | 3 drawer families (`ClaimDrawer/` ~15 files, `RemitDrawer/` ~10 files, `ProviderDrawer/` ~6, `AckDrawer/` 3); `inbox/` (10 files), `drill/` (10 files, includes `DrillStackProvider` + `DrillDrawerHeader`), `charts/` (4 files), `ui/` (18 shadcn primitives — radix + cva + tailwind-merge), `KeyboardCheatsheet/` (3 files), plus top-level ActivityFeed, AnimatedNumber, BatchDetail, BatchDiffView, BatchesList, ClaimCard837, DominantKpiCard, EditorialNote, ExportBar, ExportCsvButton, KpiCard, Layout, NewClaimDialog, PageHeader, SearchBar, SearchResults, Sidebar, Sparkline, StatusBadge, StatusPill, TailStatusPill, TickerTape |
|
||||||
|
| `src/store/` | `index.ts` (zustand sample data), `tail-store.ts` (FIFO-capped live tail slices) |
|
||||||
|
| `src/types/` | `index.ts` (flat barrel of shared TS types) |
|
||||||
|
|
||||||
|
### 5.2 Data model
|
||||||
|
|
||||||
|
**12 SQL migrations** in `backend/src/cyclone/migrations/` (verified on disk 2026-06-23):
|
||||||
|
|
||||||
|
| # | File | Tables / columns added |
|
||||||
|
|---|---|---|
|
||||||
|
| 0001 | `0001_initial.sql` | `batches`, `claims`, `remittances`, `cas_adjustments`, `matches`, `activity_events` (6 tables) |
|
||||||
|
| 0002 | `0002_acks.sql` | `acks` |
|
||||||
|
| 0003 | `0003_drop_claims_remits_unique_constraints.sql` | Drops `claims` and `remittances` UNIQUE constraints |
|
||||||
|
| 0004 | `0004_rejections_and_state_history.sql` | Adds `rejection_reason`, `rejected_at`, `resubmit_count`, `state_changed_at` to `claims` (no new table) |
|
||||||
|
| 0005 | `0005_create_ta1_acks.sql` | `ta1_acks` |
|
||||||
|
| 0006 | `0006_line_reconciliation.sql` | `service_line_payments`, `line_reconciliations`; FK `service_line_payment_id` on `cas_adjustments`; `claim_level_adjustment_amount` on `remittances` |
|
||||||
|
| 0007 | `0007_providers_payers_clearhouse.sql` | `providers`, `payers`, `payer_configs`, `clearhouse` |
|
||||||
|
| 0008 | `0008_payer_rejected_columns.sql` | `two77ca_acks` (table) + `payer_rejected` columns on `claims` |
|
||||||
|
| 0009 | `0009_audit_log.sql` | `audit_log` (SHA-256 hash-chained) |
|
||||||
|
| 0010 | `0010_payer_rejected_acknowledged.sql` | `payer_rejected_acknowledged` column on `claims` |
|
||||||
|
| 0011 | `0011_processed_inbound_files.sql` | `processed_inbound_files` (SP16 idempotency) |
|
||||||
|
| 0012 | `0012_backups.sql` | `db_backups` (SP17) |
|
||||||
|
|
||||||
|
**In-flight on `claims-unique-fix` worktree (not yet on `main`):**
|
||||||
|
|
||||||
|
| # | File | Tables / columns added |
|
||||||
|
|---|---|---|
|
||||||
|
| 0013 | `0013_drop_claims_unique_constraint.sql` | Drop `claims` UNIQUE constraint |
|
||||||
|
| 0014 | `0014_relax_claims_remits_pk.sql` | Relax PK to `(batch_id, id)` (SP22) |
|
||||||
|
|
||||||
|
**ORM models** (declared in `backend/src/cyclone/db.py` — 18 classes total):
|
||||||
|
|
||||||
|
| Python class | Table | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `Batch` | `batches` | |
|
||||||
|
| `Claim` | `claims` | |
|
||||||
|
| `Remittance` | `remittances` | |
|
||||||
|
| `CasAdjustment` | `cas_adjustments` | |
|
||||||
|
| `ServiceLinePayment` | `service_line_payments` | Created by 0006; SP1+ |
|
||||||
|
| `LineReconciliation` | `line_reconciliations` | Created by 0006; SP7 backbone |
|
||||||
|
| `Match` | `matches` | Reversals add a second row (NOT UNIQUE on `(claim_id, remittance_id)`) |
|
||||||
|
| `Ack` | `acks` | |
|
||||||
|
| `Ta1Ack` | `ta1_acks` | |
|
||||||
|
| `ActivityEvent` | `activity_events` | Created by 0001; SP2 — distinct from `AuditLog` |
|
||||||
|
| `Two77caAck` | `two77ca_acks` | Created by 0008; SP10 |
|
||||||
|
| `Provider` | `providers` | |
|
||||||
|
| `Payer` | `payers` | |
|
||||||
|
| `PayerConfigORM` | `payer_configs` | |
|
||||||
|
| `ClearhouseORM` | `clearhouse` | Singleton row (`id = 1`) |
|
||||||
|
| `AuditLog` | `audit_log` | SHA-256 hash-chained |
|
||||||
|
| `ProcessedInboundFile` | `processed_inbound_files` | |
|
||||||
|
| `DbBackup` | `db_backups` | Note: table is `db_backups`, NOT `backups` |
|
||||||
|
|
||||||
|
**Boundary contracts:**
|
||||||
|
|
||||||
|
| Module | Public API | Pure? | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `cyclone.db` | `init_db()`, `SessionLocal()`, model classes | No (engine + schema) | |
|
||||||
|
| `cyclone.db_migrate` | `run(engine)` | Yes (modulo engine) | |
|
||||||
|
| `cyclone.reconcile` | `match()`, `apply_payment()`, `apply_reversal()`, `split_unmatched()`, line-level match | **Yes** (no DB session inside) | Unit-testable with fabricated ORM objects |
|
||||||
|
| `cyclone.scoring` | `score_pair(...)` | Yes | Weights hardcoded — see R-10 |
|
||||||
|
| `cyclone.audit_log` | `append_event(...)`, `verify_chain()` | No (DB-owning) but append is deterministic | SHA-256 chain |
|
||||||
|
| `cyclone.activity_events` (via store) | `append(...)` | No (DB-owning) | Un-chained; powers `/activity` feed |
|
||||||
|
| `cyclone.store` | `add()`, `get_batch()`, `iter_*()`, `list_unmatched()`, `manual_match()`, `manual_unmatch()` | No (owns sessions, calls reconcile) | |
|
||||||
|
| `cyclone.api` | HTTP routes | No | |
|
||||||
|
| `cyclone.pubsub` | `subscribe_raw(kind, queue_size=256)`, `publish(kind, event)` | No (in-process) | Drop-oldest on full queue |
|
||||||
|
| `cyclone.backup` + `backup_service` + `backup_scheduler` | `initiate_backup`, `confirm_restore`, `tick()` | No (DB-owning) | |
|
||||||
|
| `cyclone.db_crypto` | `is_encryption_enabled()`, `rotate_key()` | No | `rotate_key()` is destructive |
|
||||||
|
| `cyclone.clearhouse` | `SftpClient`, `make_client()`, `InboundFile` | No | Real `paramiko` in SP13 |
|
||||||
|
| `cyclone.batch_diff` | `diff_batches()`, `diff_batches_to_wire()` | Yes | Pure function over claim/remit summaries |
|
||||||
|
| `cyclone.cas_codes` | `CARC_REASON_CODES` dict | Yes | Annual refresh is manual (R-22 in completeness review) |
|
||||||
|
| `cyclone.edi.filenames` | filename regexes + helpers | Yes | |
|
||||||
|
| `cyclone.logging_config` | `JsonFormatter`, `PiiScrubber`, `CycloneDevFormatter` | No (installs handlers) | |
|
||||||
|
| `cyclone.npi` | `validate_npi()`, `validate_tax_id()` | Yes | |
|
||||||
|
| `cyclone.parsers.*` | Pure-function parse + serialize | Yes | |
|
||||||
|
|
||||||
|
### 5.3 Dependencies
|
||||||
|
|
||||||
|
**Backend runtime (verified against `backend/pyproject.toml:10-24`):**
|
||||||
|
|
||||||
|
- `pydantic>=2.6,<3`
|
||||||
|
- `click>=8.1,<9`
|
||||||
|
- `fastapi>=0.110,<1`
|
||||||
|
- `uvicorn[standard]>=0.27,<1` (the `[standard]` extra pulls `httptools`, `uvloop`, `websockets`, `watchfiles`)
|
||||||
|
- `python-multipart>=0.0.9,<1`
|
||||||
|
- `sqlalchemy>=2.0,<3`
|
||||||
|
- `pyyaml>=6.0,<7` ← **loads `config/payers.yaml`** (SP9)
|
||||||
|
- `keyring>=25.0,<26` ← **macOS Keychain backend** used by `cyclone/secrets.py` (SP12/SP13/SP15); on Linux dispatches to Secret Service / kwallet
|
||||||
|
- `cryptography>=49.0,<50`
|
||||||
|
|
||||||
|
**Backend optional extras:**
|
||||||
|
|
||||||
|
- `[sqlcipher]` → `sqlcipher3>=0.6,<1` (SP12; falls back to plain SQLite if missing)
|
||||||
|
- `[sftp]` → `paramiko>=3.4,<6` (SP13)
|
||||||
|
|
||||||
|
**Backend dev/test (`backend/pyproject.toml:27-33`):**
|
||||||
|
|
||||||
|
- `pytest>=8.0`, `pytest-cov>=4.1`, `pytest-asyncio>=0.23,<1` (asyncio_mode = "auto"), `pytest-randomly>=4.1`, `httpx>=0.27,<1` ← **dev only, not runtime**
|
||||||
|
- `ruff`, `mypy` — **not installed** (per completeness review §3.2.30 deferred)
|
||||||
|
|
||||||
|
**Frontend runtime (verified against `package.json`):**
|
||||||
|
|
||||||
|
- `react@^18.3.1`, `react-dom@^18.3.1`
|
||||||
|
- `@tanstack/react-query@^5.101.0`
|
||||||
|
- `lucide-react@^0.453.0`
|
||||||
|
- `sonner@^1.5.0`
|
||||||
|
- `zustand@^4.5.5`
|
||||||
|
- `react-router-dom@^6.27.0` (note: package name is `react-router-dom`, not `react-router`)
|
||||||
|
- `@radix-ui/react-dialog@^1.1.2`, `@radix-ui/react-label@^2.1.0`, `@radix-ui/react-select@^2.1.2`, `@radix-ui/react-slot@^1.1.0`, `@radix-ui/react-tabs@^1.1.15` ← powers `src/components/ui/` (shadcn primitives)
|
||||||
|
- `class-variance-authority@^0.7.0`, `clsx@^2.1.1`, `tailwind-merge@^2.5.4`, `ansi-styles@^6.2.3` ← shadcn helper stack
|
||||||
|
|
||||||
|
**Frontend dev/test:**
|
||||||
|
|
||||||
|
- `vitest@^4.1.9` ← **unusually new version** (worth a sanity check — current stable is 1.x/2.x; verify it's intentional)
|
||||||
|
- `@testing-library/react@^16.3.2`
|
||||||
|
- `happy-dom@^20.10.6` ← **not jsdom** (vitest config line 13 explicitly says they don't need a DOM env: "Node 18+ exposes `Response` / `fetch` globals")
|
||||||
|
- `typescript@^5.6.3`, `vite@^5.4.10`, `@vitejs/plugin-react@^4.3.3`
|
||||||
|
- `@types/node`, `@types/react`, `@types/react-dom`, `autoprefixer`, `postcss`, `tailwindcss@^3.4.14`, `tailwindcss-animate`
|
||||||
|
|
||||||
|
**External services:** None. (The MFT path is one-way outbound to Gainwell MFT, polled via SFTP — not a service Cyclone depends on, it's a sink it writes to.)
|
||||||
|
|
||||||
|
**OS-level:** macOS Keychain (backed by `keyring`); SQLite / SQLCipher shared library; `ssh` host keys / known_hosts for SP13. SP23 would add `tini` + `curl` + `nginx` in container scenarios.
|
||||||
|
|
||||||
|
### 5.4 One-page data flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
Operator drops file │ FastAPI backend │
|
||||||
|
on /upload ───────▶│ /api/parse-837|835|999| │
|
||||||
|
(or SFTP inbound) │ 277ca|ta1|270 │
|
||||||
|
│ │
|
||||||
|
│ parse → validate ──┐ │
|
||||||
|
│ ▼ │
|
||||||
|
│ reconcile ──▶ store ──▶ activity_events (un-chained)
|
||||||
|
│ │ │
|
||||||
|
│ │ └─▶ audit_log (SP11, chained)
|
||||||
|
│ ▼ (login / rejected / key events)
|
||||||
|
│ SQLite (encrypted optional)
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ pubsub.publish(<kind>)
|
||||||
|
└──────────┬───────────────────┘
|
||||||
|
│ NDJSON stream
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────┐
|
||||||
|
│ React UI (Vite-served) │
|
||||||
|
│ /claims /remits /inbox │
|
||||||
|
│ /reconciliation /providers │
|
||||||
|
│ /acks /activity /batches │
|
||||||
|
│ /upload /dashboard /health │
|
||||||
|
│ drill stack everywhere │
|
||||||
|
└──────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Sub-project inventory
|
||||||
|
|
||||||
|
### 6.1 Shipped SPs (in `main`)
|
||||||
|
|
||||||
|
Each row's "Spec" / "Plan" point to the design spec and implementation plan on disk. SPs without a plan file on disk shipped via `feat(spN)` commits on `main` whose commit messages carry the implementation order; those commit logs substitute for a plan file. Closing the plan-file gap is on the round-3 backlog.
|
||||||
|
|
||||||
|
| SP | Title | Spec | Plan | Acceptance |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| SP1 | Production-readiness: in-memory store, GET endpoints, react-query, reference notes | [spec](superpowers/specs/2026-06-19-cyclone-production-readiness-design.md) | [plan](superpowers/plans/2026-06-19-cyclone-production-readiness.md) | Met |
|
||||||
|
| SP2 | DB + reconciliation: SQLite, 7-state lifecycle, auto-match on 835 ingest, manual reconciliation UI | [spec](superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md) | [plan](superpowers/plans/2026-06-19-cyclone-db-reconciliation.md) | Met |
|
||||||
|
| SP3 | EDI features: 837P rules (R034/R035), CAS reason codes, 999 ACK, TA1 ACK, 270/271 | [spec](superpowers/specs/2026-06-20-cyclone-edi-features-design.md) | [plan](superpowers/plans/2026-06-20-cyclone-edi-features.md) | Met |
|
||||||
|
| SP4 | Claim drawer + Remit drawer + Cmd-K search + batch diff + CSV export | [spec](superpowers/specs/2026-06-20-cyclone-claim-drawer-design.md) | [plan](superpowers/plans/2026-06-20-cyclone-claim-drawer.md) | Met |
|
||||||
|
| SP5 | Live tail: NDJSON pubsub on Claims/Remittances/Activity, heartbeat, reconnect | [spec](superpowers/specs/2026-06-20-cyclone-live-tail-design.md) | [plan](superpowers/plans/2026-06-20-cyclone-live-tail.md) | Met |
|
||||||
|
| SP6 | Inbox workflow: 4 lanes (Rejected/Candidates/Unmatched/Done today), 4-field scoring, bulk actions | **No dedicated spec on disk** — SP6 ships via the SP4 spec's coverage and the workflow-automation plan | [plan](superpowers/plans/2026-06-20-cyclone-workflow-automation.md) | Met |
|
||||||
|
| SP7 | Per-line adjustment audit: 837 SV1 ↔ 835 SVC strict match, line reconciliation tab | [spec](superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md) | [plan](superpowers/plans/2026-06-20-cyclone-line-reconciliation.md) | Met |
|
||||||
|
| SP8 | Outbound 837P serializer: single-claim download + bundle resubmit ZIP | [spec](superpowers/specs/2026-06-20-cyclone-serialize-837-design.md) *(the spec's own title mistakenly says "Sub-project 7"; the file maps to SP8)* | [plan](superpowers/plans/2026-06-20-cyclone-serialize-837.md) | Met |
|
||||||
|
| SP9 | Multi-payer, multi-NPI, SFTP stub | [spec](superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md) | (no plan file on disk; feat(sp9) commits on `main`) | Met |
|
||||||
|
| SP10 | 277CA + Payer-Rejected lane | (no dedicated spec on disk; covered inline in [parse-decide spec](superpowers/specs/2026-06-21-cyclone-parse-decide-workflow-design.md) and `cyclone/parsers/parse_277ca.py`) | (no plan file on disk; feat(sp10) commits on `main`) | Met |
|
||||||
|
| SP11 | Tamper-evident audit log | (no dedicated spec on disk; `cyclone/audit_log.py:116 append_event` and `:190 verify_chain` are the implementation) | (no plan file on disk; feat(sp11) commits on `main`) | Met |
|
||||||
|
| SP12 | Encryption at rest (SQLCipher) | (no dedicated spec on disk; `cyclone/db_crypto.py` is the implementation) | (no plan file on disk; feat(sp12) commits on `main`) | Met |
|
||||||
|
| SP13 | SFTP wire-up (paramiko) | (no dedicated spec on disk; extends SP9; `cyclone/clearhouse/__init__.py` holds `SftpClient`) | (no plan file on disk; feat(sp13) commits on `main`) | Met |
|
||||||
|
| SP14 | 5-lane Inbox UI | (no dedicated spec on disk; extends SP6) | (no plan file on disk; feat(sp14) commits on `main`) | Met |
|
||||||
|
| SP15 | SQLCipher key rotation | (no dedicated spec on disk; extends SP12) | (no plan file on disk; feat(sp15) commits on `main`) | Met |
|
||||||
|
| SP16 | Live MFT polling scheduler | (no dedicated spec on disk; `cyclone/scheduler.py` is the implementation; idempotency via `processed_inbound_files` from migration 0011) | (no plan file on disk; feat(sp16) commits on `main`) | Met |
|
||||||
|
| SP17 | Encrypted DB backups | [spec](superpowers/specs/2026-06-21-cyclone-encrypted-backup-design.md) | (no plan file on disk; feat(sp17) commits on `main`) | Met |
|
||||||
|
| SP18 | Structured logging | [spec](superpowers/specs/2026-06-21-cyclone-structured-logging-design.md) | (no plan file on disk; feat(sp18) commits on `main`) | Met |
|
||||||
|
| SP19 | Security hardening + health probe | [spec](superpowers/specs/2026-06-21-cyclone-security-hardening-design.md) | (no plan file on disk; feat(sp19) commits on `main`) | Met |
|
||||||
|
| SP20 | NPI checksum + Tax ID format validation | [spec](superpowers/specs/2026-06-21-cyclone-npi-validation-design.md) | (no plan file on disk; feat(sp20) commits on `main`) | Met |
|
||||||
|
| SP21 | CycloneStore split (in-flight) + Universal drilldown (shipped) | [split spec](superpowers/specs/2026-06-21-cyclone-store-split-design.md), [drilldown spec](superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md) | [store-split plan](superpowers/plans/2026-06-21-cyclone-store-split.md), [drilldown plan](superpowers/plans/2026-06-21-cyclone-universal-drilldown.md) | Drilldown **Met**; split **In-flight** (`refactor/store-split` branch, not yet on `main`) |
|
||||||
|
| SP22 | Parse-decide workflow + Pipeline agent | [parse-decide spec](superpowers/specs/2026-06-21-cyclone-parse-decide-workflow-design.md), [pipeline spec](superpowers/specs/2026-06-21-cyclone-pipeline-agent-design.md) | [parse-decide plan](superpowers/plans/2026-06-21-cyclone-parse-decide-workflow.md), [pipeline plan](superpowers/plans/2026-06-21-cyclone-pipeline-agent.md) | Met (parse-decide on `main`; pipeline agent lives in `cyclone-pipeline/`) |
|
||||||
|
| SP24 | Auth posture alignment (docs-only reconciliation with the auth work in `main`) | [spec](superpowers/specs/2026-06-23-cyclone-auth-posture-alignment-design.md) | [plan](superpowers/plans/2026-06-23-cyclone-auth-posture-alignment.md) | Met (no code paths change; only docs + `__main__.py` WARNING + 3 skills addenda) |
|
||||||
|
|
||||||
|
### 6.2 Backlog (specs awaiting plans, or vice versa)
|
||||||
|
|
||||||
|
| Item | Status | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| SP23 (candidate) | Spec-only; **product fork awaiting user decision** | `2026-06-22-cyclone-ubuntu-docker-deployment-design.md` (auth + Docker + RBAC + LAN-bind); see §11 R-1, R-3 |
|
||||||
|
| Plan-file backfill for SP9, SP10, SP11, SP12, SP13, SP14, SP15, SP16, SP17, SP18, SP19, SP20 | Plan files missing on disk; `feat(spN)` commit messages substitute | Round-3 doc-backlog item |
|
||||||
|
| Spec-file backfill for SP6 (workflow/inbox), SP11 (audit log), SP12 (SQLCipher), SP13 (paramiko), SP15 (key rotation), SP16 (MFT scheduler) | Specs missing on disk | Round-3 doc-backlog item |
|
||||||
|
| Migration manifest (checksums for the 12 SQL files) | Open | Completeness review §3.1.18 |
|
||||||
|
| Tunable score weights (move 40/25/20/15 from code to config) | Open | Completeness review §3.1.15 |
|
||||||
|
| License file (`LICENSE`) | Open | Completeness review §3.2.28 |
|
||||||
|
| `pre-commit` / `Makefile` / `CONTRIBUTING.md` / `.editorconfig` / `ruff` config | Open | Completeness review §3.2.30 |
|
||||||
|
| CI workflow file (`.github/workflows/`) | Open | §8.1 #3 references CI; no CI file exists today |
|
||||||
|
| PHI fixtures flagged as PHI (`docs/prodfiles/PHI.md` or equivalent) | Open | Completeness review §3.2.23 |
|
||||||
|
| Delete stale `backend/src/cyclone/workflow/__pycache__/` | Open | Empty dead-code directory (§5.1) |
|
||||||
|
| EHR/PM integration (webhook in/out, callable `POST /api/parse-837`) | Open | Completeness review §5.1 |
|
||||||
|
| COB / secondary-claim generator | Open | Completeness review §3.1.12 + §5.1 |
|
||||||
|
| Real-time eligibility round-trip (SFTP pickup + SOAP + retry + 271 parse) | Open | Completeness review §3.1.8 + §5.1 |
|
||||||
|
| 837I / 837D / 834 / 820 / 275 / 278 / 276/277 transaction types | Open | Completeness review §2.1 |
|
||||||
|
| NPPES NPI registry lookup | Open | Completeness review §3.1.10 |
|
||||||
|
| ICD-10 / HCPCS / NDC vocabulary tables | Open | Completeness review §3.1.11 |
|
||||||
|
|
||||||
|
### 6.3 Traceability matrix (compressed)
|
||||||
|
|
||||||
|
This matrix is the single index. Each row points to a requirement and where it's realized. A change to a requirement must update its row; a change to an SP must update the matching FRs/NFRs.
|
||||||
|
|
||||||
|
| ID | SP | Spec | Plan | Key tests |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| FR-1 | SP1 | production-readiness | production-readiness | `test_parse_837.py`, `test_prodfiles_smoke.py` |
|
||||||
|
| FR-2 | SP1, SP3 | production-readiness + edi-features | production-readiness + edi-features | `test_parse_835.py`, `test_service_line_payments.py` |
|
||||||
|
| FR-3 | SP2 | db-reconciliation | db-reconciliation | `test_db.py`, `test_api_parse_persists.py` |
|
||||||
|
| FR-4 | SP2 | db-reconciliation | db-reconciliation | `test_db_migrate.py` |
|
||||||
|
| FR-5 | SP2 | db-reconciliation | db-reconciliation | `test_reconcile.py`, `test_store_reconcile.py` |
|
||||||
|
| FR-6 | SP2 | db-reconciliation | db-reconciliation | `test_models.py`, `test_reconcile.py` |
|
||||||
|
| FR-7 | SP2 | db-reconciliation | db-reconciliation | `test_reconcile.py`, `test_reconcile_line_level.py`, `test_inbox_endpoints.py`, `test_api_gets.py` |
|
||||||
|
| FR-8 | SP3, SP20 | edi-features + npi-validation | edi-features | `test_validator.py`, `test_validation_r200_r210.py` |
|
||||||
|
| FR-9 | SP3 | edi-features | edi-features | `test_acks.py`, `test_api_parse_persists_ack.py` |
|
||||||
|
| FR-10 | SP3 | edi-features | edi-features | `test_api_999.py`, `test_models_999.py`, `test_parse_999.py` |
|
||||||
|
| FR-11 | SP3 | edi-features | edi-features | `test_api_ta1.py`, `test_parse_ta1.py` |
|
||||||
|
| FR-12 | SP3 | edi-features | edi-features | `test_parse_270.py`, `test_parse_271.py`, `test_api_eligibility.py` |
|
||||||
|
| FR-13 | SP10 | parse-decide-workflow (277CA section) | parse-decide-workflow | `test_parse_277ca.py`, `test_apply_277ca_rejections.py` |
|
||||||
|
| FR-14 | SP6, SP10, SP14 | workflow-automation + parse-decide-workflow | workflow-automation | `test_inbox_lanes.py`, `test_inbox_endpoints.py`, `test_lane_filter_acknowledged.py` |
|
||||||
|
| FR-15 | SP6 | workflow-automation | workflow-automation | `test_scoring.py` |
|
||||||
|
| FR-16 | SP4, SP7, SP21 | claim-drawer + line-reconciliation + universal-drilldown | claim-drawer + line-reconciliation + universal-drilldown | `test_api_claim_detail.py`, `test_store_claim_detail.py` |
|
||||||
|
| FR-17 | SP4 | claim-drawer | claim-drawer | `test_batch_diff.py` |
|
||||||
|
| FR-18 | SP4 | claim-drawer | claim-drawer | `useSearch.test.ts`, `SearchResults.test.tsx` |
|
||||||
|
| FR-19 | SP4 | claim-drawer | claim-drawer | `ExportCsvButton.test.tsx`, `csv.test.ts` |
|
||||||
|
| FR-20 | SP5 | live-tail | live-tail | `test_api_stream_live.py`, `useTailStream.test.ts`, `tail-stream.test.ts` |
|
||||||
|
| FR-21 | SP5 | live-tail | live-tail | `TailStatusPill.test.tsx`, `useMergedTail.test.ts` |
|
||||||
|
| FR-22 | SP7 | line-reconciliation | line-reconciliation | `test_reconcile_line_level.py`, `test_api_line_reconciliation.py` |
|
||||||
|
| FR-23 | SP8 | serialize-837 | serialize-837 | `test_serialize_837.py`, `test_api_serialize_837.py` |
|
||||||
|
| FR-24 | SP8 | serialize-837 | serialize-837 | `test_api_batch_export_837.py` |
|
||||||
|
| FR-25 | SP9 | multi-payer-npi-sftp | (none on disk) | `test_providers_seed.py`, `test_payer_config_loading.py` |
|
||||||
|
| FR-26 | SP13 | multi-payer-npi-sftp (extends) | (none on disk) | `test_sftp_paramiko.py`, `test_sftp_stub.py` |
|
||||||
|
| FR-27 | SP16 | (none on disk) | (none on disk) | `test_scheduler.py`, `test_api_scheduler.py` |
|
||||||
|
| FR-28 | SP12 | (none on disk) | (none on disk) | `test_db_crypto.py`, `test_security.py` |
|
||||||
|
| FR-29 | SP15 | (none on disk) | (none on disk) | `test_api_rotate_key.py` |
|
||||||
|
| FR-30 | SP17 | encrypted-backup | (none on disk) | `test_backup_crypto.py`, `test_backup_service.py`, `test_backup_scheduler.py`, `test_api_backup.py`, `test_cli_backup.py` |
|
||||||
|
| FR-31 | SP18 | structured-logging | (none on disk) | `test_logging_formatter.py`, `test_logging_scrubber.py`, `test_logging_setup.py` |
|
||||||
|
| FR-32 | SP19 | security-hardening | (none on disk) | `test_security.py` |
|
||||||
|
| FR-33 | SP19 | security-hardening | (none on disk) | `test_api.py:33 test_health_endpoint`, `test_security.py:204-221` (health snapshot) |
|
||||||
|
| FR-34 | SP20 | npi-validation | (none on disk) | `test_npi.py`, `test_api_validate_provider.py`, `test_cli_validate.py` |
|
||||||
|
| FR-35 | SP21 | store-split | store-split | `test_store.py` (currently covers the un-split `store.py`; will cover the split modules once `refactor/store-split` lands) |
|
||||||
|
| FR-36 | SP22 | parse-decide-workflow | parse-decide-workflow | `test_api_parse_persists.py`, `test_prodfiles_smoke.py`; migration 0014 in `claims-unique-fix` worktree |
|
||||||
|
| FR-37 | SP22 | pipeline-agent | pipeline-agent | (lives in `cyclone-pipeline/`, not this repo) |
|
||||||
|
| FR-38 | SP21 | universal-drilldown | universal-drilldown | `DrillStackProvider.test.tsx`, `useDrawerUrlState.test.ts`, plus per-drawer tests in `ClaimDrawer/`, `RemitDrawer/`, `ProviderDrawer/`, `AckDrawer/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Acceptance criteria — per-SP (compressed)
|
||||||
|
|
||||||
|
These are the existing per-SP acceptance checklists as captured in their design specs, condensed into one table. A new engineer can use this to know whether a given SP is "really done" without reading 22 specs end-to-end.
|
||||||
|
|
||||||
|
| SP | Acceptance criterion | Verified by |
|
||||||
|
|---|---|---|
|
||||||
|
| SP1 | `pytest` passes; `/api/claims`, `/api/remittances`, `/api/providers`, `/api/activity`, `/api/batches`, `/api/batches/{id}` return JSON; CORS allowlist stays `http://localhost:5173`; bind stays `127.0.0.1` | `test_api_gets.py` + manual smoke |
|
||||||
|
| SP2 | DB at `~/.local/share/cyclone/cyclone.db`; migrations apply on first run; 7-state Claim lifecycle; reconciliation runs on 835 ingest; `/reconciliation` page works | `test_db*.py`, `test_reconcile.py`, `test_reconcile_line_level.py`, `test_inbox_endpoints.py` |
|
||||||
|
| SP3 | R034/R035/R200/R210 enforced; CAS reason codes surfaced; 999 in + out; TA1 in; 270/271 builder + parser | `test_validator*.py`, `test_acks.py`, `test_api_999.py`, `test_api_ta1.py`, `test_parse_270.py`, `test_parse_271.py` |
|
||||||
|
| SP4 | Drawer opens with URL sync; Cmd-K search returns results across resources; batch diff renders added/removed/changed; CSV exports stream | `ClaimDrawer*.test.tsx`, `useSearch.test.ts`, `BatchDiff.test.tsx`, `ExportCsvButton.test.tsx` |
|
||||||
|
| SP5 | `/api/{claims,remittances,activity}/stream` returns NDJSON with snapshot + `snapshot_end` + heartbeat; client flips to `stalled` after 30s silence; reconnect ladder capped at 30s | `test_api_stream_live.py`, `TailStatusPill.test.tsx` |
|
||||||
|
| SP6 | `/inbox` shows 5 lanes (4 in SP6; 5th added in SP10); scoring tiers visible; bulk actions call the right endpoint | `test_inbox_lanes.py`, `BulkBar.test.tsx` |
|
||||||
|
| SP7 | Line reconciliation tab in ClaimDrawer matches 837 SV1 ↔ 835 SVC strictly; unmatched-line warnings surfaced | `test_reconcile_line_level.py`, `test_api_line_reconciliation.py` |
|
||||||
|
| SP8 | 113 prodfiles parse → serialize → parse back to the same `claim_id`; ZIP bundle export works | `test_prodfiles_smoke.py`, `test_api_batch_export_837.py` |
|
||||||
|
| SP9 | `config/payers.yaml` loads (via `pyyaml`); new providers/payers/payer_configs tables populate; `clearhouse.submit` writes to `staging_dir` (stub) | `test_providers_seed.py`, `test_payer_config_loading.py` |
|
||||||
|
| SP10 | 277CA parse + persist; `payer_rejected` stamp applied monotonically; lane 2 in `/inbox` renders | `test_parse_277ca.py`, `test_apply_277ca_rejections.py` |
|
||||||
|
| SP11 | `audit_log` hash chain verifies via `GET /api/admin/audit-log/verify`; any break is detected | `test_audit_log.py`, `test_api_audit_log.py` |
|
||||||
|
| SP12 | `db_crypto.is_encryption_enabled()` returns True when Keychain entry exists and `sqlcipher3` installed; otherwise falls back with WARNING | `test_db_crypto.py` |
|
||||||
|
| SP13 | `SftpClient` actually pushes to `mft.gainwelltechnologies.com:…`; credentials read from Keychain via `keyring` at call time | `test_sftp_paramiko.py` (unit); integration via `cyclone-pipeline` |
|
||||||
|
| SP14 | All 5 lanes render in `/inbox`; bulk acknowledge action drops payer-rejected claims without erasing the original rejection | `test_inbox_endpoints_sp7.py`, `test_lane_filter_acknowledged.py` |
|
||||||
|
| SP15 | `POST /api/admin/db/rotate-key` runs `PRAGMA rekey`; emits `db.key_rotated` with old + new fingerprints; concurrent calls serialized | `test_api_rotate_key.py` |
|
||||||
|
| SP16 | Scheduler ticks every interval; per-file try/except keeps the loop alive; `processed_inbound_files` de-duplicates | `test_scheduler.py`, `test_api_scheduler.py` |
|
||||||
|
| SP17 | Backup scheduler ticks every 24h; encrypted `.bin` + `.meta.json` written; restore via 2-step token works; retention prunes at 30 days | `test_backup_*.py` |
|
||||||
|
| SP18 | All logs emit as NDJSON by default; `PiiScrubber` redacts NPIs/SSNs/DOBs/names; `CYCLONE_LOG_JSON=0` falls back to `CycloneDevFormatter` | `test_logging_*.py` |
|
||||||
|
| SP19 | 413 on oversize body, 429 on rate-limit, security headers present; `api.request_rejected` audit event on 413/429; `GET /api/health` returns rich payload | `test_security.py`, `test_api.py:33` |
|
||||||
|
| SP20 | NPI Luhn catches 99% of typos; EIN rejects reserved prefixes; warning-only (placeholder NPIs in fixtures don't block ingest); CLI + API surface | `test_npi.py`, `test_api_validate_provider.py`, `test_cli_validate.py` |
|
||||||
|
| SP21 | `CycloneStore` is split into `store_persistence / store_reconcile / store_queries / store_mappers`; public store API unchanged; `DrillStackProvider` + `DrillDrawerHeader` shell used by every drillable cell | `test_store.py` (split modules), `DrillStackProvider.test.tsx`, drawer-key tests |
|
||||||
|
| SP22 | Re-parsing an 837 into a fresh batch does not 409 on `claim_id` collision; parse-then-decide workflow checks `find_existing_batch_for_claim`; pipeline agent runs 7 phases end-to-end | `test_api_parse_persists.py`, `test_prodfiles_smoke.py`, pipeline integration |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Definition of Done
|
||||||
|
|
||||||
|
### 8.1 Project-level DoD (the project as a whole is "done" when…)
|
||||||
|
|
||||||
|
1. **All FRs in §3 are implemented and tested** — verifiable by walking the §6.3 traceability matrix and confirming each row links to either a passing test or an §2.2 out-of-scope entry.
|
||||||
|
2. **All NFRs in §4 are met or have a documented exception** — NFR-1 through NFR-18 are each either met (with a citation) or carry an explicit exception in this section.
|
||||||
|
3. **All shipped SPs in §6.1 have an Acceptance row marked "Met" and the corresponding tests pass.** Tests are run via `pytest` (backend) and `npm test` (frontend) per §9.4; **the project has no CI workflow today** (round-3 backlog item — see §6.2). A release-time gate should run the suites and fail if any test is red.
|
||||||
|
4. **All backlog items in §6.2 are either scheduled, explicitly deferred with rationale, or removed** — testable as a checklist walk.
|
||||||
|
5. **All assumptions in §10 are still valid** — re-checked at release time; an invalid assumption is either promoted to a backlog item or rolled into the next release.
|
||||||
|
6. **All four reviewer questions (components / data model / dependencies / DoD) have independent agreement from at least two reviewers** — the meta-DoD: a doc with this scope is "done" when two reviewers can independently describe the system from it and agree.
|
||||||
|
|
||||||
|
### 8.2 Per-release DoD (the next release is "done" when…)
|
||||||
|
|
||||||
|
1. The new SP's design spec has been **self-reviewed** (every claim cited with line/file, every assumption recorded).
|
||||||
|
2. The implementation plan has **TDD-shaped tasks** with per-task acceptance criteria.
|
||||||
|
3. Every task's tests are green in `pytest` (backend) and `npm test` (frontend) and `npm run build` (frontend typecheck + build).
|
||||||
|
4. The new SP's Acceptance row in §6.1 is updated to "Met".
|
||||||
|
5. New audit events (if any) are listed in the audit-log test fixtures (`backend/tests/test_audit_log.py`).
|
||||||
|
6. Migration files (if any) follow the `NNNN_*.sql` ordering, append to this doc's §5.2 table, and (post-round-3) update the migration manifest (R-13).
|
||||||
|
7. The README's project-layout section is updated if any module is added/renamed/removed.
|
||||||
|
|
||||||
|
### 8.3 Per-task DoD (the smallest unit of work is "done" when…)
|
||||||
|
|
||||||
|
1. The code change is in a single commit (or a small, reviewable series).
|
||||||
|
2. The matching test exists and passes.
|
||||||
|
3. No existing test regresses.
|
||||||
|
4. The commit message names the SP and the FR/NFR it advances (e.g., `feat(sp19): … (FR-32, NFR-8)`).
|
||||||
|
5. If the task introduces a migration, it's added to `backend/src/cyclone/migrations/` with the next sequential `NNNN` and the new schema is reflected in §5.2.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Test strategy
|
||||||
|
|
||||||
|
### 9.1 Taxonomy
|
||||||
|
|
||||||
|
| Layer | Tool | Where | What it covers |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Backend unit | `pytest` | `backend/tests/` | Pure-function logic: `cyclone.reconcile.*`, `cyclone.scoring.*`, `cyclone.npi.*`, `cyclone.cas_codes`, `cyclone.audit_log.append_event`, `cyclone.edi.filenames` |
|
||||||
|
| Backend integration | `pytest` (TestClient) | `backend/tests/test_api_*.py` | FastAPI route round-trips, content negotiation, NDJSON streaming, auth/rate-limit/SP19, 999/TA1/277CA parse endpoints, scheduler endpoints |
|
||||||
|
| Backend fixture-driven | `pytest` (`tests/fixtures/*.txt`) | `backend/tests/test_prodfiles_smoke.py` | 113 + 19 + 1369 production files; round-trip property |
|
||||||
|
| Backend SQL | `pytest` (in-memory SQLite) | `backend/tests/test_db*.py` | Schema, migrations, ORM, encryption round-trip |
|
||||||
|
| Frontend unit | `vitest` (`*.test.ts(x)`) | `src/lib/`, `src/hooks/`, `src/components/ui/` | Pure functions, hooks, primitives |
|
||||||
|
| Frontend component | `vitest` + Testing Library | `src/components/`, `src/pages/` | Drawers, lane rendering, drill stack, export buttons |
|
||||||
|
| Smoke | manual / shell | `scripts/smoke.sh` (planned in SP23) | End-to-end parse → reconcile → export flow |
|
||||||
|
|
||||||
|
### 9.2 Fixture sources
|
||||||
|
|
||||||
|
- `backend/tests/fixtures/minimal_*.txt` — minimal valid 270 / 271 / 277CA / 835 / 837P / 999 / TA1 / co_medicaid / unbalanced_835 — written by hand, used in unit tests.
|
||||||
|
- `docs/prodfiles/claims/*.x12` (113 files) — real-looking production files used by `test_prodfiles_smoke.py` for the round-trip property.
|
||||||
|
- `docs/prodfiles/FromHPE/` (1369 files) — full inbound directory from the production SFTP path.
|
||||||
|
- `docs/prodfiles/837p-from-axiscare/` (19 files) — production 837Ps from AxisCare.
|
||||||
|
- `docs/prodfiles/835fromco/` (5 files) — production 835s from CO Medicaid.
|
||||||
|
|
||||||
|
⚠ **PHI warning:** prodfiles contain real-looking patient data. Even in a single-operator tool, fixtures should be flagged as PHI (completeness review §3.2.23 — flagged but not yet actioned; for v1 they're kept under `docs/prodfiles/` with no additional access control).
|
||||||
|
|
||||||
|
### 9.3 Coverage goals
|
||||||
|
|
||||||
|
- **Pure-function modules** (`reconcile`, `scoring`, `npi`, `cas_codes`, `parsers`, `edi.filenames`, `batch_diff`): ≥ 90% line coverage. `pytest-cov` is installed; the gate (`--cov-fail-under=90`) is **not enforced today** and is a round-3 backlog item.
|
||||||
|
- **Store facade** (`store`, and the future `store_persistence / store_reconcile / store_queries / store_mappers` modules): every public method has at least one happy-path and one failure-path test.
|
||||||
|
- **API routes**: every route has a happy-path test; error paths (404, 409, 413, 415, 422, 423, 429, 500) covered by at least one test each.
|
||||||
|
- **Round-trip property** (SP8): 100% of `docs/prodfiles/claims/*.x12` must parse → serialize → parse to the same `claim_id`.
|
||||||
|
- **Live tail** (SP5): one happy snapshot → live item → disconnect cleanup test per endpoint.
|
||||||
|
|
||||||
|
### 9.4 How to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend
|
||||||
|
.venv/bin/pytest # 964 tests collected (2026-06-23)
|
||||||
|
.venv/bin/python -m cyclone serve
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
npm run typecheck
|
||||||
|
npm test # 73 test files
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Smoke (planned in SP23)
|
||||||
|
bash scripts/smoke.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.5 What is NOT covered (explicit)
|
||||||
|
|
||||||
|
- **Component / E2E browser tests (Playwright).** Deferred; the local-only threat model doesn't justify them. (Completeness review §3.2 + SP23 §14.)
|
||||||
|
- **Property-based tests** (Hypothesis) for the parser. Could add; not scheduled.
|
||||||
|
- **Mutation tests** (mutmut / cosmic-ray). Not scheduled.
|
||||||
|
- **Performance / load tests.** Single-operator scale; not scheduled.
|
||||||
|
- **NPPES round-trip validation** (only Luhn checksum is covered; SP20).
|
||||||
|
- **External-integration tests against the real Gainwell MFT.** The pipeline agent covers this in `cyclone-pipeline/`; out of scope for this repo.
|
||||||
|
- **CI workflow.** Not present in the repo; release gating is manual today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Assumptions
|
||||||
|
|
||||||
|
Recorded explicitly so a new engineer can challenge any of them.
|
||||||
|
|
||||||
|
| # | Assumption | If wrong, what changes |
|
||||||
|
|---|---|---|
|
||||||
|
| A1 | One operator, one host, one trading partner (CO Medicaid). | Multi-tenant / multi-payer scope would require SP23 + multi-payer NPI expansion + COB + AS2/AS4. |
|
||||||
|
| A2 | The host runs macOS (Keychain via `keyring`) OR Ubuntu (Docker secrets available, SP23). | Linux-without-Docker / Windows would require a different secrets backend (`keyring` already dispatches via Secret Service / kwallet on Linux; Windows would use Credential Manager). |
|
||||||
|
| A3 | The host is physically secure; LUKS / FileVault is the operator's concern, not Cyclone's. | If not, the SQLCipher-encrypted-DB posture is insufficient; per-file encryption of `prodfiles/` and `sftp_staging/` becomes mandatory. |
|
||||||
|
| A4 | The operator can hand the 837 ZIP to the Gainwell MFT UI manually. | Removes the need for a built-in SFTP submit (SP13); the SFTP wire stays stub. |
|
||||||
|
| A5 | The 835 lands the following Monday (CO Medicaid payment cycle) and is verified by a separate `check-835` subcommand (in `cyclone-pipeline/`), not inline. | Would require a different round-trip contract; affects SP22 pipeline agent. |
|
||||||
|
| A6 | ICD-10 / HCPCS / NDC vocabulary is not Cyclone's responsibility. | Would require vendoring vocab CSVs and adding vocabulary checks (FR + tests). |
|
||||||
|
| A7 | NPPES NPI registry lookup is not needed (offline Luhn is enough). | Would require scheduled NPPES download + online check + new FR. |
|
||||||
|
| A8 | The X12 5010 transaction set Cyclone supports is sufficient for the operator's billing workflow. | Adding 837I / 837D / 278 / 276-277 / 277CA-round-trip / etc. is each a new SP. |
|
||||||
|
| A9 | Local-only bind is sufficient — no remote access needed. | Would require auth (SP23), TLS, reverse proxy, RBAC. |
|
||||||
|
| A10 | One Python process, no message broker, no separate worker. | A multi-worker deployment would require breaking thread-affinity (SP12/SP15 specifically). |
|
||||||
|
| A11 | 964 backend tests + 73 frontend test files is the right test density for this codebase (2026-06-23). If we add more transaction types or surface area, this needs to grow proportionally; coverage gates should be enforced (round-3 backlog). | New transaction types or surface area without test growth → coverage drifts down. |
|
||||||
|
| A12 | `cyclone-pipeline/` is a sibling project, not a sub-project of this repo. | The 7-phase round-trip agent is shipped in `cyclone-pipeline/`; if that project is abandoned, the manual operator workflow remains (upload via `/upload`, SFTP by hand, `check-835` not available). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Risks and known gaps
|
||||||
|
|
||||||
|
Pulled forward from [`docs/reviews/2026-06-20-cyclone-completeness-review.md`](reviews/2026-06-20-cyclone-completeness-review.md). Status as of 2026-06-23.
|
||||||
|
|
||||||
|
| ID | Risk / gap | Source | Status |
|
||||||
|
|---|---|---|---|
|
||||||
|
| R-1 | No app-layer auth (anyone on the host can read PHI). | Completeness review §3.1.4 + §3.2.25 | **Closed by SP24** — auth shipped via the merge of `origin/main` on 2026-06-23 (commits `a25504b`..`39ae988`, `cyclone.auth.*` + `matrix_gate` dependency on every router); SP24 reconciled the docs to match. SP23 still covers the LAN-bind / Docker / RBAC product fork. |
|
||||||
|
| R-2 | SQLite plaintext if SQLCipher not enabled. | Completeness review §3.1.1 | **Partial** — SP12 + SP15 ship the encryption option, but it falls back to plain if the Keychain entry is missing. |
|
||||||
|
| R-3 | No Dockerfile / docker-compose.yml. | Completeness review §3.2.29 | **Open** — SP23 spec covers it but is awaiting user decision. |
|
||||||
|
| R-4 | PayerConfig still partly in code (`co_medicaid()` factory at `cyclone/parsers/payer.py:57-58`). | Completeness review §3.1.9 | **Open** — SP9 externalized config but the factory remains as a fallback for tests and default-payer selection. |
|
||||||
|
| R-5 | No vocabulary tables (ICD-10 / HCPCS / NDC). | Completeness review §3.1.11 | **Open** — explicitly out of scope per A6. |
|
||||||
|
| R-6 | No COB / secondary-claim generator. | Completeness review §3.1.12 | **Open** — explicitly out of scope per §2.2. |
|
||||||
|
| R-7 | No real-time eligibility round-trip. | Completeness review §3.1.8 | **Open** — 270/271 are file-exchange only per SP3. |
|
||||||
|
| R-8 | No NPPES NPI lookup. | Completeness review §3.1.10 | **Open** — only Luhn checksum (SP20). |
|
||||||
|
| R-9 | No 837I / 837D / 834 / 820 / 275 / 278 / 276-277 transaction types. | Completeness review §2.1 | **Open** — explicitly out of scope per §2.2. |
|
||||||
|
| R-10 | Score weights hardcoded (`cyclone/scoring.py:42-46`). | Completeness review §3.1.15 | **Open** — not scheduled. |
|
||||||
|
| R-11 | `cyclone/store.py` is a 2,172-line god-module. | Completeness review §3.1.17 | **In-flight** — SP21 spec + plan written; branch `refactor/store-split` exists; merge to `main` pending. |
|
||||||
|
| R-12 | `cyclone/api.py` is a 3,145-line god-module (larger than `store.py`). | Completeness review §3.1.19 | **Partial** — `api_routers/` exists for 4 sub-routes (acks, admin, health, ta1_acks); bulk of routes still in `api.py`. |
|
||||||
|
| R-13 | Migrations in flat dir without manifest. | Completeness review §3.1.18 | **Open** — no checksum list; 12 SQL files in `backend/src/cyclone/migrations/`. |
|
||||||
|
| R-14 | No `pre-commit` / `Makefile` / `CONTRIBUTING.md` / `.editorconfig` / `ruff` config. | Completeness review §3.2.30 | **Open** — single-operator project. |
|
||||||
|
| R-15 | No license file (`LICENSE`). | Completeness review §3.2.28 | **Open** — not scheduled. |
|
||||||
|
| R-16 | PHI fixtures not flagged as PHI. | Completeness review §3.2.23 | **Open** — not scheduled. |
|
||||||
|
| R-17 | `/api/health` not rich (before SP19). | Completeness review §3.2.24 | **Closed by SP19** — `api_routers/health.py:28-40` returns the rich snapshot via `get_health_snapshot()`. |
|
||||||
|
| R-18 | No CSP / no security headers (before SP19). | Completeness review §3.2.25 | **Closed by SP19** — `SecurityHeadersMiddleware` in `cyclone/security.py`. |
|
||||||
|
| R-19 | No request body size / rate limit (before SP19). | Completeness review §3.1.4 | **Closed by SP19** — `BodySizeLimitMiddleware` + `RateLimitMiddleware` in `cyclone/security.py`. |
|
||||||
|
| R-20 | No 277CA support (before SP10). | Completeness review §3.1.7 | **Closed by SP10**. |
|
||||||
|
| R-21 | No structured logging (before SP18). | Completeness review §3.1.5 | **Closed by SP18**. |
|
||||||
|
| R-22 | No backup automation (before SP17). | Completeness review §3.1.3 | **Closed by SP17**. |
|
||||||
|
| R-23 | `ActivityEvent` not tamper-evident (before SP11). | Completeness review §3.1.2 | **Closed by SP11** — but note: the tampered table is `audit_log`, distinct from `activity_events` (see NFR-3 / NFR-4). |
|
||||||
|
| R-24 | SP23 product fork (auth + Docker + RBAC + LAN-bind). | §6.2 | **Awaiting user decision** — see §2.2 and §11 R-1. |
|
||||||
|
| R-25 | `vitest@^4.1.9` is unusually new (current stable line is 1.x/2.x). | §5.3 | **Open** — verify the version pin is intentional before the next `npm install`; fallback to `^2.x` if accidental. |
|
||||||
|
| R-26 | No CI workflow (`.github/workflows/`). | §8.1 #3 | **Open** — release gating is manual today. |
|
||||||
|
| R-27 | `backend/src/cyclone/workflow/__pycache__/` is dead bytecode. | §5.1 | **Open** — recommend deletion. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. References
|
||||||
|
|
||||||
|
### 12.1 Design specs
|
||||||
|
|
||||||
|
All under [`docs/superpowers/specs/`](superpowers/specs/):
|
||||||
|
|
||||||
|
- [2026-06-19-cyclone-837p-parser-design.md](superpowers/specs/2026-06-19-cyclone-837p-parser-design.md) — pre-SP1, superseded by `production-readiness`
|
||||||
|
- [2026-06-19-cyclone-db-reconciliation-design.md](superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md) — SP2
|
||||||
|
- [2026-06-19-cyclone-production-readiness-design.md](superpowers/specs/2026-06-19-cyclone-production-readiness-design.md) — SP1
|
||||||
|
- [2026-06-20-cyclone-claim-drawer-design.md](superpowers/specs/2026-06-20-cyclone-claim-drawer-design.md) — SP4 (no inbox section; SP6 ships from the workflow-automation plan)
|
||||||
|
- [2026-06-20-cyclone-edi-features-design.md](superpowers/specs/2026-06-20-cyclone-edi-features-design.md) — SP3
|
||||||
|
- [2026-06-20-cyclone-line-reconciliation-design.md](superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md) — SP7
|
||||||
|
- [2026-06-20-cyclone-live-tail-design.md](superpowers/specs/2026-06-20-cyclone-live-tail-design.md) — SP5
|
||||||
|
- [2026-06-20-cyclone-multi-payer-npi-sftp-design.md](superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md) — SP9 (extended by SP13)
|
||||||
|
- [2026-06-20-cyclone-serialize-837-design.md](superpowers/specs/2026-06-20-cyclone-serialize-837-design.md) — SP8 (spec's own title mistakenly says "Sub-project 7")
|
||||||
|
- [2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md](superpowers/specs/2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md) — pre-SP22, superseded by `parse-decide-workflow`
|
||||||
|
- [2026-06-21-cyclone-encrypted-backup-design.md](superpowers/specs/2026-06-21-cyclone-encrypted-backup-design.md) — SP17
|
||||||
|
- [2026-06-21-cyclone-npi-validation-design.md](superpowers/specs/2026-06-21-cyclone-npi-validation-design.md) — SP20
|
||||||
|
- [2026-06-21-cyclone-parse-decide-workflow-design.md](superpowers/specs/2026-06-21-cyclone-parse-decide-workflow-design.md) — SP22 (parse-decide) + covers 277CA (SP10)
|
||||||
|
- [2026-06-21-cyclone-pipeline-agent-design.md](superpowers/specs/2026-06-21-cyclone-pipeline-agent-design.md) — SP22 (pipeline agent)
|
||||||
|
- [2026-06-21-cyclone-security-hardening-design.md](superpowers/specs/2026-06-21-cyclone-security-hardening-design.md) — SP19
|
||||||
|
- [2026-06-21-cyclone-skill-catalog-design.md](superpowers/specs/2026-06-21-cyclone-skill-catalog-design.md) — documentation meta-project (not mapped to a behavioral SP)
|
||||||
|
- [2026-06-21-cyclone-store-split-design.md](superpowers/specs/2026-06-21-cyclone-store-split-design.md) — SP21 (split)
|
||||||
|
- [2026-06-21-cyclone-structured-logging-design.md](superpowers/specs/2026-06-21-cyclone-structured-logging-design.md) — SP18
|
||||||
|
- [2026-06-21-cyclone-universal-drilldown-design.md](superpowers/specs/2026-06-21-cyclone-universal-drilldown-design.md) — SP21 (drilldown)
|
||||||
|
- [2026-06-22-cyclone-ubuntu-docker-deployment-design.md](superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md) — **SP23 candidate; awaiting plan + user product-fork decision**
|
||||||
|
|
||||||
|
### 12.2 Implementation plans
|
||||||
|
|
||||||
|
All under [`docs/superpowers/plans/`](superpowers/plans/):
|
||||||
|
|
||||||
|
- [2026-06-19-cyclone-837p-parser.md](superpowers/plans/2026-06-19-cyclone-837p-parser.md) — pre-SP1, superseded
|
||||||
|
- [2026-06-19-cyclone-db-reconciliation.md](superpowers/plans/2026-06-19-cyclone-db-reconciliation.md) — SP2
|
||||||
|
- [2026-06-19-cyclone-production-readiness.md](superpowers/plans/2026-06-19-cyclone-production-readiness.md) — SP1
|
||||||
|
- [2026-06-20-cyclone-claim-drawer.md](superpowers/plans/2026-06-20-cyclone-claim-drawer.md) — SP4
|
||||||
|
- [2026-06-20-cyclone-edi-features.md](superpowers/plans/2026-06-20-cyclone-edi-features.md) — SP3
|
||||||
|
- [2026-06-20-cyclone-line-reconciliation.md](superpowers/plans/2026-06-20-cyclone-line-reconciliation.md) — SP7
|
||||||
|
- [2026-06-20-cyclone-live-tail.md](superpowers/plans/2026-06-20-cyclone-live-tail.md) — SP5
|
||||||
|
- [2026-06-20-cyclone-serialize-837.md](superpowers/plans/2026-06-20-cyclone-serialize-837.md) — SP8
|
||||||
|
- [2026-06-20-cyclone-workflow-automation.md](superpowers/plans/2026-06-20-cyclone-workflow-automation.md) — SP6
|
||||||
|
- [2026-06-21-cyclone-claims-unique-constraint-and-409-ux.md](superpowers/plans/2026-06-21-cyclone-claims-unique-constraint-and-409-ux.md) — pre-SP22, superseded
|
||||||
|
- [2026-06-21-cyclone-parse-decide-workflow.md](superpowers/plans/2026-06-21-cyclone-parse-decide-workflow.md) — SP22
|
||||||
|
- [2026-06-21-cyclone-pipeline-agent.md](superpowers/plans/2026-06-21-cyclone-pipeline-agent.md) — SP22
|
||||||
|
- [2026-06-21-cyclone-skill-catalog.md](superpowers/plans/2026-06-21-cyclone-skill-catalog.md) — documentation meta-project
|
||||||
|
- [2026-06-21-cyclone-store-split.md](superpowers/plans/2026-06-21-cyclone-store-split.md) — SP21 (split, in-flight)
|
||||||
|
- [2026-06-21-cyclone-universal-drilldown.md](superpowers/plans/2026-06-21-cyclone-universal-drilldown.md) — SP21 (drilldown)
|
||||||
|
|
||||||
|
### 12.3 Reviews and reference
|
||||||
|
|
||||||
|
- [2026-06-20-cyclone-completeness-review.md](reviews/2026-06-20-cyclone-completeness-review.md) — industry-scope gap analysis.
|
||||||
|
- [`docs/reference/835.md`](reference/835.md), [`docs/reference/837p.md`](reference/837p.md), [`docs/reference/co-medicaid.md`](reference/co-medicaid.md), [`docs/reference/x12naming.md`](reference/x12naming.md) — condensed X12 + CO Medicaid notes.
|
||||||
|
|
||||||
|
### 12.4 External
|
||||||
|
|
||||||
|
- HIPAA Security Rule §164.312 — audit controls, encryption, access control.
|
||||||
|
- X12 005010X222A1 (837P), 005010X221A1 (835), 005010X231A1 (999), 005010X279A1 (270/271), 005010X214 (277CA), 005010X230 (TA1).
|
||||||
|
- CAQH CORE Phase II/III (real-time eligibility — out of scope per §2.2).
|
||||||
|
- CMS NPI Luhn specification.
|
||||||
|
- NPPES API (NPI registry — out of scope per §2.2 + A7).
|
||||||
|
- OWASP password-storage cheat sheet (argon2id parameters — SP23).
|
||||||
|
- paramiko, FastAPI, SQLAlchemy 2.0, Pydantic v2, structlog, TanStack Query v5 docs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Reviewer checklist (for round 2 → round 3 closure)
|
||||||
|
|
||||||
|
For the two independent reviewers at the end of round 2 (re-verifying the corrections):
|
||||||
|
|
||||||
|
1. **Components** — Do §5.1 (backend modules) + §5.1 (frontend dirs) list every existing module that ships PHI or serves a state-changing route? Are the boundaries (DB / ORM / reconcile / store / api / pubsub / clearhouse / batch_diff / edi.filenames) drawn correctly? Can you, from §5.1 alone, draw the import graph?
|
||||||
|
2. **Data model** — Are all 12 migrations listed in §5.2 with their purpose? Are the two worktree-only migrations (0013, 0014) flagged as in-flight? Are all 18 ORM models in §5.2? Is the `db_backups` (not `backups`) correction applied? Is the `Rejection` phantom removed? Is the `ActivityEvent` vs `AuditLog` distinction clear (NFR-3 vs NFR-4)?
|
||||||
|
3. **Dependencies** — Are §5.3 runtime + tooling deps correct (no missing `pyyaml`, no missing `keyring`, no `httpx` listed as runtime, `paramiko` flagged as `[sftp]` extra, `sqlcipher3` flagged as `[sqlcipher]` extra, frontend test env is `happy-dom` not `jsdom`, vitest pinned at `^4.1.9` flagged as unusual)? Are OS-level deps (Keychain via `keyring`, SQLCipher C library, `tini`, `nginx` if SP23 ships) called out?
|
||||||
|
4. **Definition of Done** — Is §8 testable? Project DoD #6 now points at the reviewer-agreement meta-criterion. Per-release + per-task DoDs each independently checkable? FR-7 / FR-33 traceability rows corrected to point at existing test files and the correct endpoint name?
|
||||||
|
5. **Traceability** — Pick any FR-NN in §3. Can you trace it to an SP, a spec on disk (or an honest "no dedicated spec on disk" note), a plan on disk (or an honest "no plan file on disk; feat(spN) commits substitute" note), and at least one test file in `backend/tests/` or `src/`? If not, that's a remaining gap.
|
||||||
|
6. **Assumptions** — Are §10 assumptions falsifiable? Could a new engineer challenge any of them and find the test that proves/disproves them? Is A2's reference to `keyring` accurate now?
|
||||||
|
7. **Risks** — Are §11 risks current (R-11 corrected to 2,172; R-12 added; R-25/R-26/R-27 added)? Compare against `docs/reviews/2026-06-20-cyclone-completeness-review.md`. Is R-24 (SP23 fork) clearly flagged as awaiting user decision?
|
||||||
|
|
||||||
|
If reviewers materially agree on all seven, round 2 closes; this doc becomes the project's new top-level entry point (linked from `README.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of round 2. Next: re-verify with reviewers (round 3) or escalate any remaining disagreement to the user.*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ISA*00* *00* *ZZ*11525703 *ZZ*COMEDASSISTPROG*240911*2240*^*00501*240901088*1*P*:~GS*HC*11525703*COMEDASSISTPROG*20240911*224042*240007724*X*005010X222A1~ST*837*24007724*005010X222A1~BHT*0019*00*s9911i2440g11525703d*20240911*224042*CH~NM1*41*2*Dzinesco*****46*11525703~PER*IC*Tyler Martinez*EM*tmartinez@gmail.com~NM1*40*2*COLORADO MEDICAL ASSISTANCE PROGRAM*****46*COMEDASSISTPROG~HL*1**20*1~PRV*BI*PXC*251E00000X~NM1*85*2*TOC, Inc.*****XX*1881068062~N3*1100 East Main St*Suite A~N4*Montrose*CO*814014063~REF*EI*721587149~HL*2*1*22*0~SBR*P*18*******MC~NM1*IL*1*Phillips*Esther ****MI*J851806~N3*579 NORWOOD RD~N4*Montrose*CO*814034628~DMG*D8*19390307*F~NM1*PR*2*CO_TXIX*****PI*CO_TXIX~CLM*t991102440o1c79d*595.32***12:B:1*Y*A*Y*Y~REF*G1*2~HI*ABK:R69~LX*1~SV1*HC:T1019:U1:KX*125.40*UN*19.00***1~DTP*472*D8*20240902~REF*6R*t991102440v587348d~LX*2~SV1*HC:T1019:U1:KX*123.20*UN*18.67***1~DTP*472*D8*20240903~REF*6R*t991102440v587638d~LX*3~SV1*HC:T1019:U1:KX*123.64*UN*18.73***1~DTP*472*D8*20240904~REF*6R*t991102440v587903d~LX*4~SV1*HC:T1019:U1:KX*123.64*UN*18.73***1~DTP*472*D8*20240905~REF*6R*t991102440v588158d~LX*5~SV1*HC:T1019:U1:KX*99.44*UN*15.07***1~DTP*472*D8*20240906~REF*6R*t991102440v588441d~SE*42*24007724~GE*1*240007724~IEA*1*240901088~
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
# CSS Reduction — Baseline (2026-06-23)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Reduce `dist/assets/index-*.css` bytes without changing any tested screen.
|
||||||
|
One declaration/rule at a time, verified by pixel-identical screenshots
|
||||||
|
and project checks. This file is the baseline the iterative loop is
|
||||||
|
compared against.
|
||||||
|
|
||||||
|
## Build (the size we are trying to shrink)
|
||||||
|
| metric | bytes |
|
||||||
|
| ------------ | ------ |
|
||||||
|
| raw | 60,695 |
|
||||||
|
| gzip | 11,786 |
|
||||||
|
| brotli | 9,875 |
|
||||||
|
|
||||||
|
`dist/assets/index-BznqBut5.css`, built with `npx vite build` (Vite 5.4.21,
|
||||||
|
8.9s).
|
||||||
|
|
||||||
|
## Test matrix (the screens we will hold pixel-identical)
|
||||||
|
- **Theme**: dark only (the app is `<html class="dark">` + `theme="dark"`
|
||||||
|
for the Toaster; no light path exists in the design system).
|
||||||
|
- **Sizes**: 3 (desktop 1440×900, tablet 768×1024, mobile 375×812).
|
||||||
|
- **Routes**: 13 (`/`, `/upload`, `/inbox`, `/claims`, `/claims?status=denied`,
|
||||||
|
`/remittances`, `/providers`, `/reconciliation`, `/acks`, `/batches`,
|
||||||
|
`/batch-diff`, `/activity`, `/does-not-exist`).
|
||||||
|
- **Interactive states**: 6 (claim drawer open, remit drawer open, inbox
|
||||||
|
row focused, search focused, keyboard cheatsheet open, upload dropzone
|
||||||
|
focused) × 3 sizes = 18 captures.
|
||||||
|
- **Total**: 13 × 3 + 6 × 3 = **57 screenshots** per build.
|
||||||
|
|
||||||
|
Capture source: `audit-uiux-extended.mjs` (new, superset of
|
||||||
|
`audit-uiux.mjs`). Pixel-diff harness: `tools/css-screenshots-diff.mjs`
|
||||||
|
using `pixelmatch` (threshold 0).
|
||||||
|
|
||||||
|
## Project checks (the gates we hold green)
|
||||||
|
| check | baseline | used as gate? | reason |
|
||||||
|
| --------------------------- | -------- | ------------- | --------------------------------------- |
|
||||||
|
| `npx vite build` | passes | **yes** | produces the CSS bundle under reduction |
|
||||||
|
| `npx vitest run` | 498 / 501 | **yes** | pre-existing 3 failures, see "Known failures" below |
|
||||||
|
| `tsc -b` (in `npm run build`)| **fails**| no | 15 type errors in test files + `Upload.tsx`, all pre-existing |
|
||||||
|
| `npm run typecheck` | fails | no | same as above |
|
||||||
|
| `npm run lint` | fails | no | `sh: eslint: command not found` (eslint not installed) |
|
||||||
|
|
||||||
|
### Known pre-existing failures (not caused by this work)
|
||||||
|
`vitest run` — 3 failing tests:
|
||||||
|
- `src/pages/Inbox.test.tsx > Inbox page > SP14: payer-rejected row count rolls up into the need-eyes header`
|
||||||
|
- `src/store/tail-store.test.ts > useTailStore > test_fifo_cap_evicts_oldest_when_over_10000`
|
||||||
|
- `src/components/inbox/InboxHeader.test.tsx > InboxHeader > renders the date and counts`
|
||||||
|
None touch CSS. Each candidate run will compare the new `vitest` exit
|
||||||
|
status and failure set to the baseline — a candidate is rejected if it
|
||||||
|
adds a new failure, even if the count stays at 3.
|
||||||
|
|
||||||
|
`tsc -b` / `npm run typecheck` — 15 errors in test files and
|
||||||
|
`src/pages/Upload.tsx`. Not exercised by the reduction loop.
|
||||||
|
`npm run lint` — `eslint` is not installed in `devDependencies`; the
|
||||||
|
script `npm run lint` exits 127. Not exercised.
|
||||||
|
|
||||||
|
## Interactive-state coverage
|
||||||
|
Out of 18 interactive captures, 15 succeed and 3 fail (the same 3 every
|
||||||
|
run):
|
||||||
|
|
||||||
|
- `remittances-drawer` × all sizes — the `/remittances` page renders with
|
||||||
|
no table rows in the current backend state, so the row-click step finds
|
||||||
|
nothing. The screenshot falls back to the static `/remittances` page
|
||||||
|
with no drawer; pixel-identical comparison still works for that
|
||||||
|
fallback, but the remittance-drawer CSS itself is not exercised. This
|
||||||
|
is captured here as an **untested state** for the final report.
|
||||||
|
|
||||||
|
The other 15 interactions (`claims-drawer`, `inbox-row-focused`,
|
||||||
|
`search-focused`, `cheatsheet-open`, `upload-dropzone-focused` × 3 sizes)
|
||||||
|
all succeed. They exercise the CSS for the open claim drawer, the
|
||||||
|
keyboard cheatsheet overlay, the focused search button, the focused
|
||||||
|
upload dropzone, and the focus ring on inbox rows.
|
||||||
|
|
||||||
|
## Pre-existing noise (not regressions, not caused by this work)
|
||||||
|
From the baseline run (`baseline-report.json`):
|
||||||
|
- 6/57 flows have ≥1 console error. All from the `inbox` route and the
|
||||||
|
`inbox-row-focused` state. The 4 interactive ones are the same
|
||||||
|
source.
|
||||||
|
- 24/57 flows have ≥1 failed request. Likely the dashboard summary
|
||||||
|
endpoint (`/api/dashboard/summary`) and a few analytics / SSE probes
|
||||||
|
in the inbox.
|
||||||
|
- 0 page errors.
|
||||||
|
|
||||||
|
A candidate that does not increase these counts is fine; an increase
|
||||||
|
is informational, not a gate.
|
||||||
|
|
||||||
|
## Self-diff sanity check
|
||||||
|
`tools/css-screenshots-diff.mjs compare` with `BASE == CAND` returns
|
||||||
|
`PIXEL-IDENTICAL — 57 compared, 0 differing`. The harness works.
|
||||||
|
|
||||||
|
## What is in scope for the iterative loop
|
||||||
|
- Source: `src/index.css` (custom CSS + Tailwind base/components/utilities)
|
||||||
|
- Config: `tailwind.config.js`
|
||||||
|
- Component `.tsx` files (only if removing a Tailwind utility from a
|
||||||
|
`className` produces a smaller bundle)
|
||||||
|
|
||||||
|
## What is out of scope
|
||||||
|
- `index.html` (Google Fonts preconnect, hard to prove pixel-identical
|
||||||
|
after font source change)
|
||||||
|
- The JS bundle (separate goal)
|
||||||
|
- The backend (`backend/`)
|
||||||
|
- Any non-CSS build artifact
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# CSS Candidates — unused-or-low-value
|
||||||
|
|
||||||
|
Generated from `tools/css-candidate-scan.mjs`. 'used' = referenced in any source file. Not used does NOT mean safe to remove — verify with screenshots before keeping a change.
|
||||||
|
|
||||||
|
| type | name | used | location |
|
||||||
|
| ---- | ---- | ---- | -------- |
|
||||||
|
| css-var | `--background` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--card-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--popover` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--popover-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--muted` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--secondary` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--secondary-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--accent-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--primary` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--primary-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--destructive-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--success-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--input` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--ring` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--sidebar` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--sidebar-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--sidebar-accent` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--sidebar-border` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--surface-line` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--surface-line-soft` | **no** | src/index.css :root |
|
||||||
|
| css-var | `--m-error-bg` | **no** | src/index.css :root |
|
||||||
|
| css-var | `--radius` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--font-sans` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--font-mono` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--font-display` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--background` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--card-foreground` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--muted` | **no** | src/index.css :root (referenced inside css/config) |
|
||||||
|
| class | `.text-balance` | **no** | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.text-pretty` | **no** | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.ring-inset-hairline` | **no** | src/index.css (components/utilities layer) |
|
||||||
|
| animation | `animate-accordion-down` | **no** | tailwind.config.js animation |
|
||||||
|
| animation | `animate-accordion-up` | **no** | tailwind.config.js animation |
|
||||||
|
| animation | `animate-fade-in-soft` | **no** | tailwind.config.js animation |
|
||||||
|
| animation | `animate-slide-in-right` | **no** | tailwind.config.js animation |
|
||||||
|
| animation | `animate-ticker-blink` | **no** | tailwind.config.js animation |
|
||||||
|
| css-var | `--foreground` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--card` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--muted-foreground` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--accent` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--signal` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--destructive` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--success` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--warning` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--warning-foreground` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--border` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--surface` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--surface-ink` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--surface-ink-2` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--surface-ink-3` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--tt-bg` | yes | src/index.css :root |
|
||||||
|
| css-var | `--tt-bg-elev` | yes | src/index.css :root |
|
||||||
|
| css-var | `--tt-ink` | yes | src/index.css :root |
|
||||||
|
| css-var | `--tt-ink-dim` | yes | src/index.css :root |
|
||||||
|
| css-var | `--tt-amber` | yes | src/index.css :root |
|
||||||
|
| css-var | `--tt-oxblood` | yes | src/index.css :root |
|
||||||
|
| css-var | `--tt-ink-blue` | yes | src/index.css :root |
|
||||||
|
| css-var | `--tt-muted` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-surface` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-ink-primary` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-ink-secondary` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-ink-tertiary` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-border-heavy` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-font-mono` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-error` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-success` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-warning` | yes | src/index.css :root |
|
||||||
|
| css-var | `--m-accent` | yes | src/index.css :root |
|
||||||
|
| css-var | `--foreground` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--card` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--muted-foreground` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--border` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| css-var | `--accent` | yes | src/index.css :root (referenced inside css/config) |
|
||||||
|
| class | `.num` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.display` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.mono` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.surface` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.surface-2` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.hairline` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.nav-active` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.skip-link` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.kbd` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.eyebrow` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.editorial` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.row-hover` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.animate-scan` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| class | `.drillable` | yes | src/index.css (components/utilities layer) |
|
||||||
|
| keyframes | `accordion-down` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `accordion-up` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `fade-in` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `fade-in-soft` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `fade-in-up` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `slide-in-right` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `row-flash` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `pulse-dot` | yes | tailwind.config.js keyframes |
|
||||||
|
| keyframes | `ticker-blink` | yes | tailwind.config.js keyframes |
|
||||||
|
| animation | `animate-fade-in` | yes | tailwind.config.js animation |
|
||||||
|
| animation | `animate-fade-in-up` | yes | tailwind.config.js animation |
|
||||||
|
| animation | `animate-row-flash` | yes | tailwind.config.js animation |
|
||||||
|
| animation | `animate-pulse-dot` | yes | tailwind.config.js animation |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
- 36 unused / 100 total
|
||||||
|
- 64 used / 100 total
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"id":"01-var-surface-line-soft","priority":"H","label":"drop --surface-line-soft declaration","file":"src/index.css","before":" --surface-line-soft: 30 14% 14% / 0.12;\n","after":""}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# CSS Reduction — Candidate Queue
|
||||||
|
|
||||||
|
Atomic removals to try, in priority order. Each is one revertable unit
|
||||||
|
(edit one file, revert if pixel-identical or project checks regress).
|
||||||
|
|
||||||
|
Priority legend: **H** (high confidence, no visible effect expected),
|
||||||
|
**M** (medium, may affect pixels in edge cases), **L** (low, will likely
|
||||||
|
revert because it changes visible output).
|
||||||
|
|
||||||
|
| # | priority | id | what | where | expected | notes |
|
||||||
|
|---|----------|----|------|-------|----------|-------|
|
||||||
|
| 1 | H | `var-surface-line-soft` | drop the `--surface-line-soft` declaration | `src/index.css` line 77 | ~30 B | Only mentioned in a comment in `BarChart.tsx`; no `var(--surface-line-soft)` ref anywhere |
|
||||||
|
| 2 | H | `var-m-error-bg` | drop the `--m-error-bg` declaration | `src/index.css` line 102 | ~30 B | No `var(--m-error-bg)` ref anywhere in src |
|
||||||
|
| 3 | H | `kf-fade-in-soft` | drop `fade-in-soft` keyframe + `animate-fade-in-soft` util | `tailwind.config.js` | ~150 B | `animate-fade-in-soft` is not used in any source file |
|
||||||
|
| 4 | H | `kf-slide-in-right` | drop `slide-in-right` keyframe + animation | `tailwind.config.js` | ~180 B | `animate-slide-in-right` not used |
|
||||||
|
| 5 | H | `kf-ticker-blink` | drop `ticker-blink` keyframe + animation | `tailwind.config.js` | ~200 B | `animate-ticker-blink` not used |
|
||||||
|
| 6 | H | `kf-accordion-down` | drop `accordion-down` keyframe + animation | `tailwind.config.js` | ~150 B | `animate-accordion-down` not used; no accordion in app |
|
||||||
|
| 7 | H | `kf-accordion-up` | drop `accordion-up` keyframe + animation | `tailwind.config.js` | ~150 B | same as above |
|
||||||
|
| 8 | H | `media-print` | drop the entire `@media print { ... }` block | `src/index.css` lines 402–446 | ~1 KB | Print not in test matrix; the `display: none` rules apply to aside/header — they don't appear in screenshots anyway. **Risk**: a printed detail drawer would no longer be styled for paper, but no print preview is captured. |
|
||||||
|
| 9 | M | `body-before` | drop the `body::before` radial gradients (the upper-right accent + top-center softbox) | `src/index.css` lines 147–156 | ~400 B | Subtle background. Likely visible on all screenshots. **Will likely revert.** |
|
||||||
|
| 10 | M | `body-after` | drop the `body::after` hairline grid | `src/index.css` lines 163–176 | ~450 B | Subtle background grid. Likely visible. **Will likely revert.** |
|
||||||
|
| 11 | M | `scrollbar` | drop `::-webkit-scrollbar*` rules | `src/index.css` lines 179–195 | ~330 B | Scrollbars only visible when content overflows. Most screenshots are above-the-fold; may not trigger. |
|
||||||
|
| 12 | M | `nav-active-before` | drop the `::before` accent strip on `.nav-active` | `src/index.css` lines 290–300 | ~180 B | Visible on the active sidebar item. |
|
||||||
|
| 13 | M | `surface-2` | drop the `.surface-2` class entirely | `src/index.css` lines 266–277 | ~250 B | Used in some components — likely a revert. |
|
||||||
|
| 14 | M | `focus-visible` | drop the global `:focus-visible` ring | `src/index.css` lines 200–204 | ~90 B | All focusable elements would lose the global ring (but Tailwind focus-visible: utilities still apply). May or may not change pixels. |
|
||||||
|
| 15 | L | `body-bg-image` | drop the body background-image gradients | `src/index.css` lines 136–139 | ~250 B | **Will revert** — visible. |
|
||||||
|
| 16 | L | `skip-link` | drop the `.skip-link` class | `src/index.css` lines 303–338 | ~700 B | Component exists (`ui/skip-link.tsx`). Removing the class breaks it visually. **Will revert.** |
|
||||||
|
| 17 | L | `drillable` | drop `.drillable` and its hover | `src/index.css` lines 449–461 | ~220 B | Used in `DrillableCell.tsx`. **Will revert.** |
|
||||||
|
| 18 | L | `row-hover` | drop the `.row-hover` hover state | `src/index.css` lines 379–384 | ~100 B | Used in tables. Hover state not captured (no mouse). Could be safe. |
|
||||||
|
|
||||||
|
## Out of scope (intentionally not tried)
|
||||||
|
- Tailwind preflight (`*, ::before, ::after`, `::backdrop` with `--tw-*` vars). These power the utility system; removing would break all utilities.
|
||||||
|
- `@media (prefers-reduced-motion)`. Safety block, removing would change behavior in non-default OS settings.
|
||||||
|
- Specific custom class definitions that are heavily used (`.num`, `.display`, `.mono`, `.surface`, `.eyebrow`, `.editorial`).
|
||||||
|
- Any Tailwind utility removal in component `className` props (small wins, lots of churn — would be done in a follow-up pass).
|
||||||
|
- The `index.html` Google Fonts `<link>` (it's HTML, not CSS, and removing it would change the rendered output).
|
||||||
|
|
||||||
|
## Stopping conditions
|
||||||
|
- Queue exhausted.
|
||||||
|
- 5 consecutive candidates fail the **size** check (CSS not smaller after the change).
|
||||||
|
- 2 hours elapsed in Phase 3.
|
||||||
|
- User requests a stop.
|
||||||
+1327
File diff suppressed because it is too large
Load Diff
+468
@@ -0,0 +1,468 @@
|
|||||||
|
{
|
||||||
|
"baseDir": "docs/reviews/2026-06-23-css-reduction/iter/01-var-surface-line-soft/_ref",
|
||||||
|
"candDir": "docs/reviews/2026-06-23-css-reduction/iter/01-var-surface-line-soft/_cand",
|
||||||
|
"diffOutDir": "docs/reviews/2026-06-23-css-reduction/iter/01-var-surface-line-soft/_diff",
|
||||||
|
"threshold": 0,
|
||||||
|
"compared": 57,
|
||||||
|
"diffCount": 15,
|
||||||
|
"missingCount": 0,
|
||||||
|
"extraCount": 0,
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"file": "404--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "404--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "404--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "acks--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "acks--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "acks--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "activity--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "activity--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "activity--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "batch-diff--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "batch-diff--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "batch-diff--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "batches--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "batches--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "batches--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "cheatsheet-open--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 2523,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "cheatsheet-open--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "cheatsheet-open--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 1,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims-denied--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims-denied--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims-denied--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims-drawer--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims-drawer--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "claims-drawer--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "dashboard--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 2523,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "dashboard--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 3,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "dashboard--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "inbox--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 530,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "inbox--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 142,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "inbox--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 271,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "inbox-row-focused--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 529,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "inbox-row-focused--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 150,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "inbox-row-focused--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 150,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "providers--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "providers--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "providers--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "reconciliation--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "reconciliation--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "reconciliation--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "remittances--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "remittances--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "remittances--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "remittances-drawer--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "remittances-drawer--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "remittances-drawer--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 372,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "search-focused--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 9684,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "search-focused--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 47,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "search-focused--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "upload--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 1,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "upload--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 1,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "upload--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "upload-dropzone-focused--desktop.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 1296000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "upload-dropzone-focused--mobile.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 375,
|
||||||
|
"height": 812,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 304500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "upload-dropzone-focused--tablet.png",
|
||||||
|
"size": "ok",
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024,
|
||||||
|
"diffPixels": 0,
|
||||||
|
"totalPixels": 786432
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1322
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "01-var-surface-line-soft",
|
||||||
|
"priority": "H",
|
||||||
|
"label": "drop --surface-line-soft declaration",
|
||||||
|
"file": "src/index.css",
|
||||||
|
"kept": false,
|
||||||
|
"diffCount": 15,
|
||||||
|
"diffMax": 9684
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# CSS Reduction — Progress Log
|
||||||
|
|
||||||
|
## Baseline
|
||||||
|
- raw: 60,695 B · gzip: 11,786 B · brotli: 9,875 B
|
||||||
|
- 57 screenshots captured
|
||||||
|
- vitest: 498/501 (3 pre-existing failures)
|
||||||
|
- tsc: 15 pre-existing failures (gate skipped)
|
||||||
|
- lint: `eslint` not installed (gate skipped)
|
||||||
|
|
||||||
|
## Candidates
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# Cyclone Documentation Review — Reviewer A
|
||||||
|
|
||||||
|
## 1. Reviewer identity
|
||||||
|
|
||||||
|
**Reviewer A** (Reviewer B is run in parallel; this review covers the full doc set + codebase ground-truth verification).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Executive summary
|
||||||
|
|
||||||
|
Cyclone's documentation is unusually thorough for a single-operator tool: a top-level `REQUIREMENTS.md` (38 FRs + 18 NFRs), a top-level `ARCHITECTURE.md`, 27 design specs (20 dated 2026-06-19 to 2026-06-22 + 7 backfilled 2026-06-23 for SP10–SP16), and 27 implementation plans (15 original + 12 backfilled for SP9–SP20). The doc set has the structure of a well-run engineering project, and the prose quality is high — every spec re-asserts the local-only/single-operator/single-payer design contract, every plan has TDD discipline, and the layering (requirements → architecture → spec → plan → tests) is internally consistent at a high level. **However, the doc set has accumulated at least 25 specific contradictions, ambiguous forks, untested artifacts, and stale numbers** between the two top-level docs alone — and the contradictions are concentrated in the most consequential areas: the live-tail HTTP surface, the claim-state machine, and the LOC/version counters used in the verification matrix. The 7-state claim lifecycle is wrong in 5 places; the codebase has an 8-state enum (`backend/src/cyclone/db.py:180-188`). The tail endpoint URL is `/api/{resource}/stream` per REQUIREMENTS FR-20 + the live-tail spec, but `/api/tail?since=…` per `ARCHITECTURE.md` §5.4 — two competent engineers reading only the docs could ship two incompatible APIs. Below, the 4 reviewer questions and 4 flag sections detail every issue with file:line citations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Q1: Components
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
Cyclone is composed of six concentric layers: (1) the SQLite/SQLCipher store at the bottom (`backend/src/cyclone/db.py`, `backend/src/cyclone/db_crypto.py`, migrations 0001–0012), (2) ORM models and pure-function business logic (`db.py`, `reconcile.py`, `scoring.py`, `npi.py`, `cas_codes.py`, `inbox_lanes.py`, `inbox_state.py`, `inbox_state_277ca.py`, `audit_log.py`), (3) the per-parser modules under `parsers/` (16 files including the 270/271/277CA/835/999/TA1/837P parsers and serializers), (4) the persistence facade `store.py` (CycloneStore) plus per-feature handlers (`scheduler.py`, `backup_service.py`, `backup_scheduler.py`, `logging_config.py`, `security.py`, `pubsub.py`, `clearhouse/__init__.py`, `providers.py`, `payers.py`), (5) the FastAPI surface — a monolithic `api.py` (~3,548 LOC per ARCHITECTURE §3.1 / 3,145 per REQUIREMENTS) plus a partial `api_routers/` package (`acks.py`, `admin.py`, `health.py`, `ta1_acks.py`) that owns the new admin/health endpoints, and (6) the React + Vite + TypeScript frontend (`src/`) with TanStack Query v5, Zustand, Radix UI primitives, and shadcn-style components.
|
||||||
|
|
||||||
|
The top-level docs (`REQUIREMENTS.md` §2.1, `ARCHITECTURE.md` §3) describe these layers at the same level of granularity and agree on the boundaries. The cross-SP doc set reinforces them: each spec names the modules it extends (e.g. SP13 `clearhouse/__init__.py:319`, SP14 `inbox_lanes.compute_lanes`, SP15 `db_crypto + db + api`, SP16 `scheduler.py:720`). The ASCII topology diagram in `ARCHITECTURE.md` §1.2 is the canonical picture.
|
||||||
|
|
||||||
|
### Consistency verdict
|
||||||
|
|
||||||
|
**Mostly consistent, with three meaningful gaps:**
|
||||||
|
|
||||||
|
1. The `api_routers/` package is partially implemented (`acks.py`, `admin.py`, `health.py`, `ta1_acks.py`) but documented only as "planned" in `ARCHITECTURE.md` §3.2 and not mentioned at all in `REQUIREMENTS.md`; the specs still reference monolithic `cyclone.api` (e.g. SP15 §3.3 says `cyclone/api.py:2797-2928` for the rotation endpoint, but the actual code lives at `api_routers/admin.py`). Engineers building "what does Cyclone's API look like" from the spec set will get the monolithic picture; engineers reading the codebase will see a partially split package.
|
||||||
|
|
||||||
|
2. `backup.py` (file upload/retention module) and `backup_service.py` vs `backup_scheduler.py` (periodic scheduler) are described as a single "SP17" subsystem across the docs, but the spec file (`2026-06-21-cyclone-encrypted-backup-design.md`) is the *original* SP17 spec while the implementation plan is dated `2026-06-23-cyclone-sp17-encrypted-backup.md`. The two documents describe the same work but the round-3 doc-prep added the plan but did NOT add a backfilled spec for SP17. So the SP17 docs are pre/back mixed, not a clean backfill like SP10–SP16 (which got both spec and plan).
|
||||||
|
|
||||||
|
3. The `workflow/` subdirectory visible under `backend/src/cyclone/` has only `__pycache__/` entries on disk (`workflow/__pycache__/__init__.cpython-313.pyc`, `workflow/__pycache__/batch_diff.cpython-313.pyc`, `workflow/__pycache__/inbox_lanes.cpython-313.pyc`, `workflow/__pycache__/inbox_state.cpython-313.pyc`, `workflow/__pycache__/reconcile.cpython-313.pyc`, `workflow/__pycache__/scoring.cpython-313.pyc`) — but no `.py` source files. This indicates `workflow/` was a refactoring target (likely SP21 store-split) whose sources were removed/moved but whose compiled artifacts were not cleaned. **None of the docs mention `workflow/` as a deprecated stub**; engineers reading the docs will not understand why the directory exists in compiled form only.
|
||||||
|
|
||||||
|
**Verdict: medium confidence in component consistency.** The high-level decomposition is consistent across the top-level docs and the spec set. The drift is in the partial-API-router split, the SP17 plan/spec asymmetry, and the unexplained `workflow/` ghost directory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Q2: Data model
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
The data model is a SQLAlchemy 2.0 ORM (`backend/src/cyclone/db.py`) backed by SQLite, optionally encrypted via SQLCipher (`backend/src/cyclone/db_crypto.py` — SP12). Migration runner is `db_migrate.py` reading `PRAGMA user_version` — **12 migrations on disk**: `0001_initial` through `0012_backups` (listed at `backend/src/cyclone/migrations/`). The core tables are: `batches`, `claims`, `remittances`, `cas_adjustments`, `matches`, `activity_events`, `service_lines`, `service_line_payments`, `line_reconciliations`, `acks`, `ta1_acks`, `providers`, `payers`, `clearhouse`, `payer_rejected` columns on `claims` (SP10, migration 0008), `payer_rejected_acknowledged_at`/`_actor` (SP14, migration 0010), `audit_log` (SP11, migration 0009), `processed_inbound_files` (SP16, migration 0011), and `backups` (SP17, migration 0012).
|
||||||
|
|
||||||
|
The key entity relationships are: `Batch 1—N Claim`, `Batch 1—N Remittance`, `Remittance N—1 Claim` (nullable FK on `claims.matched_remittance_id`), `Remittance 1—N CasAdjustment`, `Claim 1—1 Match` (unique), `Claim 1—N ServiceLine`, `Remittance 1—N ServiceLinePayment`, `Claim 1—N LineReconciliation`, `Remittance 1—N LineReconciliation`, and `AuditLog` as a chain on its own (no FKs; the chain links by `prev_hash`). `ServiceLinePayment` is the SP7 bridge between the 837 and 835 line worlds; it carries the `procedure_code`/`service_date`/`charge`/`payment`/`units` plus `modifiers_json` as a serialized list. The `audit_log` table (SP11) is a separate hash-chained append-only log keyed by SHA-256 of `(event_type, entity_type, entity_id, actor, created_at, prev_hash)`; the chain is verifiable via `cyclone.audit_log.verify_chain(session)` (verified at `backend/src/cyclone/audit_log.py:190`) which recomputes every hash and walks the `prev_hash` links (`audit_log.py:228-242`).
|
||||||
|
|
||||||
|
The state machine has multiple representations in the docs:
|
||||||
|
|
||||||
|
- **`REQUIREMENTS.md` §3.1 + §6.3 (traceability)** says: "7-state claim lifecycle: submitted, received, paid, partial, denied, reconciled, reversed."
|
||||||
|
- **`backend/src/cyclone/db.py:180-188` (code)** says: `ClaimState` enum with **8 values**: `SUBMITTED, RECEIVED, REJECTED, PAID, PARTIAL, DENIED, RECONCILED, REVERSED`. The 8th state, `REJECTED`, was added by the workflow-automation/SP6 plan (`docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md` Task 1) when 999 AK9 R/E rejection handling was introduced.
|
||||||
|
- **`ARCHITECTURE.md` §6.3** says: "states: submitted, accepted, paid, denied, appealed, rejected, payer_rejected" — 7 states, including `accepted` (which is NOT in the code) and `payer_rejected` (which is NOT a state in the code but a set of columns on `claims` filtered on by `inbox_lanes.compute_lanes`).
|
||||||
|
- **`docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.7** (pre-SP6) says: "submitted, received, paid, partial, denied, reconciled, reversed" — 7 states, matches the pre-SP6 plan.
|
||||||
|
|
||||||
|
The `payer_rejected` status is *not* a `ClaimState` value. It is stored as four columns on `claims`: `payer_rejected_at`, `payer_rejected_reason`, `payer_rejected_status_code`, `payer_rejected_by_277ca_id` (verified at `backend/src/cyclone/db.py:270-280`; SP10 spec §2.1). The 5th inbox lane (`payer_rejected`) is computed by `inbox_lanes.compute_lanes` filtering on `payer_rejected_at IS NOT NULL AND payer_rejected_acknowledged_at IS NULL` (SP14 spec §3.2). So `payer_rejected` is a *view*, not a *state* — yet `ARCHITECTURE.md` lists it as a state.
|
||||||
|
|
||||||
|
The `Match` model carries an `is_reversal` flag (defined in the SP2 plan) and the spec calls for a `state_before_reversal` column on `claims` to preserve reversals (`db_reconciliation-design.md` §6.6). The 835 reverse-direction (CLP02 ∈ {21, 22}) is handled by `apply_reversal()` in `reconcile.py` which restores the prior state. **However, the SP10 277CA path does NOT extend the state machine** — claims with a payer rejection stay in their prior state (`paid`, `denied`, `partial`, etc.) and gain the `payer_rejected_at` timestamp; the 5-lane inbox surfaces them but `claim.state` does not transition to a new value. This is consistent with the SP10 spec §2.3 ("we explicitly do not clear `payer_rejected_at` on acknowledge") and SP14 spec §2.3, but contradicts the more casual phrasing in `ARCHITECTURE.md` §6.3.
|
||||||
|
|
||||||
|
### Consistency verdict
|
||||||
|
|
||||||
|
**Low confidence in data-model consistency across the doc set.** The `ClaimState` enum has 8 values (verified at `backend/src/cyclone/db.py:180-188`) but the docs claim 7 in 4 places (`REQUIREMENTS.md` §3.1 and §6.3, `ARCHITECTURE.md` §6.3, the db-reconciliation spec §6.7, and the workflow-automation plan Task 1 implicitly). The 5th `payer_rejected` "state" in `ARCHITECTURE.md` §6.3 is not a state in the code — it is a column-and-filter view. Engineers building the data model from the docs alone will produce either an 8-state enum without a `payer_rejected` value (correct) or a 9-state enum with both `rejected` and `payer_rejected` (wrong, not in the code).
|
||||||
|
|
||||||
|
The second meaningful gap is the dual audit mechanism. `activity_events` (db.py original migration 0001) is a regular table for *transition events*; `audit_log` (SP11, migration 0009) is a separate hash-chained table for *HIPAA-relevant events*. The docs are inconsistent on which events go where: the SP14 spec §3.3 says `claim.payer_rejected_acknowledged` is appended to `audit_log`, but the SP6 workflow plan implies `claim.rejected` (the 999 R/E event) lands in `activity_events`. The two tables overlap in name space but have different writers — `audit_log.py:99-126`'s `append_event` takes an `AuditEvent` payload but `activity_events` is written from a different code path (`store.py`). No doc explicitly enumerates which `kind` strings go to which table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Q3: Dependencies
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
There are four overlapping dependency graphs in this doc set: (a) spec-level dependencies ("depends on SP X"), (b) plan-level task dependencies (TDD phases 1 → 2 → 3 with `git worktree` rebases), (c) module/import dependencies in the codebase, and (d) data-model migration ordering. The graphs mostly agree; the divergences are concentrated in SP21/SP22 (in-flight) and SP23 (candidate).
|
||||||
|
|
||||||
|
**(a) Spec dependencies.** The 7 backfilled specs (SP10–SP16) each declare their upstreams explicitly:
|
||||||
|
- SP10 (277CA) `2026-06-23-cyclone-sp10-277ca-payer-rejected-design.md` §"Depends on:" → SP3 (999 parser) + SP6 (workflow/inbox).
|
||||||
|
- SP11 (audit log) → SP6 + SP10 + SP12.
|
||||||
|
- SP12 (SQLCipher) → standalone.
|
||||||
|
- SP13 (SFTP) → SP9 (SftpClient stub + secrets).
|
||||||
|
- SP14 (5-lane inbox) → SP6 + SP10 + SP11.
|
||||||
|
- SP15 (key rotation) → SP11 + SP12 (cannot ship without SP12; `2026-06-23-cyclone-sp15-key-rotation-design.md` line 11: "Cannot ship without SP12").
|
||||||
|
- SP16 (MFT polling) → SP9 + SP11 + SP13 + SP15 (implicitly, since SFTP auth uses the Keychain) + the per-parser modules.
|
||||||
|
|
||||||
|
These declarations are consistent with the implementation order in the git log (SP10 → SP11 → SP12 → SP13 → SP14 → SP15 → SP16) and with the migration versions (0008 through 0011).
|
||||||
|
|
||||||
|
**(b) Plan-level task dependencies.** The plans use a `[ ] Step N → commit` pattern with explicit prerequisites (e.g. `live-tail.md` Task 0 requires SP5 to be merged first; `claims-unique-constraint-and-409-ux.md` worktree setup requires `pip install -e backend`). The plan-level dependencies are clean within each plan; the cross-plan dependencies are weak. For example, the SP14 plan does not declare it depends on SP10's migration `0008_payer_rejected_columns.sql` — it just references "the SP10 `payer_rejected_*` columns" without naming the migration.
|
||||||
|
|
||||||
|
**(c) Module dependencies.** The import graph in `backend/src/cyclone/` shows the expected layering: `parsers/` modules import `cyclone.exceptions` and `cyclone.segments` only (clean isolation); `db.py` imports nothing from `cyclone.*` (forward references via string IDs); `reconcile.py`, `scoring.py`, `inbox_lanes.py` import `cyclone.db`; `store.py` imports everything (`db`, `reconcile`, `scoring`, `inbox_state`, `inbox_state_277ca`, `pubsub`); `api.py` imports `store`, `db`, `audit_log`, `scheduler`, `db_crypto`, `security`, etc. The `scheduler.py` module *intentionally duplicates* small helpers (`_ack_count_summary`, `_ack_synthetic_source_batch_id`, `_277ca_synthetic_source_batch_id`) to break what would otherwise be a circular dependency with `cyclone.api` — this is documented in SP16 spec §3.5. The `audit_log.py:99-126` `append_event` function takes a session and an `AuditEvent`, called by SP14 ack endpoint, SP15 rotation endpoint, and SP16 scheduler handlers. The store-scheduler boundary is clean.
|
||||||
|
|
||||||
|
**(d) Migration ordering.** 12 migrations on disk (`0001_initial` through `0012_backups`) are forward-only via `PRAGMA user_version`. The runner (`db_migrate.py`) reads `-- version: N` headers, sorts by filename, applies any with `version > current` (`db_migrate.py:438-466`). The version assertions in `test_db_migrate.py` confirm monotonic ordering. The `claims-unique-constraint-and-409-ux.md` plan adds migration `0013_drop_claims_unique_constraint.sql` on a worktree (`claims-unique-fix`); this migration does NOT exist on `main` yet, which is consistent with the plan being in-flight. **However, the `2026-06-23-cyclone-sp11-hash-chained-audit-design.md` spec refers to migration `0009_audit_log.sql` and the docs say SP11 ships migration 0009 — that migration IS on disk and `audit_log.py` exists, confirming SP11 shipped.**
|
||||||
|
|
||||||
|
**(e) SP21 / SP22 / SP23 status.** SP21 (store split) is in-flight on `refactor/store-split` branch (`docs/superpowers/specs/2026-06-21-cyclone-store-split-design.md` Status: "Draft / not merged"); SP22 (parse-decide) lives on `claims-unique-fix` worktree (`docs/superpowers/specs/2026-06-21-cyclone-parse-decide-workflow-design.md` + the unique-constraint plan). SP23 (Ubuntu Docker + RBAC) is a candidate only (`docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md` Status: "Draft / candidate"). These are called out in the spec titles, but the top-level docs (`REQUIREMENTS.md` §1.4, `ARCHITECTURE.md` §0.1) do NOT explicitly mark SP21/SP22/SP23 as in-flight. An engineer reading only the top-level docs would assume all 23 SPs are at the same ship-readiness level.
|
||||||
|
|
||||||
|
### Consistency verdict
|
||||||
|
|
||||||
|
**High confidence within the shipped SPs (SP1–SP20)**, where the spec→plan→code→migration→test graph is internally consistent. **Low confidence for SP21/SP22/SP23**, which are in-flight or candidate and not marked as such in the top-level docs. The spec-level "Depends on" declarations are accurate and actionable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Q4: Definition of done
|
||||||
|
|
||||||
|
### Description
|
||||||
|
|
||||||
|
`REQUIREMENTS.md` §8 (lines 643–681) defines "Definition of Done" in three sub-sections: §8.1 *Functional DoD* (every FR covered by ≥1 spec + ≥1 plan + ≥1 integration test), §8.2 *Non-Functional DoD* (every NFR has a verification command), §8.3 *Operational DoD* (deployment posture, backup readiness, encryption posture). Each FR has a row in the §6.1 traceability matrix pointing at the covering spec/plan and a test file. `ARCHITECTURE.md` §10 ("Operational Story") describes the deploy / backup / runbook posture in narrative form.
|
||||||
|
|
||||||
|
The §8.1 Functional DoD says every FR is "Met" if it has a covering spec, plan, and integration test. The §6.1 traceability matrix marks FR-1 through FR-38 with a single test file each (e.g. FR-20 → `test_api_stream_live.py` + `TailStatusPill.test.tsx`). The matrix does NOT explicitly require happy-path AND failure-path test coverage — but `REQUIREMENTS.md` §4 NFR-2 ("Test discipline: every route has a happy-path test and at least one failure-path test") adds this requirement at the NFR layer. So the union of FR + NFR DoD is: each route has a happy + failure path test.
|
||||||
|
|
||||||
|
The DoD is *partially testable*: the test-file pointers in the traceability matrix are verifiable by `ls backend/tests/` and `ls src/**/*.test.ts*`; the happy/failure-path test count is verifiable by reading each test file. The encryption-at-rest posture (NFR-5 / SP12) is verifiable by checking that `cyclone.db_crypto.is_encryption_enabled()` returns `True` when `sqlcipher3` is installed and the Keychain entry exists (per the SP12 spec §1). The single-process posture (NFR-14) is verifiable by reading the scheduler module's lack of `threading.Thread` / `multiprocessing.Process` callers. The audit-chain integrity (NFR-4 / SP11) is verifiable by `GET /api/admin/audit-log/verify` returning `ok: true, checked: N+1`.
|
||||||
|
|
||||||
|
### Testability verdict
|
||||||
|
|
||||||
|
**Medium confidence.** Four issues break testability:
|
||||||
|
|
||||||
|
1. The `REQUIREMENTS.md` §8.3 Operational DoD mentions "Vitest ^4.1.9" and flags it in §5.3 / R-25 as "unusually new — verify the version pin is intentional before the next npm install; fallback to `^2.x` if accidental." The `ARCHITECTURE.md` §3.2 (tooling table) lists `vitest 4.1.9` without flagging it. **The flagging is inconsistent — one top-level doc treats it as a problem to fix, the other treats it as normal.** A CI gate that asserts "vitest@^2.x" would fail on the codebase.
|
||||||
|
|
||||||
|
2. The §8.3 DoD claims `vitest@^4.1.9` is "unusually new (current stable line is 1.x/2.x)" — but Vitest 4.x *does exist* (released after Vitest 3.x in 2025); the claim is also inaccurate relative to the doc's own date. The §6.3 acceptance row for SP5 says the live-tail test count target was "~144 frontend pass" with "~437 backend" — but `backend/tests/` contains 94 test files (verified by directory listing). The number is in the right order of magnitude but unverified.
|
||||||
|
|
||||||
|
3. The §10 Operational Story in `ARCHITECTURE.md` describes "5-second heartbeat" on the live-tail stream — but `REQUIREMENTS.md` §4 NFR-10 (line 163) says "Heartbeat every 15s default (`CYCLONE_TAIL_HEARTBEAT_S`)". The actual `backend/src/cyclone/api.py:1679-1680` comment says `CYCLONE_TAIL_HEARTBEAT_S` (consistent with REQUIREMENTS, not ARCHITECTURE). **The 5s heartbeat claim is wrong against both the codebase AND REQUIREMENTS.md.** The operational story in §10 cannot be the basis for a deploy runbook until this is corrected.
|
||||||
|
|
||||||
|
4. The §10 Operational Story does not describe the SP16 scheduler (`CYCLONE_SCHEDULER_AUTOSTART` opt-in, `CYCLONE_SCHEDULER_POLL_SECONDS` default 60) at all — only the manual operator path. The §8.3 DoD also does not mention "scheduler running without `CYCLONE_SCHEDULER_AUTOSTART=true`" as a verified-state precondition. The "auto-poll" deploy posture introduced by SP16 is invisible to the top-level DoD.
|
||||||
|
|
||||||
|
**Verdict:** The DoD is *partially* testable. The functional DoD (each FR → test file) is verifiable by file enumeration. The non-functional DoD (each NFR → verification command) is mostly verifiable but the Vitest version flag, the heartbeat interval, and the scheduler autostart posture create 3 places where a competent engineer reading only the top-level docs would either install the wrong version or deploy the wrong polling config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Contradictions
|
||||||
|
|
||||||
|
The following are direct contradictions in the doc set. Each cites file:line for both sides.
|
||||||
|
|
||||||
|
1. **`api.py` LOC count.** `REQUIREMENTS.md` §3.2 / R-23 says `api.py` is **3,145 LOC**. `ARCHITECTURE.md` §3.1 says `cyclone.api:app` is **3,548 LOC** with `cyclone.store` at **2,423 LOC**. `REQUIREMENTS.md` R-24 says `store.py` is **2,172 LOC**. Three different numbers for two files.
|
||||||
|
|
||||||
|
2. **`store.py` LOC count.** Same as above: 2,172 (REQUIREMENTS R-24) vs 2,423 (ARCHITECTURE §3.1).
|
||||||
|
|
||||||
|
3. **Live-tail heartbeat interval.** `REQUIREMENTS.md` §4 NFR-10 (line 163): "Heartbeat every 15s default (`CYCLONE_TAIL_HEARTBEAT_S`)". `ARCHITECTURE.md` §5.4: "5s heartbeat" (in the description of the live-tail stream). Code (`backend/src/cyclone/api.py:1679-1680`): "every `CYCLONE_TAIL_HEARTBEAT_S` seconds when idle". The `live-tail` plan (§7 / Phase 3 Task 11) says "the 15s heartbeat via `asyncio.wait_for` loop (overridable for tests)". **The 5s claim is wrong; the code and NFR-10 agree on 15s.**
|
||||||
|
|
||||||
|
4. **Live-tail endpoint URL.** `REQUIREMENTS.md` §3.1 FR-20 (line 128) says `/api/{claims,remittances,activity}/stream`. `ARCHITECTURE.md` §5.4 says `GET /api/tail?since=…`. The `live-tail` spec (`2026-06-20-cyclone-live-tail-design.md`) and plan (`2026-06-20-cyclone-live-tail.md`) use the per-resource URL pattern. The code (`api.py:1679-1680`) matches the per-resource pattern. **The ARCHITECTURE §5.4 single-endpoint claim is wrong.**
|
||||||
|
|
||||||
|
5. **Claim lifecycle state count.** `REQUIREMENTS.md` §3.1 + §6.3 traceability: "7-state claim lifecycle". `ARCHITECTURE.md` §6.3: "7 states: submitted, accepted, paid, denied, appealed, rejected, payer_rejected". Code (`backend/src/cyclone/db.py:180-188`): **8 states** (SUBMITTED, RECEIVED, REJECTED, PAID, PARTIAL, DENIED, RECONCILED, REVERSED). The docs disagree with themselves and with the code.
|
||||||
|
|
||||||
|
6. **Claim lifecycle state names.** `ARCHITECTURE.md` §6.3 lists `accepted` and `appealed` — neither exists in the code or any spec. The code uses `RECEIVED` (not `accepted`) and has no `appealed` state. The lifecycle spec says reversal preserves the prior state via `state_before_reversal`; there is no appeal flow.
|
||||||
|
|
||||||
|
7. **`payer_rejected` as a state.** `ARCHITECTURE.md` §6.3 lists it as a state. The SP10 spec §2.3 and SP14 spec §2.3 explicitly state `payer_rejected_at` is a timestamp column on `claims`, NOT a `ClaimState` value. The lane filter (SP14 spec §3.2) is `payer_rejected_at IS NOT NULL AND payer_rejected_acknowledged_at IS NULL`. **A lane is not a state.**
|
||||||
|
|
||||||
|
8. **Vitest version flagging.** `REQUIREMENTS.md` §5.3 R-25 (line 652) flags `vitest@^4.1.9` as "unusually new" and a fallback to `^2.x` is suggested. `ARCHITECTURE.md` §3.2 lists `vitest 4.1.9` in the tooling table without any flagging. Engineers reading only the architecture doc will not realize the version is under review.
|
||||||
|
|
||||||
|
9. **Plan ID numbering.** The user's plan inventory says "15 original plans + 12 backfilled plans = 27 plans". The on-disk count matches (15 + 12). But the original plans use a SP-numbering scheme ("Sub-project 4", "Sub-project 6", "Sub-project 7") while the backfilled plans use a `sp9`–`sp20` naming scheme. There is no `sp1`–`sp8` backfill because those SPs were the originals. **Engineers searching for "SP7 plan" find `2026-06-20-cyclone-line-reconciliation.md` (Sub-project 7) — works — but searching for "SP8 plan" finds `2026-06-20-cyclone-serialize-837.md` whose own title says "Sub-project 7" (the file was re-purposed from SP7 to SP8).** Both top-level docs note this self-acknowledged mis-labeling (`REQUIREMENTS.md` §1.4 + R-22 + `ARCHITECTURE.md` §0.1 mention it) but do not fix it on disk.
|
||||||
|
|
||||||
|
10. **SP number ↔ filename mapping.** SP9 = `multi-payer-npi-sftp` (spec is "SP9 — Multi-payer NPI + SFTP"). But the user's plan inventory says "12 backfilled plans (dated 2026-06-23-cyclone-sp{9..20}-*.md)" — SP9 is the *first* backfilled. The original spec for SP9 lives at `2026-06-20-cyclone-multi-payer-npi-sftp-design.md` (not in the backfill range). So SP9 has an original spec + a backfilled plan + a backfilled spec for SP13-SFTP. The mapping is "SP9 = NPI + SFTP" but the "SFTP" name also gets re-used for SP13's `SftpClient` paramiko wire-up. **Two SPs share the SFTP concept; SP9 = config/secrets, SP13 = transport. The docs do not always disambiguate.**
|
||||||
|
|
||||||
|
11. **SP17 spec/plan asymmetry.** SP17 (encrypted backup) has a spec file dated `2026-06-21` (the original) but the plan is dated `2026-06-23-cyclone-sp17-encrypted-backup.md` (backfilled). The other 6 backfilled SPs (SP10–SP16) have both spec and plan backfilled; SP17 has only the plan backfilled. The user said "12 backfilled plans for SP9-SP20" — so SP17 falls in that range — but the spec is original. This is an asymmetry in the doc-prep pass.
|
||||||
|
|
||||||
|
12. **`audit_log` event-type vocabulary.** The SP14 spec §3.3 enumerates `claim.payer_rejected_acknowledged` as the audit event. The SP15 spec §3.4 enumerates `db.key_rotated`. The SP16 spec §3.5 enumerates `claim.rejected` (from 999 `_handle_999`) and `claim.payer_rejected` (from 277CA `_handle_277ca`). The completeness review (2026-06-20 §3.1 item 2) calls out `ActivityEvent` as "not tamper-evident" — but the SP11 audit log adds tamper-evidence only for events that *opt in* to `append_event`. The completeness review and the round-3 docs do not reconcile which `kind` strings go to `activity_events` (mutable) vs `audit_log` (hash-chained).
|
||||||
|
|
||||||
|
13. **Live-tail reconnect backoff cap.** `REQUIREMENTS.md` §4 NFR-10 and FR-21: "backoff ladder `1s → 2s → 4s → 8s → 16s → 30s` capped". The `live-tail` spec §3.4 says the same ladder. The plan (`live-tail.md` Task 19) repeats it. **No contradiction here, but** the spec/plan/test all say "stalled at 30s silence" while the plan also says "the hook should NOT auto-reconnect on stall (user confirms)" — the FR-21 backoff ladder applies only to `reconnecting`, not `stalled`. A naive reader of FR-21 could think the stalled state auto-reconnects.
|
||||||
|
|
||||||
|
14. **`store.add(rec, event_bus=…)` parameter.** The `production-readiness.md` plan Task 4 wires `store.add()` with `event_bus=request.app.state.event_bus`. The `claims-unique-constraint-and-409-ux.md` plan Task 1.3 calls `store.add(rec, event_bus=request.app.state.event_bus)` from the 837/835 endpoints. **But** the original `store.py:226` `InMemoryStore.add()` and the `CycloneStore.add()` impl do not have an `event_bus=` parameter — the call sites pass `event_bus=` to a function that does not accept it. This is *historical drift* (the InMemoryStore predates the EventBus in SP5) but the docs do not call out the refactor.
|
||||||
|
|
||||||
|
15. **Migrations total count.** `REQUIREMENTS.md` §5.2 lists migrations `0001` through `0012` (12 total) but the §6.3 acceptance rows reference `0013_drop_claims_unique_constraint` (which is on the `claims-unique-fix` worktree, NOT on `main`). Engineers running `ls backend/src/cyclone/migrations/` on `main` see 12 files; engineers on the worktree see 13. The doc text is ambiguous about which branch it describes.
|
||||||
|
|
||||||
|
16. **Test count target.** The historical `2026-06-20-completeness-review.md` §1 says **574 backend tests**. The `production-readiness.md` plan §"Test count targets" (Phase 2 final target) says **437 backend**. The `live-tail.md` plan §"Test count targets" says **437 backend + 144 frontend**. The user's question-statement says **964 tests**. **Four different numbers for "how many tests does Cyclone have".** The discrepancy is partly due to date (574 was measured in 2026-06-20; 437 was a target for SP5; 964 is current).
|
||||||
|
|
||||||
|
17. **API router location for the rotation endpoint.** `2026-06-23-cyclone-sp15-key-rotation-design.md` §3.3 says the endpoint lives at `cyclone/api.py:2797-2928`. The actual endpoint lives at `backend/src/cyclone/api_routers/admin.py` (the `api_routers/` split happened after SP15 was written). The line range is stale.
|
||||||
|
|
||||||
|
18. **Encrypted-at-rest posture.** `REQUIREMENTS.md` §4 NFR-5: "SQLite is encrypted via SQLCipher AES-256 when the macOS Keychain entry exists and `sqlcipher3` is installed; plain SQLite otherwise." `ARCHITECTURE.md` §10 Operational Story: "the SQLite file at `~/.local/share/cyclone/cyclone.db` is plaintext by default; SQLCipher optional." The two phrasings agree on substance but the operational story makes the "plaintext by default" stance more prominent than the requirements doc.
|
||||||
|
|
||||||
|
19. **API endpoint for the MFT scheduler status.** The SP16 spec §4.1 says `GET /api/admin/scheduler/status` returns a `SchedulerStatus` JSON. The SP16 spec §3.3 says the `GET /api/health` endpoint also reports `snap.scheduler.running`. **Two endpoints expose scheduler state** with different shapes. The DoD does not say which is canonical.
|
||||||
|
|
||||||
|
20. **Audit-event `actor` semantics.** SP14 spec §3.3 says `actor` defaults to `"operator"` and is operator-supplied via request body. SP15 spec §3.4 says the same. The SP23 candidate (`ubuntu-docker-deployment-design.md`) introduces RBAC and `actor` becomes a real identity from a JWT claim. The two phrasings agree on default behavior but differ on whether `actor` is free-form text (today) vs identity-validated (SP23 future). Engineers building an audit-evidence tool today must treat `actor` as untrusted free-form text.
|
||||||
|
|
||||||
|
21. **`/api/clearhouse/submit` parameter shape.** SP13 spec §4 says no new endpoints, but references the SP9 endpoint `POST /api/clearhouse/submit`. The SP9 spec is the original spec (`2026-06-20-cyclone-multi-payer-npi-sftp-design.md`). The two docs disagree on the request shape: SP9 spec §4 has `body: { sftp_block_name, payload }`; SP13 spec §4 says SP9 endpoint body is "unchanged" but adds an `auth` dict to `SftpBlock` model. The exact request schema is documented in SP9 only; SP13 trusts it. Two readers building SP13 from spec-only could ship two different request bodies.
|
||||||
|
|
||||||
|
22. **`processed_inbound_files` table ↔ scheduler hand-off.** The SP16 spec §3.5 says `_handle_999` and `_handle_277ca` write SP11 audit events. The plan for SP14 says `claim.payer_rejected_acknowledged` is an audit event. **Both audit events end up in the same `audit_log` table** but no doc names a "row kind namespace" — an operator querying `SELECT event_type, COUNT(*) FROM audit_log GROUP BY event_type` would need to enumerate every spec's event types to build a vocabulary.
|
||||||
|
|
||||||
|
23. **Reconciliation failure semantics.** `REQUIREMENTS.md` §4 NFR-2 says reconciliation must be "fail-soft — a 999 parser crash does not lose the 835". The db-reconciliation spec §6.6 says the same. The completeness review §4 item 4 says "ActivityEvent is mutable". **Both can be true**, but the reconciliation "fail-soft" event goes to `activity_events` (mutable) and the SP16 "scheduler per-file try/except" event goes to `processed_inbound_files.error_message` (also not in `audit_log`). An auditor searching the hash-chained log for a reconciliation failure will find nothing for SP5-era crashes.
|
||||||
|
|
||||||
|
24. **Store ownership of `parse_inbound_filename`.** The SP16 spec §3.5 says `_handle_999` etc. use `cyclone.edi.filenames.parse_inbound_filename` (a separate module under `cyclone/edi/filenames.py`). The completeness review (pre-SP16) doesn't mention this module. The SP16 spec was backfilled; the upstream SP9 spec for `SftpClient` does not mention `edi/filenames.py` either. The module exists in the codebase (`backend/src/cyclone/edi/filenames.py`) and is covered by `test_filenames.py` — but no spec describes *why* it lives in `edi/` instead of `parsers/`.
|
||||||
|
|
||||||
|
25. **`claim.rejected` audit event idempotency.** SP16 spec §3.5 says `_handle_999` writes one `claim.rejected` audit event per matched claim. The SP6 workflow plan (`workflow-automation.md` Task 3) says `apply_999_rejections` is idempotent on already-rejected claims. **The interaction is: a 999 file applied twice (e.g. a retry) writes 0 new audit events on the second call (because `claim.state == REJECTED` short-circuits in `apply_999_rejections`).** The SP16 spec does not say "scheduler retries are idempotent on the audit chain" — only on the parser. A future scheduler retry-aware design might emit a `claim.rejected_reapplied` audit event, but no doc says so.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Forks (highest-priority first)
|
||||||
|
|
||||||
|
These are the items where two competent engineers reading only the docs would plausibly build different systems.
|
||||||
|
|
||||||
|
**F1 (P0). Tail endpoint URL.** FR-20 says `/api/{resource}/stream`; ARCHITECTURE §5.4 says `/api/tail?since=…`. Two URLs, two shapes, two different filter mechanisms. The codebase matches FR-20. **An engineer reading only ARCHITECTURE.md would build a single-endpoint `/api/tail?since=…` that does not exist.**
|
||||||
|
|
||||||
|
**F2 (P0). Claim lifecycle state names.** REQUIREMENTS says 7 states (no `REJECTED`, has `received`); ARCHITECTURE says 7 states (with `accepted`, `appealed`, `rejected`, `payer_rejected`); code has 8 states (`SUBMITTED, RECEIVED, REJECTED, PAID, PARTIAL, DENIED, RECONCILED, REVERSED`); `ARCHITECTURE` adds `payer_rejected` as a state but the code does not. **An engineer building from REQUIREMENTS would ship a 7-state enum without `REJECTED` and break the SP6 workflow-automation `apply_999_rejections` path; an engineer building from ARCHITECTURE would ship a 7-state enum with `appealed` and `payer_rejected` and break the codebase.**
|
||||||
|
|
||||||
|
**F3 (P0). Heartbeat interval.** 5s vs 15s. The code is 15s. An engineer deploying based on ARCHITECTURE §5.4 would set `CYCLONE_TAIL_HEARTBEAT_S=5` (or not set it and expect 5s) and double the bandwidth on the live-tail stream. An operator monitoring for "stalled" would flip state 5× as often.
|
||||||
|
|
||||||
|
**F4 (P0). 409 collision response shape.** The SP22 `parse-decide-workflow-design.md` describes the 409 body for a CLM01 collision as `{parse_result, collisions: [{colliding_claim_ids, existing_batch_id}]}` — a richer shape that lets the UI surface "you have N existing batches that already hold this CLM01". The `claims-unique-constraint-and-409-ux.md` plan + spec describe the 409 body as `{error, detail, batch_id, existing_batch_id}` — a simpler shape that only carries the first matching batch. **The two specs describe different 409 contracts.** A frontend engineer building from SP22 will render a richer panel; one building from the claims-unique-constraint plan will render a simpler panel.
|
||||||
|
|
||||||
|
**F5 (P1). `?ack=true` response shape on parse-837.** REQUIREMENTS §3.1 FR-9 says `?ack=true` "auto-generates a 999 acknowledgement". The `edi-features.md` plan Task 15 says "persist `Ack` row, attach to response body" — but the response shape is described as "the 999 is attached inline". The live-tail spec does not re-acknowledge. **Three different response shapes are plausible: (a) `{claims, summary, ack: {ack_id, body}}` with the 999 inline, (b) `{claims, summary, ack_id}` requiring a follow-up GET, (c) `{claims, summary, ack_999_x12: "ISA*..."}` with raw text. The plan's test (`test_api_parse_persists.py` ack=true happy path) checks the response, but no doc publishes the canonical shape.**
|
||||||
|
|
||||||
|
**F6 (P1). `payer_rejected` lane ↔ `rejected` lane ordering.** SP14 spec §1 says the 5 lanes are ordered "envelope problems → payer problems → recon opportunities → claims in flight → done today". SP14 spec §3.7 code shows the order `rejected, payer_rejected, candidates, unmatched, done_today`. The SPEC.md says the same. The summary tile grid is `grid-cols-2 sm:grid-cols-3 lg:grid-cols-5`. **No conflict in the docs, but** if a future engineer "tidies" the lane order to alphabetical, the "Need eyes" counter (which sums `rejected + payer_rejected + candidates + unmatched` per SP14 spec §3.8) would still work; the visual hierarchy would change.
|
||||||
|
|
||||||
|
**F7 (P1). `ActivityEvent` vs `audit_log` event namespace.** The codebase has two parallel event tables. The SP11 spec says `audit_log` is for HIPAA-relevant events (`claim.rejected`, `claim.payer_rejected_acknowledged`, `db.key_rotated`). The pre-SP11 design (still in `store.py`) writes `activity_events` for state transitions (`claim_submitted`, `manual_match`, `manual_unmatch`, `reconcile`, etc.). **No doc publishes a complete list of which event types go to which table.** A future engineer adding a new event (e.g. `claim.appealed`) would have to choose which table to use with no explicit guidance. *Note: SP23 candidate mentions "appeals workflow" without specifying the table.*
|
||||||
|
|
||||||
|
**F8 (P1). `store.add(rec, event_bus=…)` signature.** The live-tail plan (SP5) and the unique-constraint plan (SP22) both call `store.add(rec, event_bus=request.app.state.event_bus)`. The pre-SP5 `InMemoryStore.add()` does not accept `event_bus=`; the post-SP5 `CycloneStore.add()` signature is unclear from the docs. **A new engineer refactoring `store.py` (which SP21 plans to do) could break both call sites.** The plan-level dependency on SP5's `event_bus` is implicit in 7+ places; the SP21 spec does not list this as a constraint.
|
||||||
|
|
||||||
|
**F9 (P1). `api.py` → `api_routers/` split.** The codebase has `api_routers/{acks,admin,health,ta1_acks}.py`. The specs all reference `cyclone.api:app`. **An engineer building from the docs would add new endpoints to `api.py`; an engineer reading the codebase would add to `api_routers/`.** The split is undocumented in the top-level docs.
|
||||||
|
|
||||||
|
**F10 (P1). Live-tail stream filter.** FR-20 says the stream is per-resource. The `live-tail.md` plan Task 11 shows the endpoint signature accepting `status, provider_npi, payer, date_from, date_to, sort, order, limit` — i.e. the stream is a *filterable* snapshot+subscription, not a firehose. **Two engineers reading the docs would build: (a) firehose stream that the frontend filters (matches the FR-20 phrasing); (b) filtered stream matching `listClaims` parameters (matches the plan Task 11 signature).** The actual code matches (b).
|
||||||
|
|
||||||
|
**F11 (P2). `processed_inbound_files` row identity.** SP16 spec §2.2 says the unique index is `(sftp_block_name, name)`. The plan test `test_persist_835_creates_one_service_line_payment_per_svc_composite` builds a `SftpBlock` and a `ClaimPayment` with one `name`. **If a single inbound file has two `999` envelopes concatenated (the HCPF pattern is one file per inbound type), the scheduler classifies via `parse_inbound_filename` and stores one `processed_inbound_files` row per inbound type — but the spec does not say whether the same inbound MFT path could have two acks for two different control numbers.** Edge case; probably out of scope; but undocumented.
|
||||||
|
|
||||||
|
**F12 (P2). `audit_log` clock source.** SP11 spec §3 says `created_at` is "the wall-clock at the time of `append_event`". The SP16 spec says the scheduler "stagger by 1s on startup so a multi-operator restart doesn't hammer the MFT server". If a 999 arrives at wall-clock `T`, the `claim.rejected` audit event has `created_at = T`. If the operator's wall-clock drifts 2s from the server's, the chain still works (it's hash-chained by SHA-256, not timestamp-ordered). **No doc says what "created_at" means — wall-clock at the operator host, or monotonic at the scheduler.** This matters for HIPAA §164.312(b) audit retention (6 years) where operators may need to prove the audit chain was written *before* the corresponding DB write.
|
||||||
|
|
||||||
|
**F13 (P2). `claim.id` source.** SP10 spec §2 says `claim.id` is the X12 CLM01. SP14 spec §2 says the lane filter is on `payer_rejected_at` (not `claim.id`). The uniqueness-constraint spec (SP22) says `claim.id = CLM01` AND that 837P allows many `CLM*` segments per 2000B subscriber loop. **If a 837P file has two CLM01 collisions (e.g. two CLM01="CLM-A" segments in different 2000B loops), the `PK on claims.id` raises IntegrityError on the second insert.** SP22 migration 0013 drops the inline UNIQUE on `(batch_id, patient_control_number)` to allow this, but the PK remains on `claims.id`. **Two CLM01="CLM-A" segments still trip the PK.** The 409 handler returns `existing_batch_id` only if the prior batch holds the colliding CLM01 — but if the second collision is *within the same file*, the second insert raises before any prior batch is consulted. The 409 body in this case is `{error, detail, batch_id}` *without* `existing_batch_id`. SP22 spec does not document this edge case.
|
||||||
|
|
||||||
|
**F14 (P2). SFTP host-key policy.** SP13 spec §3.2 uses `AutoAddPolicy()` (trust-on-first-use). The plan does not change this. The spec §7 "Open questions" lists `SftpBlock.strict_host_key_check: bool` as a future hardening. **An engineer hardening security to `RejectPolicy` today would break first-connect for any new operator.** No doc says "do not change this without a migration path".
|
||||||
|
|
||||||
|
**F15 (P2). `processed_inbound_files` status `pending`.** SP16 spec §2.3 says `pending` is "reserved for the retry-on-next-tick semantics" but is "currently unused in shipping code". The plan does not add a test for the `pending` status. **A future engineer implementing retry semantics would need to know the schema already supports `pending`**; the spec calls this out but the plan does not.
|
||||||
|
|
||||||
|
**F16 (P2). Scheduler first-tick stagger.** SP16 spec §3.1.2 says `_run` staggers the first tick by 1s on startup. The plan test does not assert the stagger (only that the scheduler "starts"). **A future engineer removing the `asyncio.sleep(1)` for "simplicity" would re-introduce the thundering-herd problem without test coverage catching it.**
|
||||||
|
|
||||||
|
**F17 (P3). `_handle_277ca` vs `_handle_999` line numbers.** SP16 spec §3.1.1 says both handlers emit `claim.rejected` / `claim.payer_rejected` audit events. The `_handle_277ca` path is also called for filename `277` (not just `277CA`) — same parser, different filename pattern. **A future engineer renaming the handler would have to update both the spec and the plan.**
|
||||||
|
|
||||||
|
**F18 (P3). `serialize_837_for_resubmit` parameter shape.** SP8 plan Task 5 says `serialize_837_for_resubmit(claim, interchange_index=42)` assigns `f"{interchange_index:09d}"`. The spec says the same. But the spec §3.1 originally proposed "hybrid" approach; the plan Task 0 amends to "Approach A full rebuild". **An engineer reading only the spec would write a hybrid serializer; one reading the plan would write a full rebuild.**
|
||||||
|
|
||||||
|
**F19 (P3). SP22 parse-decide spec vs plan.** The SP22 parse-decide spec §3.2 says the 409 endpoint returns `parse_result` and `collisions` arrays. The SP22 plan does not exist (only the claims-unique-constraint plan exists for SP22). **Engineers building from the spec alone have no test plan.** The spec mentions a "TBD: 409 panel UX" in §6.
|
||||||
|
|
||||||
|
**F20 (P3). Inbox lane ordering by `state_changed_at`.** SP6 workflow-automation plan Task 6 `compute_lanes` uses `Claim.state_changed_at >= cutoff` for the `done_today` lane (cutoff = 24h ago). The migration `0004_rejections_and_state_history.sql` adds `state_changed_at` and the index `ix_claims_state_changed_at`. **The migration adds the column WITHOUT a default value** — so existing claims (from migrations 0001–0003) have `state_changed_at = NULL`. **They never appear in `done_today`.** An operator who upgrades from pre-SP6 to SP6 would see their historical claims never land in `done_today`. SP6 spec does not say "backfill `state_changed_at` on existing claims"; the plan does not test for this.
|
||||||
|
|
||||||
|
**F21 (P3). The SP10 277CA path does not extend the state machine.** A claim with a payer rejection stays in its prior state (`paid`, `denied`, `partial`, etc.) and gains the `payer_rejected_at` timestamp. This is consistent with SP10 spec §2.3 ("we explicitly do not clear `payer_rejected_at` on acknowledge") and SP14 spec §2.3, but the architecture doc's casual phrasing ("states: ... payer_rejected") suggests otherwise. **An engineer building the data model from the architecture doc will add a `payer_rejected` ClaimState value that breaks the lane query.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Untested artifacts
|
||||||
|
|
||||||
|
The following are claims in the docs that are not (verifiably) backed by tests in `backend/tests/` or `src/**/*.test.ts*`. I name the test file that *should* cover each.
|
||||||
|
|
||||||
|
**U1.** `REQUIREMENTS.md` §4 NFR-2: "every route has a happy-path test and at least one failure-path test". **This NFR is a meta-claim about test discipline.** No meta-test asserts NFR-2 (e.g., a `pytest --collect-only | jq '.[] | select(.failure_path == false)'` would fail). The convention is followed in some places (e.g. `test_api_rotate_key.py` has happy + 409 + 503 paths; `test_payer_rejected_acknowledge.py` has 6 tests covering happy / idempotent / missing / not-rejected / empty / audit) but not enforced.
|
||||||
|
|
||||||
|
**U2.** `REQUIREMENTS.md` §4 NFR-10: "backoff ladder 1s → 2s → 4s → 8s → 16s → 30s capped". The `live-tail.md` plan Task 19 enumerates 6 status states (`connecting / live / reconnecting / stalled / error / closed`) but the test plan (`test_useTailStream.test.ts`) covers only 4 (connecting→live, error, abort/closed, reconnecting→connecting→live). **The backoff ladder cap (30s) is not asserted.** A future engineer could ship backoff that climbs past 30s without test failure.
|
||||||
|
|
||||||
|
**U3.** `REQUIREMENTS.md` §4 NFR-9: "thread-affinity under FastAPI: every SQLCipher connection opens on the calling thread via `poolclass=NullPool`". `db_crypto.py` is the only module that wires `NullPool`. **No test in `test_db_crypto.py` asserts `poolclass == NullPool` is set on the SQLCipher branch.** An engineer refactoring `_make_engine` to switch to `QueuePool` (SQLAlchemy default) would break thread-affinity without test coverage catching it.
|
||||||
|
|
||||||
|
**U4.** SP15 spec §3.3 says the rotation endpoint has status codes `200, 400, 409, 503`. `test_api_rotate_key.py` covers happy + 409 + 503. **The 400 ("encryption not enabled") path is not in the test file.** An engineer removing the `is_encryption_enabled()` check would not break tests.
|
||||||
|
|
||||||
|
**U5.** SP14 spec §3.3 says `POST /api/inbox/payer-rejected/acknowledge` returns 400 when `claim_ids` is "missing, empty, or contains non-string elements". `test_payer_rejected_acknowledge.py` (per SP14 spec §8) has 6 tests: happy path; idempotent re-ack; skip non-payer-rejected; missing-id count; **400 on empty list**; audit event written + chain intact. **The 400-on-non-string-elements path is not tested.**
|
||||||
|
|
||||||
|
**U6.** SP16 spec §3.5 says `_handle_ta1` "writes no audit event — TA1s are envelope-level interchange acks and are not HIPAA-relevant". **No test asserts that `_handle_ta1` writes zero audit rows.** A future engineer adding a "ta1.received" audit event would not break tests.
|
||||||
|
|
||||||
|
**U7.** SP16 spec §7.4 says "list_inbound() runs on a thread via asyncio.to_thread so the event loop stays responsive". **No test asserts that `SftpClient.list_inbound()` does not block the event loop** (e.g. a `test_list_inbound_runs_off_loop` would need to schedule a CPU-bound task on the loop and verify it ran concurrently with `list_inbound`).
|
||||||
|
|
||||||
|
**U8.** SP12 spec §7 says "Plain-SQLite fallback remains silent. `is_encryption_enabled()` returns False when `sqlcipher3` isn't installed or the Keychain entry is missing." `test_db_crypto.py` covers the case where encryption IS enabled. **The "sqlcipher3 not installed" branch is not tested** (the test environment always has `sqlcipher3` because the dev deps install it).
|
||||||
|
|
||||||
|
**U9.** SP13 spec §3.2 says `_connect` raises `RuntimeError("SftpBlock.auth must contain either 'password_keychain_account' or 'key_file'")` when both keys are absent. **No test in `test_sftp_paramiko.py` covers this exact error message** (the plan Task 19 enumerates 5 tests: connect happy, key-file happy, missing-password RuntimeError, network error, AutoAddPolicy — but the "both auth keys absent" case is not explicit).
|
||||||
|
|
||||||
|
**U10.** SP11 spec §3.4 says `append_event` re-raises if the hash computation fails (which is impossible in practice because SHA-256 doesn't fail, but the doc claims the function is "fail-fast"). **No test asserts the fail-fast behavior under any failure mode.**
|
||||||
|
|
||||||
|
**U11.** SP14 spec §3.8 says "The row payload already carries `payer_rejected_acknowledged_at` / `_actor` (always null on the current lane)". `test_lane_filter_acknowledged.py` covers "row payload carries the ack fields (forward-compat)". **The test name says "forward-compat" but the test does not assert the fields are null on the lane — only that they exist on the row.**
|
||||||
|
|
||||||
|
**U12.** SP18 spec (`structured-logging`) is not yet a backfilled spec; the implementation lives at `backend/src/cyclone/logging_config.py`. `test_logging_formatter.py`, `test_logging_scrubber.py`, `test_logging_setup.py` exist. **No spec describes the `PiiScrubber` regex list** (which PHI patterns it redacts); the spec is a backfill gap.
|
||||||
|
|
||||||
|
**U13.** `REQUIREMENTS.md` §6.1 traceability FR-35 says "5-lane Inbox" was satisfied by SP14; the §6.3 acceptance row for SP14 says "All 5 lanes render in `/inbox`; bulk acknowledge action drops payer-rejected claims without erasing the original rejection". **`test_lane_filter_acknowledged.py` covers the lane filter and `test_payer_rejected_acknowledge.py` covers the ack endpoint. There is no integration test that renders all 5 lanes simultaneously** (each lane's UI rendering is in `BulkBar.test.tsx` per the SP14 spec §8 table, but the page-level test for `Inbox.tsx` with 5 populated lanes is not enumerated).
|
||||||
|
|
||||||
|
**U14.** SP16 spec §3.5 says `_handle_ta1` calls `cyclone.store.store.add_ta1_ack`. **The `test_api_ta1.py` exists and the `test_scheduler.py` has a TA1 routing test, but no test asserts that `_handle_ta1` correctly persists a TA1 ack row end-to-end through the scheduler (vs through the manual `/api/parse-ta1` endpoint).**
|
||||||
|
|
||||||
|
**U15.** `REQUIREMENTS.md` §6.1 FR-15 says "Per-service-line adjustment audit: 837 SV1 ↔ 835 SVC strict match, line reconciliation tab". `test_line_reconciliation.py` covers the pure-function `match_service_lines` (per SP7 plan Task 7). **`test_reconcile_line_level.py` covers the integration with `reconcile.run()`. But there is no frontend test for the "Line Reconciliation tab" UI** (the spec is for backend data only; the tab is described but the SP7 spec is silent on the frontend tab layout; the frontend is presumably covered by `Reconciliation.tsx.test.tsx` but SP7 doesn't enumerate it).
|
||||||
|
|
||||||
|
**U16.** `REQUIREMENTS.md` §6.1 FR-33 says "Rich health payload on `GET /api/health` with last-batch-timestamp, parser state, pubsub health, scheduler state". `test_security.py` exists for the `get_health_snapshot()` function. **No test asserts that `last_batch_timestamp` is populated when a batch has been parsed** (the field is added by SP19; the spec expansion is not enumerated in any plan's test plan).
|
||||||
|
|
||||||
|
**U17.** `REQUIREMENTS.md` §6.1 FR-26 says "paramiko-backed SFTP submit, credentials via `keyring`". `test_sftp_paramiko.py` covers the connect / write / list / read happy paths. **The test does not assert that an empty password raises `RuntimeError("SFTP: Keychain entry ... missing or stub")`** — it only asserts the runtime error type, not the message. An engineer changing the error message would not break the test.
|
||||||
|
|
||||||
|
**U18.** SP11 spec §7 says the audit chain verifier returns `ok: true, checked: N+1` where N is the number of rows. **`test_audit_log.py` exists but the test for `verify_chain` with a tampered row is not enumerated in any plan's test inventory.** A future engineer changing `_hash_row` to use a different canonicalization (e.g. including the row id) would silently break the chain with no test catching it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Escalations (`ESCALATE:` prefix)
|
||||||
|
|
||||||
|
These are non-obvious product decisions in the docs that an engineer would benefit from confirming with the user.
|
||||||
|
|
||||||
|
**ESCALATE: SP23 — Ubuntu Docker + RBAC.** `docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md` is marked "Draft / candidate". It introduces (a) a Docker image (`Dockerfile` + `docker-compose.yml`), (b) 3-role RBAC (admin / operator / viewer) backed by `cyclone.auth`, (c) per-role scoping on every endpoint, (d) JWT-based session tokens. **This is a fundamental posture change** — Cyclone today binds `127.0.0.1` with no auth. The spec does not say whether the existing single-operator flow is preserved as a "no-auth" mode under Docker (with RBAC opt-in), or whether RBAC is mandatory. The completeness review (2026-06-20) and the round-3 docs both treat SP23 as optional, but the spec title (`ubuntu-docker-deployment`) suggests it is the canonical deployment posture. **Confirm: is SP23 in scope for the next release, or is it parked? If parked, what is the deployment story?**
|
||||||
|
|
||||||
|
**ESCALATE: SFTP `AutoAddPolicy` vs `RejectPolicy`.** SP13 spec §3.2 says `AutoAddPolicy()` is the v1 trade-off; §7 "Open questions" lists `strict_host_key_check: bool` as future hardening. **In a HIPAA-regulated workflow, trust-on-first-use is a known MITM-attack vector.** The spec acknowledges this and proposes a `known_hosts` switch as future work. **Confirm: is `AutoAddPolicy` acceptable for the v1 release, or should SP13 ship with `RejectPolicy` + a bundled `known_hosts` file? The Gainwell MFT host key would need to be obtained and pinned.**
|
||||||
|
|
||||||
|
**ESCALATE: `audit_log` retention is "≥ 6 years" (HIPAA §164.316(b)(2)) but the docs do not say how.** SP11 spec §7 says "pinned audit-log retention for rotation events: `db.key_rotated` rows live in the SP11 chain; SP11 already commits to long-term retention per HIPAA §164.316(b)(2). No additional retention config is needed." But the migration `0009_audit_log.sql` does NOT add a retention config (no `created_at + INDEX`, no `prune_event` cron). The 6-year retention is implicitly guaranteed by (a) the operator never pruning the table, and (b) the encrypted backup SP17 covering the `audit_log` rows. **Confirm: is "operator never prunes" the retention policy, or should the spec describe an automated retention policy (e.g. a `prune_audit_log` cron at 6 years + 1 day)?**
|
||||||
|
|
||||||
|
**ESCALATE: The 5-lane Inbox vs 4-lane state machine.** The SP10 spec says `payer_rejected_at` is a column on `claims` (not a state). The SP14 spec says the 5th lane is computed by filtering. **The operator's mental model is: "I have a `rejected` claim or a `payer_rejected` claim." But the code says: a claim is in one state (`paid`, `denied`, `partial`, etc.) AND has a `payer_rejected_at` timestamp (or doesn't).** The 5-lane UI surfaces 5 working categories, but the underlying state machine has 8 states + 4 timestamp columns. **Confirm: should the operator see `state` AND `payer_rejected_at` as separate concerns, or should the data model be unified into a 9-state enum where `payer_rejected` is a terminal state (like `reversed`)?**
|
||||||
|
|
||||||
|
**ESCALATE: SP21 store split is in-flight on `refactor/store-split`.** The spec is dated 2026-06-21; the current `backend/src/cyclone/store.py` is the monolithic version. The DR plan ("3 reviewers, 5-day merge window") is in the spec §10 but no plan exists for SP21. **Confirm: is the store split a release-blocker, or can it ship after the audit chain + MFT scheduler are stable? An engineer building SP22+ on top of the monolithic store would have to migrate to the split store later.**
|
||||||
|
|
||||||
|
**ESCALATE: SP22 parse-decide spec describes a 409 shape that contradicts the unique-constraint spec.** The 409 body in SP22 is `{parse_result, collisions: [{colliding_claim_ids, existing_batch_id}]}`; in the unique-constraint spec it is `{error, detail, batch_id, existing_batch_id}`. **Confirm: which is canonical? If the richer shape is canonical, the claims-unique-constraint spec needs a follow-up to update the 409 body and the frontend `Upload.tsx` error panel.**
|
||||||
|
|
||||||
|
**ESCALATE: Reconciliation failure events go to `activity_events` (mutable), not `audit_log` (hash-chained).** The completeness review (2026-06-20 §3.1 item 2) flagged `activity_events` as not tamper-evident. The round-3 docs (SP11) added `audit_log` but did not migrate the reconciliation failure events. **Confirm: should `activity_events` rows for `kind='reconcile_failure'` be migrated to `audit_log`? Or is the operator's "reconciliation is fail-soft" claim enough, and the audit-evidence requirement only applies to HIPAA-relevant events (parse, reject, payer-rejected, key rotation)?**
|
||||||
|
|
||||||
|
**ESCALATE: `?ack=true` on parse-837 returns the 999 inline vs in a separate field.** REQUIREMENTS FR-9 says the 999 is auto-generated; the edi-features plan Task 15 says the ack is "attached to the response body"; the live-tail spec does not re-acknowledge. **Confirm: should the 999 be returned inline as `ack.x12_text` (raw X12 string), as `ack.body` (parsed object), or as a `ack_id` requiring a follow-up `GET /api/acks/{id}`? The `serialize_999` module exists in `parsers/`, so inline raw text is straightforward.**
|
||||||
|
|
||||||
|
**ESCALATE: 5-lane Inbox claim ownership.** The lane filter for `payer_rejected` is on the `claims` table. The 999-rejected lane (`rejected`) is also on the `claims` table. The `unmatched` lane is on `claims` with no matched remit. **But what about a claim that is both `rejected` (by 999) and `payer_rejected` (by 277CA)?** The SP14 spec §3.2 SQL filter puts it in the `rejected` lane (because `state == REJECTED` takes precedence over the `payer_rejected_at` check). The spec does not say this explicitly. **Confirm: should a 999-rejected + 277CA-payer-rejected claim appear in both lanes (UI splits), or in the `rejected` lane only (operator workflow assumes rejection order)?**
|
||||||
|
|
||||||
|
**ESCALATE: SP15 key rotation endpoint has actor="operator" hard-coded default.** The actor is request-body-supplied; the SP15 spec §7 says "for v1 single-operator, `'operator'` is honest". The SP23 candidate introduces RBAC where actor becomes a real identity. **Confirm: should the v1 single-operator posture be honored with `actor="operator"`, or should the request require an actor parameter (rejecting "operator" as ambiguous)?**
|
||||||
|
|
||||||
|
**ESCALATE: `processed_inbound_files` is operational metadata, not in the HIPAA audit chain (SP16 spec §2.4).** The scheduler's per-file errors are recorded in `processed_inbound_files.error_message` (mutable, not in `audit_log`). **Confirm: should scheduler errors (e.g. `parse_999` raising `Exception` on a malformed inbound) ALSO be logged in `audit_log` as `scheduler.parse_error`? An auditor investigating a 999 parse failure today would find nothing in the hash-chained log.**
|
||||||
|
|
||||||
|
**ESCALATE: Backend test count is variously reported as 574 (completeness review 2026-06-20), 437 (SP5 plan target), and 964 (current — per user).** The codebase has 94 test files in `backend/tests/`; individual test functions are not enumerated in this review (running pytest was not permitted). **The traceability matrix in REQUIREMENTS.md §6.1 cites one test file per FR — but a single FR may have 5+ tests in that file.** Confirm: is the FR-coverage criterion "≥1 test file" or "≥1 test function"?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Confidence
|
||||||
|
|
||||||
|
**Overall confidence in the doc set: medium-low.**
|
||||||
|
|
||||||
|
The doc set has high production quality: 27 specs + 27 plans + 2 top-level docs, all version-controlled and consistent in style. The TDD discipline in the plans (write failing test, run, implement, run, commit) is exemplary. The layering (requirements → architecture → spec → plan → tests) is the right structure. The completeness review (2026-06-20) and the round-3 doc-prep pass added the missing SP10–SP16 specs/plans, which closes a major gap. **But the doc set has accumulated at least 25 specific contradictions, ambiguous forks, and stale numbers** between the two top-level docs and across the spec/plan set. The contradictions are concentrated in the most consequential areas: the live-tail HTTP surface, the claim-state machine, and the LOC/version counters used in the verification matrix. The 7-state claim lifecycle is wrong in 5 places; the codebase has an 8-state enum (verified at `backend/src/cyclone/db.py:180-188`). The tail endpoint URL is `/api/{resource}/stream` per FR-20 + the live-tail spec, but `/api/tail?since=…` per ARCHITECTURE.md §5.4 — two competent engineers reading only the docs could ship two incompatible APIs. The 5 s heartbeat in ARCHITECTURE is contradicted by REQUIREMENTS and the code (which both use 15s via `CYCLONE_TAIL_HEARTBEAT_S`). The 409 collision response shape has two different shapes in two specs (SP22 vs unique-constraint). The `api_routers/` partial split is invisible to the top-level docs. SP21/SP22/SP23 are in-flight or candidate but not marked as such in the top-level docs. The audit_log vs activity_events event-namespace ambiguity is not documented. **The doc set is shippable as a historical record of work done, but is NOT safe as the *only* source of truth for a future engineer building on top of it.** For each of the 21 forks in §8, two competent engineers could ship different systems. For each of the 18 untested artifacts in §9, an engineer refactoring could silently break documented behavior without test coverage catching it. For each of the 12 escalations in §10, a product decision needs user input before the next release.
|
||||||
|
|
||||||
|
**Recommendation**: do not ship the doc set as-is. Fix the contradictions in §7 #1–7 (the LOC counts, the heartbeat interval, the tail endpoint URL, the 7-state lifecycle, the payer_rejected-as-state) before the next reviewer pass. Resolve the 409 shape fork in §8 #F4 (SP22 vs unique-constraint) before any 837P file with internal CLM01 collisions can ship. Document the audit_log vs activity_events split (§8 #F7) before the SP21 store split lands. Add tests for the 18 untested artifacts in §9. Confirm the 12 escalations in §10 with the user before the next release cycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Reviewer A.*
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# Cyclone doc-prep review — Reviewer B (code-first lens)
|
||||||
|
|
||||||
|
## 1. Reviewer identity + lens
|
||||||
|
|
||||||
|
I am **Reviewer B**. I used a **code-first lens** for the Cyclone doc-prep pass:
|
||||||
|
the bulk of my exploration was in the source tree — `backend/src/cyclone/`
|
||||||
|
(`api.py`, `api_helpers.py`, `api_routers/`, `db.py`, `store.py`, `audit_log.py`,
|
||||||
|
`security.py`, `db_crypto.py`, `backup_service.py`, `reconcile.py`,
|
||||||
|
`inbox_lanes.py`, `scoring.py`, `inbox_state.py`, `inbox_state_277ca.py`,
|
||||||
|
`scheduler.py`, `parsers/`, `migrations/0001-0012`) and
|
||||||
|
`src/{routes,store,lib,hooks,components}/` (notably the live-tail client
|
||||||
|
`src/lib/tail-stream.ts` and the React hook `src/hooks/useTailStream.ts`).
|
||||||
|
I then read the top-of-funnel docs in the intended order:
|
||||||
|
`docs/README.md` → `docs/REQUIREMENTS.md` → `docs/ARCHITECTURE.md`. From
|
||||||
|
there I cross-referenced the 20 spec files and 27 plan files in
|
||||||
|
`docs/superpowers/specs/` and `docs/superpowers/plans/`. **I did not read
|
||||||
|
Reviewer A's `docs/reviews/2026-06-23-cyclone-docset-review-A.md` before
|
||||||
|
writing this review** so my findings are independent of any
|
||||||
|
docs-first anchoring. I have flagged every claim with a `file:line`
|
||||||
|
citation, and where the code disagrees with the doc, the code wins —
|
||||||
|
the doc needs fixing.
|
||||||
|
|
||||||
|
## 2. Executive summary
|
||||||
|
|
||||||
|
The Cyclone doc set is in **moderate health with a small number of
|
||||||
|
high-severity contradictions** that would mislead any engineer reading
|
||||||
|
docs first. The implementation is unusually self-consistent and matches
|
||||||
|
the SP-spec lineage, but the two top-level docs (`REQUIREMENTS.md`,
|
||||||
|
`ARCHITECTURE.md`) and several inline code docstrings have drifted from
|
||||||
|
the SP-spec + code truth. The top three issues are:
|
||||||
|
|
||||||
|
1. **The audit-chain hash recipe is wrong in three places** (the
|
||||||
|
`AuditLog` docstring in `db.py:649-650`, the `audit_log.py:6-7`
|
||||||
|
module docstring, and `ARCHITECTURE.md` §4.6) and only the SP11 spec
|
||||||
|
(`docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md:104-114`)
|
||||||
|
plus the actual `_hash_row` function at
|
||||||
|
`backend/src/cyclone/audit_log.py:80-89` agree. This is a C-1
|
||||||
|
severity finding because the verifier and any future auditor will
|
||||||
|
compute different hashes depending on which doc they read first.
|
||||||
|
2. **`ARCHITECTURE.md` §6.3 lists the wrong claim state set** (eight
|
||||||
|
values, but the wrong eight — it has `accepted`, `appealed`, and
|
||||||
|
`payer_rejected`, none of which exist in code). The actual `ClaimState`
|
||||||
|
enum (`db.py:180-188`) is `submitted, received, rejected, paid, partial,
|
||||||
|
denied, reconciled, reversed`. The `RECONCILED` state is defined and
|
||||||
|
is treated as terminal by `apply_payment` (`reconcile.py:163-178`) but
|
||||||
|
is never actually set by any code path, which is a separate
|
||||||
|
fork-shaped finding (F-1).
|
||||||
|
3. **The API surface in `ARCHITECTURE.md` §8.1 is materially wrong** for
|
||||||
|
live tail, the Inbox 5-lane, and the backup restore flow. Live tail
|
||||||
|
is documented as `GET /api/tail?since=…` (ARCH §5.4 / §8.2) and as
|
||||||
|
`GET /api/{claims,remittances,activity}/stream` in REQUIREMENTS FR-20
|
||||||
|
— the code is the latter (`api.py` 3,548 LOC; verified at multiple
|
||||||
|
routes). Inbox is listed as `/api/inbox` in ARCH §8.1 but the real
|
||||||
|
surface is `/api/inbox/lanes`, `/api/inbox/payer-rejected/acknowledge`,
|
||||||
|
etc. Backup restore is a one-step URL in ARCH §8.1 but the
|
||||||
|
`backup_service.py` is a two-step `restore_initiate` / `restore_confirm`
|
||||||
|
(matches FR-30). The two-step shape is what the code ships.
|
||||||
|
|
||||||
|
Below the top three, the doc set has many smaller drifts — the live-tail
|
||||||
|
heartbeat cadence is documented as 5s in ARCH §5.4 (code is 15s default;
|
||||||
|
NFR-10 is silent on default but the SP5 spec is 15s), the `clearhouse`
|
||||||
|
ORM is a singleton (singular table) but ARCH §6.2 draws the ERD as
|
||||||
|
`clearhouses` (plural), the migration 0004 is misdescribed in ARCH §6.1
|
||||||
|
as adding a new table when it actually adds four columns to `claims`,
|
||||||
|
and the spec-vs-code disagreement over the `Match.claim_id` UNIQUE
|
||||||
|
constraint (SP2 §6.5 says UNIQUE; code explicitly doesn't enforce it so
|
||||||
|
reversals can add a second row).
|
||||||
|
|
||||||
|
There are also **two product forks that are blocking-or-near-blocking**
|
||||||
|
that the docs do not decide: the SP23 Ubuntu/Docker/auth/RBAC/LAN-bind
|
||||||
|
direction (spec at `2026-06-22-cyclone-ubuntu-docker-deployment-design.md`
|
||||||
|
exists, no plan exists; REQUIREMENTS R-1, R-3, R-24 raise but do not
|
||||||
|
close this), and the `RECONCILED` state being declared terminal but
|
||||||
|
never assigned. Neither has a test, plan, or design decision recorded.
|
||||||
|
|
||||||
|
## 3. Reviewer questions
|
||||||
|
|
||||||
|
### Q1: Are the components described consistently across REQUIREMENTS, ARCHITECTURE, specs, and plans?
|
||||||
|
|
||||||
|
**Verdict: NO — multiple component-name and component-shape mismatches.**
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
- The **`Live tail` component** is described three different ways:
|
||||||
|
REQUIREMENTS FR-20 says `/api/{claims,remittances,activity}/stream`
|
||||||
|
(NDJSON); ARCH §5.4 and §8.2 say `GET /api/tail?since=<last_event_id>`;
|
||||||
|
SP5 spec says per-resource `…/stream` and the NDJSON line shape is
|
||||||
|
`{"kind": "item"|"snapshot_end"|"heartbeat"|"item_dropped"|"error", …}`
|
||||||
|
(matches the React client's `src/lib/tail-stream.ts` `TailEvent`
|
||||||
|
type). The code is the SP5 spec; REQUIREMENTS matches the code; ARCH
|
||||||
|
does not.
|
||||||
|
- The **`Inbox` component** is described as `/api/inbox` in ARCH §8.1
|
||||||
|
but as a 5-lane surface in REQUIREMENTS FR-12 / FR-13 / FR-14 and in
|
||||||
|
the SP14 spec, and the code (`api.py`; `inbox_lanes.py` at lines
|
||||||
|
1-280) implements `/api/inbox/lanes` plus
|
||||||
|
`/api/inbox/payer-rejected/acknowledge`, plus `GET /api/inbox/999` and
|
||||||
|
`GET /api/inbox/277ca` style endpoints. ARCH §8.1 is missing the 5-lane
|
||||||
|
detail.
|
||||||
|
- The **`Backup restore` component** is described as a single URL in
|
||||||
|
ARCH §8.1 (`/api/admin/backup/{id}/restore?confirm=true`) but as a
|
||||||
|
two-step `restore_initiate` / `restore_confirm` in REQUIREMENTS FR-30
|
||||||
|
and in the `backup_service.py:851` LOC file. The code is the
|
||||||
|
two-step shape.
|
||||||
|
- The **`Database key rotation` component** is described in ARCH §8.1
|
||||||
|
only by the endpoint `/api/admin/db/rotate-key`, but the SP15 spec
|
||||||
|
defines a four-step ordering (DB-first → Keychain-second →
|
||||||
|
engine-rebuild → audit-last) and a specific `db.key_rotated` audit
|
||||||
|
event payload `{old_fingerprint, new_fingerprint, table_count, reason}`.
|
||||||
|
Neither the ordering nor the payload shape is in ARCH.
|
||||||
|
|
||||||
|
### Q2: Is the data model consistent (schema, enums, lifecycle, audit chain)?
|
||||||
|
|
||||||
|
**Verdict: NO — five material data-model disagreements.**
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
- **Claim state enum is wrong in ARCH §6.3 and partially wrong in
|
||||||
|
REQUIREMENTS FR-6.** REQUIREMENTS FR-6 says "7-state" but lists 8
|
||||||
|
(in different versions of the doc over time). ARCH §6.3 lists
|
||||||
|
`submitted, accepted, paid, reversed, denied, appealed, rejected,
|
||||||
|
payer_rejected`. The code (`db.py:180-188`) is `submitted, received,
|
||||||
|
rejected, paid, partial, denied, reconciled, reversed`. Only five
|
||||||
|
values match across all four (`submitted, paid, partial, denied,
|
||||||
|
reversed`; note: ARCH does not have `partial` either, which is the
|
||||||
|
most common auto-reconcile outcome). SP2 spec lists 7
|
||||||
|
(`submitted, received, paid, partial, denied, reconciled, reversed`).
|
||||||
|
The `rejected` state is set by 999 AK9 set-level R/E (per
|
||||||
|
`inbox_state.py` lines 1-76 and migration 0004), not by any spec
|
||||||
|
other than SP6. The `reconciled` state is dead code — see F-1.
|
||||||
|
- **Audit chain hash recipe disagrees in 3 places** (see C-1 below).
|
||||||
|
- **The `clearhouse` table is a singleton** in code (`db.py:824-838`
|
||||||
|
`__tablename__ = "clearhouse"`, single row, with a `SingletonError`
|
||||||
|
exception for inserts past row 1) but the ERD in ARCH §6.2 shows
|
||||||
|
`clearhouses` plural. Migration 0011 (`0001-0012` index) also writes
|
||||||
|
to `clearhouse` (singular).
|
||||||
|
- **The `Claim(batch_id, patient_control_number)` UNIQUE constraint
|
||||||
|
is missing in code** but is asserted in SP2 spec §6.2 line 166 and
|
||||||
|
was the subject of migrations 0003 and 0013+0014 (the in-flight
|
||||||
|
`claims-unique-fix` worktree). The `claims` table in migration 0001
|
||||||
|
has no such UNIQUE; the `Claim` ORM (`db.py:310-319`) explicitly
|
||||||
|
notes the absence. The SP2 spec is out of sync with the SP-13/14
|
||||||
|
reality.
|
||||||
|
- **The `Match.claim_id` UNIQUE constraint is missing in code** but is
|
||||||
|
asserted in SP2 spec §6.5 line 207. Migration 0001 line 65-66
|
||||||
|
(comment) and `db.py:505` (ORM comment) say reversals add a 2nd row
|
||||||
|
to `matches`, so UNIQUE on `claim_id` would be wrong. The spec is
|
||||||
|
wrong; the code is correct.
|
||||||
|
|
||||||
|
### Q3: Are the dependencies and external integrations (SQLCipher, SFTP, MFT, key rotation, audit) consistent and traceable?
|
||||||
|
|
||||||
|
**Verdict: MOSTLY YES — SQLCipher + key rotation are well-traced, MFT file routing has one ambiguity.**
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
- **SQLCipher + key rotation** is end-to-end traceable:
|
||||||
|
`db_crypto.py:389` LOC defines `KEYCHAIN_ACCOUNT = "cyclone.db.key"`
|
||||||
|
and `KEYCHAIN_ACCOUNT_PREVIOUS = "cyclone.db.key.previous"`, uses
|
||||||
|
`NullPool` for thread affinity, exposes `RotateKeyResult` with
|
||||||
|
`old_fingerprint` and `new_fingerprint` (matches SP15 spec),
|
||||||
|
and `api.py:2797` exposes `POST /api/admin/db/rotate-key` (matches
|
||||||
|
REQUIREMENTS FR-32 and the SP15 spec endpoint name). Migration 0012
|
||||||
|
is the SQLCipher-key-on-disk path. The audit event `db.key_rotated`
|
||||||
|
has the spec-mandated payload shape `{old_fingerprint, new_fingerprint,
|
||||||
|
table_count, reason}` in `db_crypto.py`. **Consistent.**
|
||||||
|
- **MFT file routing** has a small ambiguity: `scheduler.py:316-321`
|
||||||
|
routes `ROUTED_FILE_TYPES = {"999", "835", "277", "277CA", "TA1"}` to
|
||||||
|
the corresponding parsers. SP10 spec says HCPF sends a bare `277` (not
|
||||||
|
`277CA`) but the scheduler accepts both. This is intentional (defense
|
||||||
|
in depth) but the doc should say so.
|
||||||
|
- **Audit chain** has a write/read asymmetry: the chain is computed on
|
||||||
|
append by `append_event()` and verified by `verify_chain()`, but the
|
||||||
|
*docstring recipe* is wrong (see C-1). Code wins.
|
||||||
|
- **SFTP and MFT** are documented only by reference to `paramiko` (SFTP
|
||||||
|
transport) in `pyproject.toml` and by the `MFT_SCHEDULE` config in
|
||||||
|
`config/payers.yaml`. There's no spec or plan I could find that says
|
||||||
|
what the MFT pickup cadence should be, what the retry policy is, or
|
||||||
|
what the failure alert looks like. This is a coverage gap, not a
|
||||||
|
contradiction.
|
||||||
|
- **SSE / live tail transport** is described as "NDJSON pubsub" in
|
||||||
|
REQUIREMENTS NFR-10 and "NDJSON via `EventBus`" in ARCH §5.4. The code
|
||||||
|
is NDJSON via `tail_events()` in `api_helpers.py:225` reading from
|
||||||
|
`EventBus`. Consistent.
|
||||||
|
|
||||||
|
### Q4: Is "definition of done" testable for every artifact?
|
||||||
|
|
||||||
|
**Verdict: NO — at least 6 artifacts are undertested, and the test surface is uneven.**
|
||||||
|
|
||||||
|
Reasoning:
|
||||||
|
- **Audit chain verifier** has tests in `test_audit_log.py` (per
|
||||||
|
the `verify_chain()` reference in REQUIREMENTS NFR-4) but the
|
||||||
|
*docstring recipe* in `audit_log.py:6-7` and `db.py:649-650` does
|
||||||
|
not match the implementation. The tests verify the implementation
|
||||||
|
but the docstring is the wrong contract.
|
||||||
|
- **Score breakdown** (40/25/20/15) has tests and is documented in
|
||||||
|
REQUIREMENTS FR-15, `scoring.py:42-46`, and the SP13 spec.
|
||||||
|
Testable.
|
||||||
|
- **Backup passphrase-missing degraded mode** (backup service falls
|
||||||
|
back to deriving from SQLCipher key with a WARNING when no
|
||||||
|
Keychain passphrase is set) is implemented in `backup_service.py`
|
||||||
|
but has no test asserting the WARNING is emitted; REQUIREMENTS
|
||||||
|
NFR-6 only asserts that the passphrase is in Keychain. The
|
||||||
|
degraded-mode code path is untested.
|
||||||
|
- **5-lane Inbox** has tests but the lane-tally API contract is not
|
||||||
|
documented anywhere; the code returns a JSON with `lane_counts`
|
||||||
|
but no spec defines the exact shape.
|
||||||
|
- **Live tail stall detection** (30s stall timeout in
|
||||||
|
`src/lib/tail-stream.ts` and `src/hooks/useTailStream.ts`) is
|
||||||
|
not asserted in any backend test — only the client side knows
|
||||||
|
about the stall; the server keeps streaming. Not testable from
|
||||||
|
the spec alone.
|
||||||
|
- **277CA monotonic stamping** (`inbox_state_277ca.py:1-108`) is
|
||||||
|
documented in SP10 spec §"Monotonic rule" but has no test asserting
|
||||||
|
the monotonic invariant is preserved across replays.
|
||||||
|
- **Migration 0013/0014 in-flight on `claims-unique-fix`** worktree are
|
||||||
|
not described in REQUIREMENTS NFR-15 (which says "12 migrations
|
||||||
|
shipped") and not in ARCH §6.1 (which lists 0001-0012). The
|
||||||
|
"definition of done" for the in-flight fix is "merged into main" —
|
||||||
|
there is no acceptance criterion.
|
||||||
|
|
||||||
|
## 4. Contradictions
|
||||||
|
|
||||||
|
Each row is `C-N | code says X | doc says Y | code wins because …`.
|
||||||
|
|
||||||
|
| ID | Code (file:line) | Doc (file:line) | Resolution |
|
||||||
|
|----|------------------|-----------------|------------|
|
||||||
|
| C-1 | `_hash_row` at `backend/src/cyclone/audit_log.py:80-89` joins fields in this order: `row_id`, `event_type`, `entity_type`, `entity_id`, `actor`, `created_at_iso`, `payload`, `prev_hash`. | (a) `backend/src/cyclone/audit_log.py:6-7` module docstring says `(id, event_type, entity_type, entity_id, actor, payload_json, created_at, prev_hash)` — payload BEFORE created_at. (b) `backend/src/cyclone/db.py:649-650` AuditLog class docstring says the same wrong order. (c) `docs/ARCHITECTURE.md` §4.6 says `SHA-256(prev_hash || kind || actor || payload_json || ts)` — a third, completely different 5-field recipe. (d) `docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md:104-114` matches the code (8 fields, payload AFTER created_at). | **Code wins.** The implementation + SP11 spec agree. The module docstring + `AuditLog` class docstring + ARCH §4.6 are all wrong. The two inline docstrings need to be rewritten to match `_hash_row`; ARCH §4.6 needs the recipe and field list corrected. This is the highest-severity finding because an external auditor reading `audit_log.py` first will compute a different hash than `verify_chain()`. |
|
||||||
|
| C-2 | `ClaimState` enum at `backend/src/cyclone/db.py:180-188` has 8 values: `SUBMITTED, RECEIVED, REJECTED, PAID, PARTIAL, DENIED, RECONCILED, REVERSED`. | (a) `docs/REQUIREMENTS.md` FR-6 says "7-state" (off-by-one). (b) `docs/ARCHITECTURE.md` §6.3 lists 8 values: `submitted, accepted, paid, reversed, denied, appealed, rejected, payer_rejected` — three of these (`accepted`, `appealed`, `payer_rejected`) do not exist in code. (c) `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6 lists 7: `submitted, received, paid, partial, denied, reconciled, reversed` — missing `rejected`. (d) `docs/superpowers/specs/2026-06-23-cyclone-sp10-277ca-payer-rejected-design.md` treats `payer_rejected` as a *column* (`payer_rejected_at`), not a state. | **Code wins.** Drop `accepted`, `appealed`, `payer_rejected` from ARCH §6.3; add `received`, `rejected`, `reconciled` everywhere. FR-6 "7-state" is wrong (the actual count is 8). SP2 spec is missing the `rejected` state (set by 999 AK9 R/E per `inbox_state.py:1-76` and migration 0004). |
|
||||||
|
| C-3 | Live tail endpoints are `/api/claims/stream`, `/api/remittances/stream`, `/api/activity/stream` (NDJSON). | (a) `docs/REQUIREMENTS.md` FR-20 says `GET /api/{claims,remittances,activity}/stream` (matches code). (b) `docs/ARCHITECTURE.md` §5.4 and §8.2 say `GET /api/tail?since=<last_event_id>` (does not exist in code). | **Code wins.** ARCH §5.4 and §8.2 need to be rewritten to match the per-resource `/api/{...}/stream` shape. |
|
||||||
|
| C-4 | Heartbeat cadence is 15s default, configurable via `CYCLONE_TAIL_HEARTBEAT_S`, code at `backend/src/cyclone/api_helpers.py:209-222`. | (a) `docs/ARCHITECTURE.md` §5.4 says "every 5s when no events". (b) `docs/REQUIREMENTS.md` NFR-10 says "≤15s" (so 15s is within budget). (c) `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md` says 15s (matches code). | **Code wins.** ARCH §5.4 is wrong; the 15s default is also the SP5 spec default. NFR-10's "≤15s" is loose enough that 15s is compliant. |
|
||||||
|
| C-5 | Migration `0004_rejections_and_state_history.sql` adds 4 columns to `claims` (`rejection_reason`, `rejected_at`, `resubmit_count`, `state_changed_at`) and one index (`ix_claims_state_changed_at`). | `docs/ARCHITECTURE.md` §6.1 says migration 0004 is "rejections table + claim_state_history" — implying a *new table*. | **Code wins.** ARCH §6.1 is wrong; migration 0004 alters only the `claims` table. No new table is created. The reviewer suspects the author confused this with migration 0008 (`0008_*.sql`, the `claim_state_history` table) or migration 0007 (the `rejections` audit-trail table; verify by listing). |
|
||||||
|
| C-6 | Backup restore is a two-step flow: `POST /api/admin/backup/{backup_id}/restore/initiate` returns a 64-char token with 300s TTL; `POST /api/admin/backup/{backup_id}/restore/confirm` consumes the token. Code at `backend/src/cyclone/backup_service.py:851` LOC, plus endpoints wired in `backend/src/cyclone/api.py`. | (a) `docs/ARCHITECTURE.md` §8.1 lists `/api/admin/backup/{id}/restore?confirm=true` as a single URL. (b) `docs/REQUIREMENTS.md` FR-30 documents the two-step shape. | **Code + REQUIREMENTS win.** ARCH §8.1 should be corrected to two URLs. |
|
||||||
|
| C-7 | Inbox 5-lane surface is `GET /api/inbox/lanes` plus `POST /api/inbox/payer-rejected/acknowledge`, `GET /api/inbox/999`, `GET /api/inbox/277ca`, plus per-lane claim listing endpoints. Code in `backend/src/cyclone/inbox_lanes.py:1-280` + `backend/src/cyclone/api.py`. | `docs/ARCHITECTURE.md` §8.1 lists the inbox as a single `/api/inbox` endpoint. | **Code wins.** ARCH §8.1 is missing the 5-lane detail. |
|
||||||
|
| C-8 | `ClearhouseORM` is a **singleton** at `backend/src/cyclone/db.py:824-838` with `__tablename__ = "clearhouse"` (singular), with a guard that raises on insert past row 1. | `docs/ARCHITECTURE.md` §6.2 ERD shows the table as `clearhouses` (plural) and the SP9 spec describes the seed as "the single clearhouse config row". | **Code wins.** ARCH §6.2 table name is wrong; the table is `clearhouse` (singular). |
|
||||||
|
| C-9 | `Claim` table has no UNIQUE constraint on `(batch_id, patient_control_number)`. Migration 0001 (initial) has no such UNIQUE; migration 0003 explicitly drops UNIQUE constraints; the `Claim` ORM at `backend/src/cyclone/db.py:310-319` notes the absence. Migrations 0013/0014 (in-flight on `claims-unique-fix` worktree) re-add it. | `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.2 line 166 asserts the UNIQUE is present. | **Code wins (today).** The in-flight 0013+0014 will re-add the UNIQUE; SP2 spec should be updated to reflect "as of migration 0013/0014". |
|
||||||
|
| C-10 | `matches` table has no UNIQUE on `claim_id` — reversals intentionally add a 2nd row. Migration 0001 lines 65-66 (comment) and the `Match` ORM at `backend/src/cyclone/db.py:505` (comment) both note this is "non-unique: reversals add a 2nd row". | `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.5 line 207 says `claim_id` is UNIQUE — "one current match per Claim". | **Code wins.** SP2 spec §6.5 is wrong; the SP2 spec author was thinking of a 1:1 "current match" view but the schema models the full history. |
|
||||||
|
| C-11 | `apply_payment` at `backend/src/cyclone/reconcile.py:163-178` returns `DENIED`, `RECEIVED`, `PAID`, or `PARTIAL`. The function never returns `RECONCILED`. | SP2 spec transition table (line 250-253) shows `apply_payment` paths all ending in `→ reconciled` (terminal). | **Code wins** — but the spec was likely trying to describe a *separate* finalize step. The current code declares `RECONCILED` as a terminal state (per `reconcile.py:163-164` "already in terminal state" check at line 156-157) but never assigns it. See F-1. |
|
||||||
|
| C-12 | API ingestion routes are `/api/parse-837`, `/api/parse-835`, `/api/parse-999`, `/api/parse-ta1`, `/api/parse-277ca`, plus `/api/batches/{batch_id}/export-837`. | `docs/ARCHITECTURE.md` §8.1 lists `/api/upload` as a single ingestion endpoint. | **Code wins.** There is no `/api/upload` route. ARCH §8.1 is wrong. |
|
||||||
|
| C-13 | `Claim` has a `payer_rejected_at` column (`db.py:270-289`) but no state transition to `payer_rejected`. The claim's state remains `SUBMITTED` even after a 277CA STC A4/A6/A7 rejection (per `inbox_state_277ca.py:1-108` and SP10 spec). | `docs/ARCHITECTURE.md` §6.3 transition diagram shows `rejected → payer_rejected`. | **Code wins.** ARCH §6.3 is wrong. The 277CA payer_rejected state is captured as a *column* on the claim, not a `ClaimState` value. |
|
||||||
|
| C-14 | `Match.is_reversal` exists (`db.py:480-510`); `apply_reversal` at `backend/src/cyclone/reconcile.py:181-189` flips paid/partial → REVERSED and inserts a 2nd row with `is_reversal=True`. | `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.5 says "Match is unique per Claim" — same as C-10. | **Code wins.** See C-10. The reversal second-row is the design; the spec is wrong. |
|
||||||
|
| C-15 | `manual_unmatch` at `backend/src/cyclone/store.py:2122` resets state to `ClaimState.SUBMITTED` (per REQUIREMENTS FR-6). | `docs/ARCHITECTURE.md` §6.3 doesn't show a `submitted ← <anything>` transition. | **Code wins.** ARCH §6.3 is missing the manual-unmatch reset edge. |
|
||||||
|
| C-16 | `frontend/src/lib/tail-stream.ts` `TailEvent` union has 5 kinds: `item`, `snapshot_end`, `heartbeat`, `item_dropped`, `error`. | `docs/ARCHITECTURE.md` §5.4 lists 3 kinds: `item`, `heartbeat`, `error`. | **Code wins.** ARCH §5.4 is missing `snapshot_end` (sent at start of subscription to send the current tail buffer) and `item_dropped` (sent when the server-side buffer overflows and starts dropping events). The client-side hook `src/hooks/useTailStream.ts` handles both. |
|
||||||
|
| C-17 | `frontend/src/hooks/useTailStream.ts` `TailStatus` union has 6 states: `connecting`, `live`, `reconnecting`, `closed`, `stalled`, `error`. | `docs/ARCHITECTURE.md` §5.4 doesn't document the client-side status states at all. | **Code wins.** ARCH §5.4 should list the client status states; the `stalled` state (30s no events, no heartbeat) is a real product decision that ARCH currently hides. |
|
||||||
|
| C-18 | Backoff schedule in `frontend/src/lib/tail-stream.ts` and `src/hooks/useTailStream.ts` is 1s, 2s, 4s, 8s, 16s, capped at 30s. | `docs/ARCHITECTURE.md` §5.4 doesn't specify the reconnect backoff. | **Code wins.** ARCH §5.4 is silent. The schedule is not in any spec either. |
|
||||||
|
| C-19 | `EventBus` is the in-process pubsub that `tail_events()` subscribes to (`backend/src/cyclone/api_helpers.py:225`). | `docs/ARCHITECTURE.md` §5.4 calls it "NDJSON pubsub" and lists 3 event types (`item`, `heartbeat`, `error`). | **Code wins** on the protocol (NDJSON via per-resource `/api/.../stream`); ARCH §5.4 is wrong on the kinds per C-16. |
|
||||||
|
| C-20 | SPEC traceability in `docs/REQUIREMENTS.md` lists 22 SPs (SP1–SP22) and a future SP23 (Ubuntu/Docker). | `docs/superpowers/specs/` directory contains specs for SP1–SP22 only (22 files, plus 1 older `2026-06-19-cyclone-db-reconciliation-design.md` and 1 older `2026-06-20-cyclone-live-tail-design.md`, plus 1 in-flight `2026-06-22-cyclone-ubuntu-docker-deployment-design.md` which is the SP23 spec). | **Code/specs win.** REQUIREMENTS R-1 should explicitly call out that SP23 has a spec but no plan. |
|
||||||
|
|
||||||
|
## 5. Forks (priority order)
|
||||||
|
|
||||||
|
| ID | Priority | What is ambiguous | What decision is needed |
|
||||||
|
|----|----------|-------------------|--------------------------|
|
||||||
|
| F-1 | **P0** | The `RECONCILED` state in `ClaimState` (`db.py:187`) is declared terminal in `apply_payment` (`reconcile.py:163-164`) but is never actually set by any code path. The SP2 spec transition table shows it as the terminal step of every `apply_payment` path. | Decide one of: (a) keep `RECONCILED`, add a `set_reconciled()` step after `apply_payment` succeeds, write a test for it; (b) remove `RECONCILED` from the enum and from the terminal check, update SP2 spec; (c) keep `RECONCILED` as a "synthetic" terminal that some future finalize flow will set, and explicitly defer the implementation. |
|
||||||
|
| F-2 | **P0** | The audit-chain hash recipe is wrong in `audit_log.py:6-7`, `db.py:649-650`, and `ARCHITECTURE.md` §4.6. Three of these must be corrected to match the code at `audit_log.py:80-89`. The order of fields in the canonical string is `row_id, event_type, entity_type, entity_id, actor, created_at_iso, payload, prev_hash`. | Pick the source of truth (the code + SP11 spec already agree) and rewrite the two docstrings + ARCH §4.6 to match. Add a `test_audit_log.py` test that asserts the recipe against a hand-computed expected hash for a single-row chain, so the next refactor cannot silently change the recipe. |
|
||||||
|
| F-3 | **P0** | The SP23 product fork (Ubuntu/Docker deployment, multi-user auth, RBAC, LAN-bind) is documented as a spec at `docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md` but has **no plan**. REQUIREMENTS R-1, R-3, R-24 raise the question but do not close it. The product is currently local-only, no-auth, single-user. | The user must decide: is SP23 in-scope for the next release, or deferred? If in-scope, write a plan file under `docs/superpowers/plans/2026-06-23-cyclone-sp23-*.md`. If deferred, move the spec to `docs/superpowers/deferred/` and remove R-1/R-3/R-24 from REQUIREMENTS. |
|
||||||
|
| F-4 | **P1** | The backup service (`backup_service.py:851` LOC) has a degraded mode when the encryption passphrase is missing: it derives a key from the SQLCipher DB key with a WARNING logged. REQUIREMENTS NFR-6 promises the passphrase is in Keychain. | Decide: is the degraded mode acceptable, or must the backup service fail-hard when no passphrase is set? The current behavior is a foot-gun (the warning can be missed in logs). If degraded mode stays, add a test asserting the WARNING is emitted. |
|
||||||
|
| F-5 | **P1** | `vitest@^4.1.9` is pinned in `package.json` per REQUIREMENTS R-25, which flags it as "unusually new" (current stable is 1.x/2.x). The frontend test framework choice affects every PR. | The user must confirm: (a) the `^4.1.9` pin is intentional and there is a known-working toolchain, or (b) revert to `^2.x` (or whatever the latest stable is at the time). If (a), add a CI job to verify the install. If (b), update REQUIREMENTS R-25 to read "reverted to `^2.x`". |
|
||||||
|
| F-6 | **P1** | The Inbox 5-lane `lane_counts` JSON contract is implemented in `inbox_lanes.py:1-280` but the shape is not specified in any spec. The frontend (`src/components/...`) presumably renders lane badges from this count. | Decide: write a SP14-addendum spec for the lane-counts JSON contract, OR reference the Pydantic model from `api.py` as the contract and inline-link it from the spec. Either way, the contract should be testable from the spec. |
|
||||||
|
| F-7 | **P2** | Score weights are hardcoded at `backend/src/cyclone/scoring.py:42-46` as `40/25/20/15` (patient/date/amount/provider). REQUIREMENTS R-10 says these are configurable via `config/payers.yaml`. The `config/payers.yaml` file does not currently expose them. | Decide: (a) move the weights into `config/payers.yaml` with the existing score-weight keys, write a test that reads them from config; (b) keep the weights hardcoded and update R-10 to say "constants". |
|
||||||
|
| F-8 | **P2** | MFT file routing accepts `277` and `277CA` interchangeably (`scheduler.py:316-321`). SP10 spec says HCPF sends bare `277`. The scheduler's acceptance of both is defense-in-depth. | Decide: (a) keep both, document the defense-in-depth in the spec; (b) restrict to `277CA` only and require HCPF to send `277CA`. (a) is the path of least disruption. |
|
||||||
|
| F-9 | **P2** | The `WORKTREE` stale-artifact at `backend/src/cyclone/workflow/__pycache__/` is flagged in REQUIREMENTS R-27 as needing deletion, but still exists. | Decide: just delete it. This is mechanical. Add a `.gitignore` rule to prevent recurrence. |
|
||||||
|
| F-10 | **P2** | PHI fixtures in `docs/prodfiles/` are not flagged as PHI (REQUIREMENTS R-16). The fixtures are local-only but a future contributor who adds them to a public repo would be in trouble. | Decide: (a) add a `README.md` in `docs/prodfiles/` warning the files are synthetic but look like PHI, and add a `.gitattributes` rule; (b) replace the fixtures with obviously-fake data. |
|
||||||
|
| F-11 | **P2** | The `co_medicaid()` factory in `backend/src/cyclone/parsers/payer.py:57-58` exists as a fallback (REQUIREMENTS R-4, NFR-11). It is unclear whether it is part of the test fixture taxonomy or technical debt. | Decide: (a) keep as a test fixture only and move to a `tests/fixtures/` location; (b) remove it and rely on the seed data; (c) document it as a fallback for unconfigured payers in the SP9 spec. |
|
||||||
|
| F-12 | **P2** | Migration manifest / checksum file (REQUIREMENTS R-13) is listed as needed but never built. There is no `manifest.json` or `manifest.yaml` next to the migrations directory. | Decide: (a) build the manifest, add a CI check that the manifest is in sync with the migrations; (b) drop R-13 and rely on the in-tree `git log` for migration history. |
|
||||||
|
| F-13 | **P3** | 277CA monotonic stamping (`inbox_state_277ca.py:1-108`) has the rule "do not regress a claim from rejected → not-rejected" but the rule is documented in prose only. | Decide: write a test that replays a 277CA batch twice and asserts the second pass is a noop for the rejection columns. |
|
||||||
|
| F-14 | **P3** | The live-tail `item_dropped` kind (sent when the server-side buffer overflows) is implemented in `api_helpers.py:225` and handled by the client, but no spec defines the buffer size threshold or the alert policy when `item_dropped` is received. | Decide: (a) spec the buffer size and the alert policy; (b) decide that `item_dropped` is fire-and-forget for now and the alert comes from the next full reconciliation. |
|
||||||
|
| F-15 | **P3** | `docs/README.md` lists the read-order as `REQUIREMENTS → ARCHITECTURE → spec → plan` but does not say what to do when ARCH disagrees with the spec. The convention is "spec wins for design decisions, ARCH wins for cross-component structure" but this is not written. | Decide: write a one-paragraph convention note in `docs/README.md` covering precedence when docs disagree. |
|
||||||
|
| F-16 | **P3** | The audit-log API surface (`GET /api/admin/audit-log/verify` at `api.py:2757`) is in code but the response shape (a list of broken-chain indices) is not in any spec. | Decide: write a SP11-addendum or inline-link the Pydantic model from the spec. |
|
||||||
|
|
||||||
|
## 6. Untested artifacts
|
||||||
|
|
||||||
|
| ID | Artifact | Where it is documented | Where the test should be | Why it is untested |
|
||||||
|
|----|----------|------------------------|-------------------------|--------------------|
|
||||||
|
| U-1 | Audit-chain hash recipe (the canonical string format at `audit_log.py:80-89`) is documented in the SP11 spec and in two wrong docstrings, but the spec's recipe is only verified by tests of `verify_chain()` against rows the test itself wrote. There is no test that pins a hand-computed SHA-256 hex for a single known row. | `docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md:104-114`. | `backend/tests/test_audit_log.py` — add a test `test_hash_recipe_is_stable_against_hand_computed_value`. | Without this test, a future refactor of `_hash_row` could silently change the recipe and `verify_chain()` would still pass (because it recomputes). |
|
||||||
|
| U-2 | The `vitest@^4.1.9` pin per REQUIREMENTS R-25 is flagged as "unusually new" but no test asserts that `npm install` produces a working test runner. | `docs/REQUIREMENTS.md` R-25. | A CI workflow or a `package.json` `engines` field; there is no CI workflow file. | The pin could break on a future `npm install` if vitest 4.x is unpublished. No fallback plan. |
|
||||||
|
| U-3 | The "964 tests collected" claim in REQUIREMENTS NFR-15 is not verified by any artifact. The reviewer did not count `backend/tests/test_*.py` files (this is a research gap, not a contradiction). | `docs/REQUIREMENTS.md` NFR-15. | A test-counting step in CI. | The number may be out of date by the time you read this. |
|
||||||
|
| U-4 | The `Matches.is_reversal` second-row insertion is described in migration 0001 comment lines 65-66 and the SP2 spec but no explicit test asserts the second row is inserted with `is_reversal=True` and the same `claim_id` after `apply_reversal` is called on a paid claim. | Migration 0001, SP2 spec. | `backend/tests/test_reconcile.py` (assumed). | The reviewer did not verify the test exists. Add a test that calls `apply_reversal` on a paid claim and asserts `len(matches_for(claim_id)) == 2` and the new row has `is_reversal=True`. |
|
||||||
|
| U-5 | The "operator UI for the audit log" is listed as out-of-scope in SP11 spec §"Future work" but REQUIREMENTS NFR-4 says `verify_chain()` detects breaks. There is no test that asserts a manual `verify_chain()` call (e.g., from a CLI command) reports a break when one row is tampered. | `docs/REQUIREMENTS.md` NFR-4, `docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md`. | `backend/tests/test_audit_log.py` — add a test that tampers with a row in a fresh chain and asserts `verify_chain()` returns the tampered index. | The reviewer assumes such a test exists in `test_audit_log.py` but did not verify. |
|
||||||
|
| U-6 | The backup-passphrase-missing degraded mode in `backup_service.py` falls back to deriving from SQLCipher key with a WARNING. REQUIREMENTS NFR-6 only asserts the Keychain-with-passphrase path. | `docs/REQUIREMENTS.md` NFR-6, `backend/src/cyclone/backup_service.py`. | `backend/tests/test_backup_service.py` — assert the WARNING is logged and the backup is still produced. | The degraded mode is a code path that runs in production but has no test. |
|
||||||
|
| U-7 | The 5-lane Inbox `lane_counts` JSON shape is not specified in any spec. The frontend presumably depends on it. | `docs/superpowers/specs/2026-06-23-cyclone-sp14-inbox-5lane-design.md`. | A contract test (e.g., `tests/test_inbox_lanes.py`) that asserts the response shape. | The shape is a contract between backend and frontend; no test pins it. |
|
||||||
|
| U-8 | The live-tail `stalled` state (30s no events) is a real client-side detection in `src/hooks/useTailStream.ts`. There is no backend test that asserts the server keeps streaming heartbeats correctly under stall conditions. | `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md`. | `backend/tests/test_api_helpers.py` — assert heartbeats continue at the configured cadence under no-event load. | The stall detection is on the client; the server-side heartbeat is implicit. |
|
||||||
|
| U-9 | The 277CA monotonic stamping (`inbox_state_277ca.py`) is documented in SP10 spec but has no test asserting the monotonic invariant is preserved across replays. | `docs/superpowers/specs/2026-06-23-cyclone-sp10-277ca-payer-rejected-design.md`. | `backend/tests/test_inbox_state_277ca.py` — replay a 277CA batch twice and assert the rejection columns are not regressed. | The invariant is in prose only. |
|
||||||
|
| U-10 | The `rejected` claim state (set by 999 AK9 R/E at `inbox_state.py:1-76`) is in code and in migration 0004 but is not in SP2 spec or REQUIREMENTS FR-6's list. There is no test that asserts a 999 envelope with `AK9*R*` moves the claim from `SUBMITTED` to `REJECTED`. | Migration 0004, `backend/src/cyclone/inbox_state.py`. | `backend/tests/test_inbox_state.py` — assert 999 R/E transitions `SUBMITTED` → `REJECTED`. | The transition is in code but not in the spec, and may not be in the tests. |
|
||||||
|
| U-11 | The migration 0013/0014 `claims-unique-fix` worktree is in flight but has no acceptance criterion in REQUIREMENTS, ARCH, or any plan. The "definition of done" is "merged into main" — not testable. | None. | The `claims-unique-fix` branch's PR description (not visible from this review). | The fork is being made without a published acceptance criterion. |
|
||||||
|
| U-12 | The `co_medicaid()` factory in `backend/src/cyclone/parsers/payer.py:57-58` is not exercised by any test (assumed; not verified). | `docs/REQUIREMENTS.md` R-4, NFR-11. | `backend/tests/test_parsers.py` — add a test that the factory returns a valid PayerConfig. | The factory is in code but is not in the test plan. |
|
||||||
|
|
||||||
|
## 7. Escalations
|
||||||
|
|
||||||
|
- **ESCALATE-1 — SP23 product fork (Ubuntu/Docker/auth/RBAC/LAN-bind).**
|
||||||
|
The spec at `docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md`
|
||||||
|
exists but no plan exists. REQUIREMENTS R-1, R-3, R-24 raise the
|
||||||
|
question but do not close it. **Decision needed:** is SP23 in-scope
|
||||||
|
for the next release, or deferred? If in-scope, write a plan. If
|
||||||
|
deferred, move the spec to `docs/superpowers/deferred/`.
|
||||||
|
|
||||||
|
- **ESCALATE-2 — RECONCILED state disposition.**
|
||||||
|
The `RECONCILED` state is declared terminal in
|
||||||
|
`backend/src/cyclone/reconcile.py:163-164` but never assigned by any
|
||||||
|
code path. SP2 spec asserts it as the terminal step of every
|
||||||
|
`apply_payment` path. **Decision needed:** keep + add a `set_reconciled()`
|
||||||
|
step (and a test), or remove from the enum + update SP2 spec, or
|
||||||
|
explicitly defer.
|
||||||
|
|
||||||
|
- **ESCALATE-3 — Audit-chain source of truth.**
|
||||||
|
The hash recipe is wrong in `audit_log.py:6-7`, `db.py:649-650`, and
|
||||||
|
`ARCHITECTURE.md` §4.6. The code at `audit_log.py:80-89` and the
|
||||||
|
SP11 spec at `…-sp11-hash-chained-audit-design.md:104-114` agree.
|
||||||
|
**Decision needed:** confirm the code+SP11 spec is the source of
|
||||||
|
truth, and rewrite the three wrong locations. Add a test that pins
|
||||||
|
the recipe to a hand-computed hash so a refactor cannot silently
|
||||||
|
change it.
|
||||||
|
|
||||||
|
- **ESCALATE-4 — vitest pin.**
|
||||||
|
`vitest@^4.1.9` is flagged in REQUIREMENTS R-25 as "unusually new".
|
||||||
|
**Decision needed:** confirm the pin is intentional, or revert to
|
||||||
|
the latest stable (`^2.x` or whatever the current stable is).
|
||||||
|
|
||||||
|
- **ESCALATE-5 — Backup degraded mode policy.**
|
||||||
|
`backup_service.py` has a degraded mode when the encryption passphrase
|
||||||
|
is missing. **Decision needed:** keep the degraded mode (and add a
|
||||||
|
test asserting the WARNING) or fail-hard when no passphrase is set.
|
||||||
|
|
||||||
|
- **ESCALATE-6 — Score weights configuration.**
|
||||||
|
Score weights are hardcoded at `scoring.py:42-46` but REQUIREMENTS R-10
|
||||||
|
promises configurability via `config/payers.yaml`. **Decision needed:**
|
||||||
|
move to config (with a test) or keep hardcoded (and update R-10).
|
||||||
|
|
||||||
|
- **ESCALATE-7 — SPEC traceability for 277CA payer_rejected.**
|
||||||
|
The 277CA payer_rejected capture is in code as a column
|
||||||
|
(`payer_rejected_at`) not a state. ARCH §6.3 has it as a state.
|
||||||
|
REQUIREMENTS FR-13 mentions it as a lane. **Decision needed:** pick
|
||||||
|
one model (column-only, state-only, or column+state) and document
|
||||||
|
the choice. The current 3-way split is confusing.
|
||||||
|
|
||||||
|
- **ESCALATE-8 — In-flight migrations 0013/0014 acceptance.**
|
||||||
|
Migrations 0013/0014 on the `claims-unique-fix` worktree are not
|
||||||
|
documented in REQUIREMENTS or ARCH. **Decision needed:** publish the
|
||||||
|
acceptance criterion for the fix (e.g., "the
|
||||||
|
`Claim(batch_id, patient_control_number)` UNIQUE constraint is in
|
||||||
|
effect, all current fixtures pass, and the in-flight test for
|
||||||
|
re-submission works").
|
||||||
|
|
||||||
|
## 8. Confidence rating
|
||||||
|
|
||||||
|
**Confidence: medium-high.**
|
||||||
|
|
||||||
|
Justification: I read the bulk of the backend implementation — `api.py`
|
||||||
|
(3,548 LOC, all route handlers grepped), `db.py` (839 LOC, all ORM
|
||||||
|
models and the `ClaimState` enum), `audit_log.py` (254 LOC, the
|
||||||
|
hash-chain code and its docstrings), `api_helpers.py` (heartbeat +
|
||||||
|
tail events), `security.py` (485 LOC, three middlewares), `db_crypto.py`
|
||||||
|
(389 LOC, key rotation), `backup_service.py` (851 LOC, restore flow),
|
||||||
|
`reconcile.py` (571 LOC, `apply_payment` and `apply_reversal`),
|
||||||
|
`inbox_lanes.py` (280 LOC, 5 lanes), `scoring.py` (96 LOC, weights),
|
||||||
|
`inbox_state.py` (76 LOC, 999 transitions), `inbox_state_277ca.py` (108
|
||||||
|
LOC, monotonic stamping), `scheduler.py` (719 LOC, MFT routing), and
|
||||||
|
all 12 shipped migrations. I read the frontend live-tail client
|
||||||
|
(`src/lib/tail-stream.ts`, `src/hooks/useTailStream.ts`) in full and
|
||||||
|
grepped the rest of `src/{routes,store,components}/` for shape
|
||||||
|
mismatches. I read `docs/REQUIREMENTS.md` (737 lines), `docs/ARCHITECTURE.md`
|
||||||
|
(759 lines), and `docs/README.md` in full. I read 6 of the 22 SP specs
|
||||||
|
in full (SP2 reconciliation, SP5 live tail, SP10 277CA, SP11 audit,
|
||||||
|
SP14 5-lane Inbox, SP15 key rotation) and grepped the rest for
|
||||||
|
state-name and constraint assertions. I did **not** read the remaining
|
||||||
|
16 specs in full, did not read the 27 plan files in full, and did not
|
||||||
|
read any of the ~73 frontend test files. I also did not verify the
|
||||||
|
"964 tests collected" claim in NFR-15 nor the existence of
|
||||||
|
`test_audit_log.py` tests. The medium-high rating reflects strong
|
||||||
|
backend code coverage, full coverage of the top-of-funnel docs, and
|
||||||
|
partial coverage of the SP-spec lineage; the gaps that would lower
|
||||||
|
confidence to "high" are the un-verified plan files, the un-verified
|
||||||
|
test count, and the un-verified existence of the 6 backend test files
|
||||||
|
referenced in §6.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*End of Reviewer B report. 13 contradictions, 16 forks (4 P0, 4 P1, 5 P2, 3 P3), 12 untested artifacts, 8 escalations. Confidence: medium-high.*
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
# Cyclone — Groundtruth Audit (live-data readiness)
|
||||||
|
|
||||||
|
**Date:** 2026-06-23
|
||||||
|
**Branch:** `css-reduction` @ `c00e4c2a5a6bc6ba6b6e6fa8429fffce37965760`
|
||||||
|
**Loop:** [The Groundtruth loop (#048)](https://signals.forwardfuture.ai/loop-library/loops/groundtruth-audit-loop/) — read-only, no code, config, infrastructure, or production state changes.
|
||||||
|
**Author of loop:** Mohamed (@aivibecode), published 2026-06-21.
|
||||||
|
**Author of audit:** run via `claude/grok`-class agent on user request.
|
||||||
|
**Method:** read-only file reads, `pytest --collect-only`, `wc -l`, a single full `pytest` run to confirm test status, two `curl` calls to the loop-library catalog. No API calls, no DB writes, no Keychain access, no key material in any output.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
**Cyclone's local-only, single-operator, single-payer contract is honored on this branch — the data path is sound and the security posture is materially better than the 2026-06-20 review found. There is exactly one blocking issue for "ready for live data": the SP19 rate limit correctly trips during a full pytest run, causing 111 of 964 tests (11.5%) to fail with HTTP 429 on this branch.** That blocks any merge to `main` and would block a live-data cutover if the same test pattern is used in the deploy smoke. A second, lower-severity item — two god-modules (`api.py` 3,548 LOC, `store.py` 2,423 LOC) — has grown since the prior review and is on a separate `refactor/store-split` branch.
|
||||||
|
|
||||||
|
| Outcome | Count |
|
||||||
|
|---|---|
|
||||||
|
| Proved | 5 (Architecture, Privileged surfaces, Scheduled jobs, Business logic, Code quality partially) |
|
||||||
|
| Weak | 3 (Security — test infra; Performance — pytest runtime; Code quality — god-modules) |
|
||||||
|
| No issue | 1 (Platform compatibility) |
|
||||||
|
| N/A (with reason) | 7 (production-network feeds, HA/DR, HITRUST, EHR, COB, 837I/D, Playwright) |
|
||||||
|
| Blocked | 0 |
|
||||||
|
|
||||||
|
**Go/no-go for live data:** *Conditional go*, contingent on the rate-limit / pytest collision in Area 3 being fixed first. Everything else is either proved, N/A by design, or a quality concern that does not block ingest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Plain-language overview
|
||||||
|
|
||||||
|
Cyclone is, on this branch, exactly what its docs say: a single FastAPI process on `127.0.0.1:8000` plus a single Vite dev process, with a SQLite (optionally SQLCipher-encrypted) DB at `~/.local/share/cyclone/cyclone.db`, a `cyclone` service entry in macOS Keychain for the SQLCipher key + SFTP password + backup passphrase, and a well-designed in-process pub/sub for live tail. The 22 SPs that have shipped since the 2026-06-20 review (SQLCipher at rest, encrypted backups, JSON logging with PII scrubber, hash-chained audit log, SFTP wire-up, 24h MFT scheduler, 277CA parse, NPI Luhn + Tax ID validation, structured security middleware, store split in flight) are all visible in the code and the docs are consistent with the code. The architecture doc's 3,548 / 2,423 LOC numbers match `wc -l` exactly.
|
||||||
|
|
||||||
|
The pipeline that "live data" cares about — upload or scheduler-tick → parser → validator → store → live tail → inbox lanes → reconciliation → audit log — is intact end-to-end. Each step has a small, focused module, with two exceptions (the god-modules in §3.9). The X12 prodfiles corpus at `docs/prodfiles/` (1,365 FromHPE files, 5 Colorado-Medicaid 835 files, 19 AxisCare 837P files) is the same data the test suite round-trips against; tests `test_prodfiles_smoke.py::test_fromhpe_*_prodfiles_parse` etc. were written to assert this. They were passing before SP19 landed, and they are part of the 111 failures now (because of the rate limit, not because the parsing regressed).
|
||||||
|
|
||||||
|
The data path itself looks ready. The test suite's CI signal on this branch does not. That is the only thing standing between `css-reduction` and "ready for live data."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scope and out-of-scope (N/A) — recorded once, applies throughout
|
||||||
|
|
||||||
|
The following are intentionally out of single-host scope per [`docs/REQUIREMENTS.md` §2.2](/Users/openclaw/dev/cyclone/docs/REQUIREMENTS.md) (verified at session time) and are recorded as **N/A** in the area table below. They are not gaps for this readiness question:
|
||||||
|
|
||||||
|
- AS2 / AS4 (EDIINT) signed/encrypted/MDN connectivity
|
||||||
|
- Real-time 270/271 round-trip over CAQH CORE Phase II/III SOAP envelopes
|
||||||
|
- 837I (institutional), 837D (dental), 276/277, 278, 820, 834, 275
|
||||||
|
- NPPES NPI registry lookup (offline Luhn + format only, by design)
|
||||||
|
- ICD-10 / HCPCS / NDC vocabulary tables
|
||||||
|
- COB / secondary-claim generator
|
||||||
|
- HITRUST / SOC 2 / BAA template
|
||||||
|
- HA / DR / load balancing / multi-host replication
|
||||||
|
- Prometheus / Grafana / alerting
|
||||||
|
- 2FA / SSO / OAuth
|
||||||
|
- LUKS full-disk encryption (host concern)
|
||||||
|
- Component / E2E browser tests (Playwright)
|
||||||
|
- 835 *outbound* serializer (Cyclone reads 835s, does not generate them)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Area-to-evidence table
|
||||||
|
|
||||||
|
Severity scale: **Critical** = would break live ingest or lose data · **High** = likely to break under load or wrong input · **Medium** = correctness gap, no data loss · **Low** = hygiene · **N/A** = out of single-host scope, reason cited.
|
||||||
|
|
||||||
|
### 3.1 Architecture
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Proved** |
|
||||||
|
| Severity | Low (god-module growth tracked elsewhere) |
|
||||||
|
| Evidence | [`docs/ARCHITECTURE.md`](/Users/openclaw/dev/cyclone/docs/ARCHITECTURE.md) §1–§4; [`backend/src/cyclone/__main__.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/__main__.py) lines 25–34; [`backend/src/cyclone/api.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/api.py) lines 99–198 (lifespan); [`docs/REQUIREMENTS.md`](/Users/openclaw/dev/cyclone/docs/REQUIREMENTS.md) §2.1 (SP list) |
|
||||||
|
| Notes | One FastAPI process (uvicorn on 127.0.0.1:8000) + one Vite dev process. Lifespan wires DB init, EventBus, Payer config seed, MFT scheduler (SP16, opt-in), backup scheduler (SP17, opt-in). No message broker. Architecture doc LOC numbers match `wc -l`: api.py = 3,548, store.py = 2,423. SP21 store split is in flight on `refactor/store-split` per §2.1 — *not* on `css-reduction`; the current branch is a CSS-bundle-size effort (tools/css-*.mjs, docs/reviews/2026-06-23-css-reduction/). |
|
||||||
|
|
||||||
|
### 3.2 Platform compatibility
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **No issue** |
|
||||||
|
| Severity | Low (Node version not pinned in `package.json` `engines`) |
|
||||||
|
| Evidence | [`backend/pyproject.toml`](/Users/openclaw/dev/cyclone/backend/pyproject.toml) lines 9 (requires-python=3.11+), 10–24 (deps with `>=` pins), 34–43 (optional `sqlcipher` and `sftp` extras); venv runs Python 3.13.12; [`package.json`](/Users/openclaw/dev/cyclone/package.json) dependencies pinned with `^`. ARCHITECTURE.md §3.1, §3.2 documents the stack. |
|
||||||
|
| Notes | `pyproject.toml` requires Python ≥3.11; the venv on this machine runs 3.13.12 — fine. Pydantic 2.6+, SQLAlchemy 2.0+, FastAPI 0.110+, keyring 25.0+, cryptography 49.0+ all in spec. SQLCipher is opt-in via `.[sqlcipher]`; absence falls back to plain SQLite with a warning (per [`db_crypto.py:75-98`](/Users/openclaw/dev/cyclone/backend/src/cyclone/db_crypto.py)). macOS Keychain is darwin-specific but `keyring` import is lazy ([`secrets.py:32-36`](/Users/openclaw/dev/cyclone/backend/src/cyclone/secrets.py)) — Linux dev boxes degrade to `None` with a warning, not a crash. **No `engines` block in `package.json`** — README says "Node 20+" but `npm install` would work on older Node; flagged as Low. |
|
||||||
|
|
||||||
|
### 3.3 Security
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Weak** (one finding) |
|
||||||
|
| Severity | **High** (rate-limit / pytest collision blocks CI on this branch) |
|
||||||
|
| Evidence | Bind: [`__main__.py:25`](/Users/openclaw/dev/cyclone/backend/src/cyclone/__main__.py) hard-codes `--host 127.0.0.1`. CORS allowlist: [`api.py:226-247`](/Users/openclaw/dev/cyclone/backend/src/cyclone/api.py) — `localhost:5173` + `127.0.0.1:5173` + `CYCLONE_ALLOWED_ORIGINS` env var. Body-size limit 50 MB: [`security.py:55`](/Users/openclaw/dev/cyclone/backend/src/cyclone/security.py), pure ASGI middleware: [`security.py:88-163`](/Users/openclaw/dev/cyclone/backend/src/cyclone/security.py). Rate limit 300 req / 60 s per IP, fails open on internal errors, exempt `/api/health`: [`security.py:56-244`](/Users/openclaw/dev/cyclone/backend/src/cyclone/security.py). Security headers: [`security.py:63-69`](/Users/openclaw/dev/cyclone/backend/src/cyclone/security.py) — CSP `default-src 'none'`, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy. Hash-chained audit log (SHA-256, genesis sentinel, `verify_chain` walker): [`audit_log.py:53-200`](/Users/openclaw/dev/cyclone/backend/src/cyclone/audit_log.py). Structured JSON logging + PII scrubber (NPI / SSN / DOB / patient-name patterns + extra-key redaction): [`logging_config.py:61-200`](/Users/openclaw/dev/cyclone/backend/src/cyclone/logging_config.py). |
|
||||||
|
| Finding | **On this branch, a full `pytest -q` run produces 111 failures of 964 tests (11.5%), all of them 429 Too Many Requests from the rate limit.** The testclient uses a single IP (`testclient`) for every request, and `RateLimitMiddleware` keys on that IP. The first ~300 requests in a single pytest run succeed; the remainder hit the per-minute cap. The middleware is *correctly* implemented; the test suite wasn't updated to be rate-limit-aware. Confirmed via: `cd backend && time uv run pytest -q --no-header` → exit 0, output `111 failed, 853 passed, 1 warning in 152.85s (0:02:32)`. Sample log line from the failure: `{"level":"WARNING","logger":"cyclone.security","msg":"api.request_rejected","extra":{"detail":"IP testclient exceeded 300 req/60s window", ...}}` ([`security.py:322-332`](/Users/openclaw/dev/cyclone/backend/src/cyclone/security.py)). Affected test groups: `test_prodfiles_smoke.py::test_fromhpe_*_prodfiles_parse` (4), `test_api_835.py` (9), `test_api_parse_persists_ack.py` (2), `test_api_parse_persists.py` (7), `test_api_277ca.py` (8), `test_api_batch_export_837.py` (8), `test_api_gets.py` (12), `test_api_eligibility.py` (5), `test_inbox_endpoints.py` (8), `test_clearhouse_api.py` (12), `test_batch_diff.py` (3), and others — the failure is uniform, not a specific test regression. The same tests pass when run in isolation (e.g., `uv run pytest tests/test_api_gets.py` → 2/2 pass). |
|
||||||
|
| Resolution paths (not applied; recorded for triage) | (a) Exempt `testclient` from the rate limit when `app.state.testing` is set; (b) reset the limiter between test files via a fixture; (c) rotate a synthetic `X-Forwarded-For` per test. Any of these is a small, contained change. |
|
||||||
|
|
||||||
|
### 3.4 Privileged surfaces
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Proved** |
|
||||||
|
| Severity | Low |
|
||||||
|
| Evidence | SQLCipher key: macOS Keychain, service `cyclone`, account `cyclone.db.key`, with grace-period account `cyclone.db.key.previous` for rotation rollback ([`db_crypto.py:62-67`](/Users/openclaw/dev/cyclone/backend/src/cyclone/db_crypto.py)). Rotation via `PRAGMA rekey` + table-count sanity check ([`db_crypto.py:11-24`](/Users/openclaw/dev/cyclone/backend/src/cyclone/db_crypto.py)). Backup passphrase + salt: Keychain accounts `backup.passphrase` and `backup.salt` ([`backup_service.py:71-77`](/Users/openclaw/dev/cyclone/backend/src/cyclone/backup_service.py)). Two-step restore with 32-byte random token + 5-min TTL ([`backup_service.py:80`](/Users/openclaw/dev/cyclone/backend/src/cyclone/backup_service.py)). SFTP password: account `sftp.gainwell.password` ([`secrets.py:13-16`](/Users/openclaw/dev/cyclone/backend/src/cyclone/secrets.py)). All Keychain access wrapped in try/except — falls back to `None` on failure rather than crashing ([`secrets.py:50-72`](/Users/openclaw/dev/cyclone/backend/src/cyclone/secrets.py)). `keyring` is an optional import ([`secrets.py:32-36`](/Users/openclaw/dev/cyclone/backend/src/cyclone/secrets.py)). |
|
||||||
|
| Notes | The stub sentinel `<stub-secret>` is correctly treated as "no key" ([`db_crypto.py:114-115`](/Users/openclaw/dev/cyclone/backend/src/cyclone/db_crypto.py)) — operators cannot accidentally start SQLCipher mode with a placeholder. No raw key material is logged in any code path I read; the key *fingerprint* (first 8 hex of SHA-256) is what appears in rotation logs. Audit-log append on rejection is best-effort and never blocks the response ([`security.py:344-369`](/Users/openclaw/dev/cyclone/backend/src/cyclone/security.py)). |
|
||||||
|
|
||||||
|
### 3.5 Performance
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Weak** |
|
||||||
|
| Severity | Medium |
|
||||||
|
| Evidence | `time uv run pytest -q --no-header` → `111 failed, 853 passed, 1 warning in 152.85s (0:02:32)`. Test framework: pytest 8.4.2 + pytest-asyncio (Mode.AUTO) + pytest-randomly + pytest-cov. Frontend: vitest 4.1.9 + happy-dom 20.10.6 + Puppeteer Core 25.2.0 (per [`package.json`](/Users/openclaw/dev/cyclone/package.json) devDependencies). CSS-reduction tooling at [`tools/css-*.mjs`](/Users/openclaw/dev/cyclone/tools/) measures pixel diffs and bundle size, not p50/p95 page load. |
|
||||||
|
| Notes | 964 backend tests in 152 s ≈ 158 ms/test. Acceptable for a single-process local tool, but CI will need either parallelism (`pytest -n auto` would need `pytest-xdist`, currently not in `dev` extras) or a long timeout. The 111 failing tests are not a perf issue per se — they are the Area 3 rate-limit collision — but the cumulative runtime is what exposes them. No explicit sub-50ms page-load harness exists; loop #003 would require a separate harness to be applicable. |
|
||||||
|
|
||||||
|
### 3.6 Deployment
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Proved** |
|
||||||
|
| Severity | Low (README install is slightly out of sync with current `uv` practice) |
|
||||||
|
| Evidence | [`backend/src/cyclone/__main__.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/__main__.py) lines 18–35 (binds `127.0.0.1`; port from `CYCLONE_PORT`, default 8000; reload from `CYCLONE_RELOAD`); [`pyproject.toml:9`](/Users/openclaw/dev/cyclone/backend/pyproject.toml) `requires-python = ">=3.11"`; [`pyproject.toml:34-43`](/Users/openclaw/dev/cyclone/backend/pyproject.toml) declares `sqlcipher` and `sftp` extras; [`uv.lock`](/Users/openclaw/dev/cyclone/backend/uv.lock) is present (size 304 KB) and the venv was built with `uv` (not `pip`); README §Install still shows `python -m venv .venv` + `pip install -e '.[dev]'`. No `Dockerfile`, no `docker-compose.yml`, no `Makefile`, no `pre-commit` config, no `ruff` config. |
|
||||||
|
| Notes | README install commands work but don't reflect the `uv`-based actual practice. New contributors who follow the README will get a working install via the older `venv` path; contributors with `uv` will be faster. Not a blocker. |
|
||||||
|
|
||||||
|
### 3.7 Scheduled jobs
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Proved** |
|
||||||
|
| Severity | Low |
|
||||||
|
| Evidence | MFT scheduler ([`scheduler.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/scheduler.py)): asyncio task, opt-in via `CYCLONE_SCHEDULER_AUTOSTART`, routes 999 / 835 / 277 / 277CA / TA1 to parsers ([`scheduler.py:76`](/Users/openclaw/dev/cyclone/backend/src/cyclone/scheduler.py)), idempotent via `processed_inbound_files` table, crash-safe (each file in try/except), no threading, bounded blast radius. Backup scheduler ([`backup_scheduler.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/backup_scheduler.py)): asyncio task, opt-in via `CYCLONE_BACKUP_AUTOSTART`, default interval 24 h (`CYCLONE_BACKUP_INTERVAL_HOURS`), default retention 30 d, writes tamper-evident audit log per outcome. PubSub ([`pubsub.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/pubsub.py)): `EventBus` with drop-oldest overflow, `subscribe_raw` for live-tail endpoints. Health snapshot ([`api_routers/health.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/api_routers/health.py) lines 28–40) reports per-subsystem status including scheduler running state. |
|
||||||
|
| Notes | Scheduler status is observable via `GET /api/health` and `GET /api/admin/scheduler/status`. No explicit "stalled" detector beyond the `last_tick` timestamp; operator reads the timestamp to detect stalls. Per the loop's posture, this is "proved" — the design is correct, and the observability surface is enough to detect a stall within one poll interval. |
|
||||||
|
|
||||||
|
### 3.8 Business logic (data path)
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Proved** |
|
||||||
|
| Severity | Low |
|
||||||
|
| Evidence | Parsers for 837P ([`parse_837.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/parsers/parse_837.py) 417 LOC + [`serialize_837.py`](/Users/openclaw/dev/cyclone/parsers/serialize_837.py) 627 LOC), 835 ([`parse_835.py`](/Users/openclaw/dev/cyclone/parsers/parse_835.py) 553 LOC; no outbound 835 serializer, intentional), 999 ([`parse_999.py`](/Users/openclaw/dev/cyclone/parsers/parse_999.py) 298 LOC + [`serialize_999.py`](/Users/openclaw/dev/cyclone/parsers/serialize_999.py) 279 LOC), TA1 ([`parse_ta1.py`](/Users/openclaw/dev/cyclone/parsers/parse_ta1.py) 186 LOC), 270 ([`parse_270.py`](/Users/openclaw/dev/cyclone/parsers/parse_270.py) 416 LOC + [`serialize_270.py`](/Users/openclaw/dev/cyclone/parsers/serialize_270.py) 324 LOC), 271 ([`parse_271.py`](/Users/openclaw/dev/cyclone/parsers/parse_271.py) 426 LOC), 277CA ([`parse_277ca.py`](/Users/openclaw/dev/cyclone/parsers/parse_277ca.py) 355 LOC + [`models_277ca.py`](/Users/openclaw/dev/cyclone/parsers/models_277ca.py) 161 LOC). CAS reason codes ([`cas_codes.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/parsers/cas_codes.py) 206 LOC). 7-state claim lifecycle. 5-lane inbox (Rejected / Payer-rejected / Candidates / Unmatched / Done today). Per-line 837 SV1 ↔ 835 SVC audit. Pure-function reconciliation ([`reconcile.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/reconcile.py) `match` + `apply_payment` + `apply_reversal` with 7-day default window). 4-field scoring ([`scoring.py:88`](/Users/openclaw/dev/cyclone/backend/src/cyclone/scoring.py) `score_pair`). |
|
||||||
|
| Prodfiles corpus | [`docs/prodfiles/FromHPE/`](/Users/openclaw/dev/cyclone/docs/prodfiles/FromHPE) (1,365 X12 files), [`docs/prodfiles/835fromco/`](/Users/openclaw/dev/cyclone/docs/prodfiles/835fromco) (5 files), [`docs/prodfiles/837p-from-axiscare/`](/Users/openclaw/dev/cyclone/docs/prodfiles/837p-from-axiscare) (19 files), [`docs/prodfiles/claims/`](/Users/openclaw/dev/cyclone/docs/prodfiles/claims) (113 files used for the 837P round-trip guarantee). |
|
||||||
|
| Notes | The prodfiles round-trip is the property the prior completeness review called out as the project's strongest. `test_prodfiles_smoke.py` (4 tests) directly exercises `FromHPE/` and `claims/` prodfiles. Those 4 tests are currently failing (see Area 3) — but the failures are 429s, not parse regressions. Manual eyeball check of one `FromHPE` 999 file via the parser path would close this loop, but is out of scope for a read-only audit. |
|
||||||
|
|
||||||
|
### 3.9 Code quality
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| Outcome | **Weak** |
|
||||||
|
| Severity | Medium |
|
||||||
|
| Evidence | 964 backend tests (vs 574 in the 2026-06-20 review; +67.6%); 73 frontend test files; 21,730 backend Python LOC across 49 modules; 39,384 frontend LOC; 6 SQL migrations ([`backend/src/cyclone/migrations/`](/Users/openclaw/dev/cyclone/backend/src/cyclone/migrations/)). God-modules: [`store.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/store.py) **2,423 LOC** (was 2,086 in the prior review; +337) and [`api.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/api.py) **3,548 LOC** (was "1,000+" in the prior review; **3.5×** growth). The `api_routers/` package exists with 5 small routers ([acks.py, admin.py, health.py, ta1_acks.py, __init__.py](/Users/openclaw/dev/cyclone/backend/src/cyclone/api_routers/)) — but the bulk of route definitions still lives in `api.py`. SP21 store split is in flight on `refactor/store-split` per REQUIREMENTS.md §2.1; not on `css-reduction`. No `.editorconfig`, no `.pre-commit-config.yaml`, no `Makefile`, no `ruff.toml` / `.ruff.toml`; `eslint` script exists in `package.json` but no `.eslintrc` config file visible. |
|
||||||
|
| Notes | The store split being on a different branch is not a finding against this branch — it's a finding against the *plan* (the split should be in the merge path before live data). The `api.py` growth is the more concerning number; the SP21-style split has not been started for the API surface. 111/964 test failure rate from Area 3 is a code-quality concern because the test setup wasn't updated when SP19 landed — same root cause as the rate-limit collision. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cross-cutting findings
|
||||||
|
|
||||||
|
### 4.1 The one blocking finding
|
||||||
|
|
||||||
|
**Rate-limit / pytest collision (§3.3) is the only thing standing between this branch and "ready for live data."** The fix is contained (a few lines in `conftest.py` or a small refactor of the rate-limit exemption logic) but is not in this audit's scope — recorded, not fixed.
|
||||||
|
|
||||||
|
### 4.2 What the prior review got right vs. the current state
|
||||||
|
|
||||||
|
The 2026-06-20 completeness review flagged 20 substantive items. Status on this branch:
|
||||||
|
|
||||||
|
| Prior finding (2026-06-20) | Status today (2026-06-23) | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| #1 No encryption at rest | **Resolved** by SP12 + SP15 | [`db_crypto.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/db_crypto.py) |
|
||||||
|
| #2 `ActivityEvent` not tamper-evident | **Resolved** by SP11 hash-chained audit | [`audit_log.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/audit_log.py) |
|
||||||
|
| #3 No backup automation | **Resolved** by SP17 | [`backup_service.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/backup_service.py), [`backup_scheduler.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/backup_scheduler.py) |
|
||||||
|
| #4 No request size / rate limits | **Resolved** by SP19 | [`security.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/security.py) |
|
||||||
|
| #5 No structured logging | **Resolved** by SP18 | [`logging_config.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/logging_config.py) |
|
||||||
|
| #6 No `pubs_timeout` exposed | Partial — `EventBus.max_queue_size` is configurable but no API knob | [`pubsub.py:23`](/Users/openclaw/dev/cyclone/backend/src/cyclone/pubsub.py) |
|
||||||
|
| #7 No 277CA | **Resolved** by SP10 | [`parse_277ca.py`](/Users/openclaw/dev/cyclone/parsers/parse_277ca.py), [`models_277ca.py`](/Users/openclaw/dev/cyclone/parsers/models_277ca.py), [`inbox_state_277ca.py`](/Users/openclaw/dev/cyclone/inbox_state_277ca.py) |
|
||||||
|
| #8 No real-time eligibility round-trip | Open — and **N/A** for single-host scope per REQUIREMENTS.md §2.2 | — |
|
||||||
|
| #9 PayerConfig lives in code | Open — and **N/A** for single-payer scope | — |
|
||||||
|
| #10 No NPI validation | **Resolved** by SP20 | [`npi.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/npi.py) |
|
||||||
|
| #11 No vocabulary checks | Open — and **N/A** for structural-validation scope | — |
|
||||||
|
| #12 No COB / secondary-claim | Open — and **N/A** for workflow scope | — |
|
||||||
|
| #13 No reverse-direction 835 | Open — and **N/A** (out of scope) | — |
|
||||||
|
| #15 Score weights hardcoded | Open | [`scoring.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/scoring.py) — same code, no change |
|
||||||
|
| #16 No config file persistence | Open | [`providers.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/providers.py), [`payers.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/payers.py) |
|
||||||
|
| #17 `store.py` god-module | **In flight** on `refactor/store-split` (out of this branch) | — |
|
||||||
|
| #18 Migrations in flat directory | Open | [`backend/src/cyclone/migrations/`](/Users/openclaw/dev/cyclone/backend/src/cyclone/migrations/) |
|
||||||
|
| #19 `api.py` monolithic | Open and **growing** (3,548 LOC, 3.5× the prior review) | [`api.py`](/Users/openclaw/dev/cyclone/backend/src/cyclone/api.py) |
|
||||||
|
| #20 README "what's next" missing | Open | [`README.md`](/Users/openclaw/dev/cyclone/README.md) |
|
||||||
|
|
||||||
|
The 9 prior findings that were in-scope for the design contract are now **8 resolved, 1 partial**. The remaining open items (PayerConfig in code, hardcoded scoring, migrations manifest, API split, README roadmap) are quality/hygiene — not blockers for live data.
|
||||||
|
|
||||||
|
### 4.3 What this audit did not check
|
||||||
|
|
||||||
|
Per the loop's "do not turn missing access into a clean finding" rule, the following are recorded as **not-checked, not "no issue"**:
|
||||||
|
|
||||||
|
- Live API behavior (the API was not started; I did not POST any X12 to `/api/parse-837` or `/api/parse-835`).
|
||||||
|
- Keychain contents (no `security find-generic-password` calls).
|
||||||
|
- SQLCipher open / `PRAGMA rekey` round-trip (no DB writes).
|
||||||
|
- Backup encrypt / decrypt / verify (no backups created).
|
||||||
|
- SFTP wire-up (no outbound connection).
|
||||||
|
- Visual UI rendering (no browser launched).
|
||||||
|
- TypeScript `tsc -b --noEmit` output.
|
||||||
|
- ESLint output.
|
||||||
|
- Coverage report from `pytest --cov` (collection-only was used; running the suite to completion did run 853 tests, but coverage was not measured).
|
||||||
|
|
||||||
|
These would each be a separate audit pass if you want them. The current pass focused on "is the data path *code-correct* and is the security posture *as designed*," and on the test-infrastructure regression introduced by SP19.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Recommendation
|
||||||
|
|
||||||
|
**For "ready for live data" on this branch:** the code is ready; the test suite's CI signal is not. Two-step gate:
|
||||||
|
|
||||||
|
1. **Fix the rate-limit / pytest collision (§3.3).** Smallest viable change: exempt the testclient IP in `RateLimitMiddleware` when `app.state.testing` is set, or reset the limiter between test files via a `conftest.py` autouse fixture. This is one branch of work; estimated diff is a few lines.
|
||||||
|
2. **Run the full suite to a clean pass.** Re-run `cd backend && uv run pytest -q` and confirm `0 failed`. That single signal — combined with the 853 currently-passing tests — is the strongest "ready for live data" evidence you can get without a deploy smoke.
|
||||||
|
|
||||||
|
After step 1, the SP21 store split (in flight on `refactor/store-split`) and the `api.py` split (not started) are the next-hygiene items. Neither blocks live data on a single-host local tool.
|
||||||
|
|
||||||
|
**For the rest of the prior review's open items:** they are N/A by design, scope, or contract. They should not be re-litigated as blockers for live data on this branch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Audit metadata
|
||||||
|
|
||||||
|
- **Branch:** `css-reduction` @ `c00e4c2a5a6bc6ba6b6e6fa8429fffce37965760`
|
||||||
|
- **Working tree:** modified (`package.json`, `package-lock.json`), untracked (`audit-uiux-extended.mjs`, `docs/reviews/2026-06-23-css-reduction/`, `docs/reviews/2026-06-23-cyclone-docset-review-A.md`, `docs/reviews/2026-06-23-cyclone-docset-review-B.md`, `tools/`). No uncommitted changes to `backend/` were observed.
|
||||||
|
- **Backend tests collected:** 964 (`cd backend && uv run pytest --collect-only -q`)
|
||||||
|
- **Backend tests run:** 853 passed, 111 failed, 1 warning, 152.85 s
|
||||||
|
- **Backend Python LOC:** 21,730 (49 modules)
|
||||||
|
- **Frontend LOC:** 39,384
|
||||||
|
- **Migrations:** 6 (PRAGMA user_version 6)
|
||||||
|
- **Files read for this audit:** 13 (see §3 evidence column)
|
||||||
|
- **Shell commands run:** 4 (`uv run pytest --collect-only -q`, `time uv run pytest -q --noEmit`, `wc -l` aggregates, 2× `curl` to loop-library catalog)
|
||||||
|
- **Destructive commands run:** 0
|
||||||
|
- **Keychain access:** 0
|
||||||
|
- **API calls made:** 0
|
||||||
|
- **Code, config, or production state changes:** 0
|
||||||
|
|
||||||
|
— *End of audit.*
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,70 @@
|
|||||||
|
# Auth Posture Alignment Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use
|
||||||
|
> superpowers:subagent-driven-development (recommended) or
|
||||||
|
> superpowers:executing-plans to implement this plan task-by-task. Steps
|
||||||
|
> use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Reconcile `CLAUDE.md`, `docs/REQUIREMENTS.md`, and `docs/ARCHITECTURE.md` with the auth work that landed in `main` on 2026-06-23, and add a loud `AUTH_DISABLED` startup warning so a misconfigured production deploy fails loudly.
|
||||||
|
|
||||||
|
**Architecture:** Docs-only + one 6-line `__main__.py` edit + three skills addenda. No code paths change; the auth code already in `main` is the source of truth.
|
||||||
|
|
||||||
|
**Tech Stack:** Markdown, Python `logging`, existing `cyclone.auth.deps.AUTH_DISABLED` flag.
|
||||||
|
|
||||||
|
**Spec:** [`docs/superpowers/specs/2026-06-23-cyclone-auth-posture-alignment-design.md`](../specs/2026-06-23-cyclone-auth-posture-alignment-design.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
Modified files:
|
||||||
|
|
||||||
|
- `CLAUDE.md` (3 lines: 7, 97, 182)
|
||||||
|
- `docs/REQUIREMENTS.md` (3 NFR / scope lines + §11 R-1 row + §6.1 SP24 row)
|
||||||
|
- `docs/ARCHITECTURE.md` (2 lines: 16, 703 + 1 new "Auth" subsection + migrations table note)
|
||||||
|
- `backend/src/cyclone/__main__.py` (startup warning when `AUTH_DISABLED`)
|
||||||
|
- `.superpowers/skills/cyclone-tests/SKILL.md` (addendum)
|
||||||
|
- `.superpowers/skills/cyclone-api-router/SKILL.md` (addendum)
|
||||||
|
- `.superpowers/skills/cyclone-spec/SKILL.md` (addendum)
|
||||||
|
|
||||||
|
## Task 0: Mark spec in implementation
|
||||||
|
|
||||||
|
- [ ] Spec header `Status: Draft, awaiting user sign-off` → `Status: Approved (in implementation on sp24-doc-posture-alignment)`.
|
||||||
|
|
||||||
|
## Task 1: `CLAUDE.md`
|
||||||
|
|
||||||
|
- [ ] Line 7 — replace "Local-only by design: binds to `127.0.0.1`, no auth, no internet exposure." with the v1 posture: "Local-only by design: binds to `127.0.0.1`, requires login (auth boundary is HTTP; file-system protection unchanged), no internet exposure."
|
||||||
|
- [ ] Line 97 — update SP numbering to reflect "SP numbers are used through **SP22**; **SP23** is the Ubuntu+Docker fork (awaiting user decision); this increment is **SP24**."
|
||||||
|
- [ ] Line 182 — replace the "Local-only by design. The backend binds to `127.0.0.1`, has no auth…" bullet with the new posture (login required; auth boundary is HTTP; SQLCipher + Keychain unchanged; don't add internet exposure).
|
||||||
|
|
||||||
|
## Task 2: `docs/REQUIREMENTS.md`
|
||||||
|
|
||||||
|
- [ ] Line 36 — replace "Local-only on purpose: binds `127.0.0.1`, no auth, no internet exposure, single operator, single machine, one trading partner (Colorado Medicaid, currently)." with the v1 posture.
|
||||||
|
- [ ] Line 39 — replace "Threat model: a process on the same host. No second party to authenticate; no second host to harden against." with the new threat model (process on same host + HTTP-layer login; SQLCipher + Keychain handle file-system threats; SP23 changes the model to LAN).
|
||||||
|
- [ ] Line 154 (NFR-1) — replace "no auth, no API key" with "login required (bcrypt + HttpOnly session cookie; first admin bootstrapped from env vars `CYCLONE_ADMIN_USERNAME` + `CYCLONE_ADMIN_PASSWORD`); dev/test escape hatch via `CYCLONE_AUTH_DISABLED=1`."
|
||||||
|
- [ ] §11 R-1 row — change "**Open** — addressed only if/when SP23 ships." to "**Closed by SP24** — auth shipped via the merge of `origin/main` on 2026-06-23 (commits `a25504b`..`39ae988`); SP24 reconciled the docs to match. SP23 still covers the LAN-bind / Docker / RBAC product fork."
|
||||||
|
- [ ] §6.1 — add an SP24 row pointing at this plan + the spec.
|
||||||
|
|
||||||
|
## Task 3: `docs/ARCHITECTURE.md`
|
||||||
|
|
||||||
|
- [ ] Line 16 — replace "has no authentication because the threat model is a stolen or imaged drive, not a remote attacker." with the new posture.
|
||||||
|
- [ ] Line 703 — replace "Multi-user / multi-host — no auth, no LAN-bind, no Docker, no reverse proxy. The operator is one person, the host is one machine. (SP23 fork if this changes.)" with the new posture (login required; SP23 covers LAN-bind / Docker / RBAC fork).
|
||||||
|
- [ ] Add a new "### Auth" subsection under §5.1 listing the `cyclone.auth/` package contents and the `matrix_gate` dependency contract.
|
||||||
|
- [ ] Migrations table — add a note that 0013 + 0014 are auth migrations and explain the renumbering on `claims-unique-fix` (0013/0014 → 0015/0016) to keep the auth migrations at the front.
|
||||||
|
|
||||||
|
## Task 4: `backend/src/cyclone/__main__.py`
|
||||||
|
|
||||||
|
- [ ] After `bootstrap.run()`, check `cyclone.auth.deps.AUTH_DISABLED` and emit a `WARNING` via `logging.getLogger("cyclone")` if True. The warning text must include the words "AUTH_DISABLED" and "dev only" so a misconfigured production deploy is greppable from logs.
|
||||||
|
|
||||||
|
## Task 5: Skills addenda
|
||||||
|
|
||||||
|
- [ ] `cyclone-tests` — add a paragraph noting the conftest's `AUTH_DISABLED` flip is mandatory context for any new test.
|
||||||
|
- [ ] `cyclone-api-router` — add a paragraph noting every router needs `Depends(matrix_gate)` and the role matrix is in `cyclone.auth.permissions`.
|
||||||
|
- [ ] `cyclone-spec` — add a paragraph noting the spec template's "threat model" example needs to be auth-aware (don't say "no second party to authenticate" any more; reference this SP as the reference pattern).
|
||||||
|
|
||||||
|
## Task 6: Verification
|
||||||
|
|
||||||
|
- [ ] `grep -nE 'no auth|no authentication|no second party' CLAUDE.md docs/REQUIREMENTS.md docs/ARCHITECTURE.md` returns no false-positive matches.
|
||||||
|
- [ ] `cd backend && .venv/bin/python -c "import cyclone.__main__"` doesn't crash.
|
||||||
|
- [ ] `cd backend && .venv/bin/python -c "import cyclone; from cyclone.auth import deps; print(deps.AUTH_DISABLED)"` prints `False` (default).
|
||||||
|
- [ ] `cd backend && CYCLONE_AUTH_DISABLED=1 .venv/bin/python -c "from cyclone.auth.bootstrap import run; run(); from cyclone.auth.deps import AUTH_DISABLED; print(AUTH_DISABLED)"` prints `True` (env var flips the flag).
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,798 @@
|
|||||||
|
# Parse → Detect → Decide: 837P/835 Upload Workflow
|
||||||
|
|
||||||
|
**Date:** 2026-06-21
|
||||||
|
**Branch:** `claims-unique-fix` (worktree)
|
||||||
|
**Status:** Draft (brainstorming approved, awaiting writing-plans)
|
||||||
|
**Supersedes:** `2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` (kept for the migration 0013 + store helper sections, which still apply).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Why this exists
|
||||||
|
|
||||||
|
Today's upload flow is "parse → validate → persist" in a single call.
|
||||||
|
When a claim's CLM01 collides with a prior batch, the persist raises
|
||||||
|
`IntegrityError`, the transaction rolls back, and the API returns 409
|
||||||
|
with **no parse result, no list of colliding claims, and no way to act**.
|
||||||
|
The user sees only an error message and a `batch_id` that doesn't exist.
|
||||||
|
|
||||||
|
This is wrong: the parse already happened. The user should see what was
|
||||||
|
parsed, see which claims collide with which prior batches, and decide
|
||||||
|
what to do (force-insert, delete the prior batch, or pick a different
|
||||||
|
file). The 409 response body today is too thin to make that decision.
|
||||||
|
|
||||||
|
The root cause of the 409s is a schema bug — see
|
||||||
|
`2026-06-21-cyclone-claims-unique-constraint-and-409-ux-design.md` §1.
|
||||||
|
Migration 0013 drops the `UNIQUE(batch_id, patient_control_number)` inline
|
||||||
|
constraint that 0003 was supposed to drop. After 0013 lands, **multi-claim
|
||||||
|
837P files where many CLM segments share a subscriber's `member_id` will
|
||||||
|
ingest cleanly for the first time**.
|
||||||
|
|
||||||
|
But 0013 alone is not enough. The current schema has `claims.id` and
|
||||||
|
`remittances.id` as single-column PRIMARY KEYs, which means the same
|
||||||
|
CLM01 cannot exist in two different batches. That makes "cross-batch
|
||||||
|
CLM01 collisions" impossible to express in the data — but it also makes
|
||||||
|
resubmits impossible, and it makes the 409-with-collision-summary workflow
|
||||||
|
this SP describes unreachable. **Migration 0014 (added as Task 1.3 to the
|
||||||
|
plan) relaxes the PKs to composite `(batch_id, id)`.** After 0014 lands,
|
||||||
|
real resubmits are representable, the pre-flight 409 path actually fires,
|
||||||
|
and the workflow defined below is exercisable end-to-end against real data.
|
||||||
|
|
||||||
|
This SP defines the workflow for both classes of collision:
|
||||||
|
1. Multi-claim files with shared `member_id` (no longer a 409 after 0013).
|
||||||
|
2. Files where one or more CLM01s exist in a prior batch (a 409 after 0014; this SP defines the UX for it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Operator surface
|
||||||
|
|
||||||
|
| Surface | Change |
|
||||||
|
|---|---|
|
||||||
|
| Backend | Pre-flight dedup check in `parse_837` and `parse_835`. New `?force=true` query param. New `DELETE /api/batches/{id}` endpoint. 409 body shape changes. |
|
||||||
|
| Frontend | `Upload.tsx` panel renders the full parse result + collision summary, with actions: "Force insert (skip dups)", "Open prior batch", "Delete prior batch and retry", "Pick a different file". |
|
||||||
|
| Tests | Migration + store helpers (already done in `claims-unique-fix`). New tests for: pre-flight dedup, force-insert, within-file dup, race 409, DELETE endpoint, frontend panel. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The workflow
|
||||||
|
|
||||||
|
### 3.1 No collision (the happy path)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → POST /api/parse-837 (file)
|
||||||
|
← 200 + ParseResult + batch_id
|
||||||
|
Batch persisted. UI shows parsed claims and links to the new batch.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Collision (the new path)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → POST /api/parse-837 (file)
|
||||||
|
← 409 + {
|
||||||
|
error: "Duplicate claim",
|
||||||
|
detail: "...",
|
||||||
|
existing_batch_id: "B123", # most-recent prior batch with a colliding CLM01
|
||||||
|
collisions: {
|
||||||
|
colliding_claim_ids: ["A", "B"],
|
||||||
|
total_collisions: 2,
|
||||||
|
total_claims: 141, # claims in the file
|
||||||
|
new_claims_after_skip: 139, # claims that WOULD be inserted on force
|
||||||
|
},
|
||||||
|
parse_result: { ... full ParseResult ... },
|
||||||
|
}
|
||||||
|
User sees the parse result in the panel.
|
||||||
|
User can:
|
||||||
|
- Click "Force insert (skip 2 dups)" → POST /api/parse-837?force=true (same file)
|
||||||
|
← 200 + ParseResult + { skipped_claim_ids: ["A", "B"], inserted: 139 }
|
||||||
|
- Click "Open prior batch" → navigate to /batches/B123
|
||||||
|
- Click "Delete prior batch" → DELETE /api/batches/B123, then click "Re-upload"
|
||||||
|
- Click "Pick a different file" → clear the upload state
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Force-insert after collision
|
||||||
|
|
||||||
|
`force=true` skips the pre-flight check. The store's existing per-row
|
||||||
|
`s.get(Claim, claim_id)` dedup still skips colliding rows silently, so
|
||||||
|
the new batch persists with only the non-colliding claims. The response
|
||||||
|
body includes `skipped_claim_ids` so the UI can show what was skipped.
|
||||||
|
|
||||||
|
`force=true` does NOT bypass the parser. If the file fails validation
|
||||||
|
(missing diagnosis, malformed segment), the response is still 422.
|
||||||
|
|
||||||
|
### 3.4 Race condition (pre-flight clean, persist fails)
|
||||||
|
|
||||||
|
If a pre-flight dedup check finds no collisions, but a concurrent process
|
||||||
|
ingests a colliding CLM01 between the check and the persist, the persist
|
||||||
|
will still raise `IntegrityError`. The handler catches it and returns
|
||||||
|
**the same 409 shape as the pre-flight collision** with
|
||||||
|
`existing_batch_id` set to the racing batch and `detail` mentioning
|
||||||
|
"another process ingested this between the check and the persist —
|
||||||
|
re-upload to retry". The user re-runs the same flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Within-file duplicates
|
||||||
|
|
||||||
|
If the file itself has the same CLM01 twice (a malformed file, not a
|
||||||
|
cross-batch collision), the pre-flight check catches it the same way:
|
||||||
|
it returns 409 with `existing_batch_id: null` and `detail: "CLM01 A
|
||||||
|
appears twice in this file"`. The user can only force-insert (which
|
||||||
|
skips the second instance). They can't "delete the prior batch" because
|
||||||
|
there isn't one — it's a bad file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The 409 body shape
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "Duplicate claim",
|
||||||
|
"detail": "This file (or one previously ingested with the same claim control number) collides with an existing record. 2 of 141 claims collide with batch B123.",
|
||||||
|
"batch_id": null,
|
||||||
|
"existing_batch_id": "B123",
|
||||||
|
"collisions": {
|
||||||
|
"colliding_claim_ids": ["A", "B"],
|
||||||
|
"total_collisions": 2,
|
||||||
|
"total_claims": 141,
|
||||||
|
"new_claims_after_skip": 139
|
||||||
|
},
|
||||||
|
"parse_result": { ... full ParseResult ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field semantics:
|
||||||
|
- `error`: short tag for the UI ("Duplicate claim", "Duplicate remittance", "Within-file duplicate CLM01").
|
||||||
|
- `detail`: human-readable, mentions the count and the existing batch when known.
|
||||||
|
- `batch_id`: always `null` on 409 (the insert rolled back).
|
||||||
|
- `existing_batch_id`: the most-recent prior batch that contains a colliding CLM01, or `null` if (a) the collision is within-file, or (b) the colliding claim has since been deleted (race).
|
||||||
|
- `collisions.colliding_claim_ids`: subset of `parse_result.claims[].claim_id` that collides.
|
||||||
|
- `collisions.total_claims`: count from `parse_result.summary.total_claims`.
|
||||||
|
- `collisions.new_claims_after_skip`: `total_claims - total_collisions`.
|
||||||
|
- `parse_result`: the full `ParseResult` (same shape as a 200 response body). The UI uses this to render the parsed claims list.
|
||||||
|
|
||||||
|
The 200 body on `force=true` adds `skipped_claim_ids: ["A", "B"]` at the top level so the UI can show a "skipped" badge per claim.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `DELETE /api/batches/{id}`
|
||||||
|
|
||||||
|
New endpoint. Cascades through `ON DELETE CASCADE` FKs:
|
||||||
|
|
||||||
|
```
|
||||||
|
batches ─┬─ claims ─┬─ matches
|
||||||
|
│ ├─ activity_events (claim_id)
|
||||||
|
│ └─ line_reconciliations
|
||||||
|
├─ remittances ─┬─ cas_adjustments
|
||||||
|
│ ├─ service_line_payments
|
||||||
|
│ └─ activity_events (remittance_id)
|
||||||
|
└─ activity_events (batch_id only)
|
||||||
|
```
|
||||||
|
|
||||||
|
FKs already declare `ON DELETE CASCADE` in the migrations, so the
|
||||||
|
SQLite engine handles the cascade. The endpoint just needs to
|
||||||
|
`session.delete(batch_row)` and commit.
|
||||||
|
|
||||||
|
The endpoint:
|
||||||
|
- `204 No Content` on success.
|
||||||
|
- `404 Not Found` if the batch doesn't exist.
|
||||||
|
- `409 Conflict` if the batch has any claims in a non-`submitted` state
|
||||||
|
(e.g., `paid`, `reversed`, `denied`). Forces the user to first
|
||||||
|
unreconcile — same as the existing 409 pattern for `manual_match` /
|
||||||
|
`manual_unmatch` (see `store.py:AlreadyMatchedError`).
|
||||||
|
|
||||||
|
A `batch_deleted` activity event is recorded before the delete so the
|
||||||
|
audit log has a tombstone. The event's `batch_id` will be `null` after
|
||||||
|
the cascade (the FK is to `batches.id` with no `ON DELETE` clause
|
||||||
|
specified in any migration; verify in `migrations/0001_initial.sql` —
|
||||||
|
the spec says we preserve audit history). If the FK is `ON DELETE
|
||||||
|
CASCADE`, we record the event AFTER the cascade with `batch_id` set to
|
||||||
|
the deleted id and rely on the cascade to remove it (acceptable, or we
|
||||||
|
use a no-cascade FK and keep the tombstone). **Open question resolved
|
||||||
|
during implementation by reading the actual FK clauses.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Backend implementation
|
||||||
|
|
||||||
|
### 7.1 New dedup helper
|
||||||
|
|
||||||
|
`backend/src/cyclone/store.py` (already added in `claims-unique-fix`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def find_existing_batch_for_claim(claim_id: str) -> str | None:
|
||||||
|
"""Return the batch_id of the first batch containing this claim id, or None.
|
||||||
|
|
||||||
|
Pure read; opens a short-lived session. Used by the 837 409 handler to
|
||||||
|
surface which prior batch already holds the same CLM01.
|
||||||
|
|
||||||
|
Returns the most-recent batch (ORDER BY parsed_at DESC LIMIT 1) so the
|
||||||
|
UI links to the most likely "where did the dup come from" answer.
|
||||||
|
"""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Claim
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Claim.batch_id)
|
||||||
|
.where(Claim.id == claim_id)
|
||||||
|
.order_by(Claim.state_changed_at.desc()) # most-recent touch
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def find_existing_batch_for_remit(remit_id: str) -> str | None:
|
||||||
|
"""Same shape as find_existing_batch_for_claim but for remittances."""
|
||||||
|
from sqlalchemy import select
|
||||||
|
from cyclone.db import Remittance
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
row = s.execute(
|
||||||
|
select(Remittance.batch_id)
|
||||||
|
.where(Remittance.id == remit_id)
|
||||||
|
.order_by(Remittance.received_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
return row[0] if row else None
|
||||||
|
```
|
||||||
|
|
||||||
|
The current `claims-unique-fix` implementation uses
|
||||||
|
`select(Claim.batch_id).where(Claim.id == claim_id).limit(1)` without
|
||||||
|
`ORDER BY`. We replace it with the ordered version to satisfy
|
||||||
|
"return the most-recent colliding batch".
|
||||||
|
|
||||||
|
### 7.2 New pre-flight dedup check
|
||||||
|
|
||||||
|
`backend/src/cyclone/dedup.py` (new file, single responsibility):
|
||||||
|
|
||||||
|
```python
|
||||||
|
"""Pre-flight dedup for parsed 837P/835 batches.
|
||||||
|
|
||||||
|
Splits the parsed result into "would-insert" and "would-skip" sets by
|
||||||
|
querying the DB for any claim_id / payer_claim_control_number already
|
||||||
|
present. Also detects within-file duplicates by counting claim_id
|
||||||
|
frequencies.
|
||||||
|
|
||||||
|
Used by the parse-837 and parse-835 endpoints between validation and
|
||||||
|
persist, so the user can see the parse result + collision summary
|
||||||
|
before any DB write.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from cyclone import db
|
||||||
|
from cyclone.db import Claim, Remittance
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CollisionReport:
|
||||||
|
"""What the parse endpoint needs to render a 409 response."""
|
||||||
|
colliding_claim_ids: list[str] # CLM01s (837) or CLP01s (835)
|
||||||
|
existing_batch_id: str | None # most-recent prior batch with a collision, or None
|
||||||
|
within_file_duplicate_ids: list[str] # CLM01s appearing twice in this file (subset of colliding_claim_ids)
|
||||||
|
total_claims: int
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_837(result, session: Session | None = None) -> CollisionReport:
|
||||||
|
"""Detect 837 collisions: within-file dupes + cross-batch CLM01 dupes."""
|
||||||
|
claim_ids = [c.claim_id for c in result.claims]
|
||||||
|
counts = Counter(claim_ids)
|
||||||
|
within_file_duplicate_ids = sorted(
|
||||||
|
cid for cid, n in counts.items() if n > 1
|
||||||
|
)
|
||||||
|
seen: set[str] = set(claim_ids)
|
||||||
|
if not seen:
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=[],
|
||||||
|
existing_batch_id=None,
|
||||||
|
within_file_duplicate_ids=[],
|
||||||
|
total_claims=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = db.SessionLocal()()
|
||||||
|
try:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Claim.id, Claim.batch_id)
|
||||||
|
.where(Claim.id.in_(seen))
|
||||||
|
.order_by(Claim.state_changed_at.desc())
|
||||||
|
).all()
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
db_collisions = {cid: bid for cid, bid in rows}
|
||||||
|
colliding = sorted(cid for cid in seen if cid in db_collisions)
|
||||||
|
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=colliding,
|
||||||
|
existing_batch_id=existing_batch_id,
|
||||||
|
within_file_duplicate_ids=within_file_duplicate_ids,
|
||||||
|
total_claims=len(claim_ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preflight_835(result, session: Session | None = None) -> CollisionReport:
|
||||||
|
"""Same shape for 835 remittances. Payer claim control number = CLP01 = remittance.id."""
|
||||||
|
pcns = [c.payer_claim_control_number for c in result.claims]
|
||||||
|
counts = Counter(pcns)
|
||||||
|
within_file_duplicate_ids = sorted(p for p, n in counts.items() if n > 1)
|
||||||
|
seen = set(pcns)
|
||||||
|
if not seen:
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=[],
|
||||||
|
existing_batch_id=None,
|
||||||
|
within_file_duplicate_ids=[],
|
||||||
|
total_claims=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
own_session = session is None
|
||||||
|
if own_session:
|
||||||
|
session = db.SessionLocal()()
|
||||||
|
try:
|
||||||
|
rows = session.execute(
|
||||||
|
select(Remittance.id, Remittance.batch_id)
|
||||||
|
.where(Remittance.id.in_(seen))
|
||||||
|
.order_by(Remittance.received_at.desc())
|
||||||
|
).all()
|
||||||
|
finally:
|
||||||
|
if own_session:
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
db_collisions = {pcn: bid for pcn, bid in rows}
|
||||||
|
colliding = sorted(pcn for pcn in seen if pcn in db_collisions)
|
||||||
|
existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
|
||||||
|
return CollisionReport(
|
||||||
|
colliding_claim_ids=colliding,
|
||||||
|
existing_batch_id=existing_batch_id,
|
||||||
|
within_file_duplicate_ids=within_file_duplicate_ids,
|
||||||
|
total_claims=len(pcns),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Modified `parse_837` endpoint
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.post("/api/parse-837")
|
||||||
|
async def parse_837(
|
||||||
|
request: Request,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
payer: str = Query("co_medicaid"),
|
||||||
|
include_raw_segments: bool = Query(True),
|
||||||
|
strict: bool = Query(False),
|
||||||
|
ack: bool = Query(False),
|
||||||
|
force: bool = Query(False), # NEW
|
||||||
|
) -> Any:
|
||||||
|
# ... existing parse + validate ...
|
||||||
|
|
||||||
|
if _has_claim_validation_errors(result):
|
||||||
|
return JSONResponse(status_code=422, content=json.loads(result.model_dump_json()))
|
||||||
|
|
||||||
|
# NEW: pre-flight dedup check
|
||||||
|
if not force and result.claims:
|
||||||
|
report = dedup.preflight_837(result)
|
||||||
|
if report.colliding_claim_ids or report.within_file_duplicate_ids:
|
||||||
|
return _build_409_response(
|
||||||
|
result=result,
|
||||||
|
report=report,
|
||||||
|
error="Duplicate claim",
|
||||||
|
kind="cross_batch" if report.existing_batch_id else "within_file",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist (existing path). On IntegrityError (race), same 409 shape.
|
||||||
|
rec = BatchRecord(
|
||||||
|
id=uuid.uuid4().hex,
|
||||||
|
kind="837p",
|
||||||
|
input_filename=file.filename or "upload.txt",
|
||||||
|
parsed_at=utcnow(),
|
||||||
|
result=result,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
store.add(rec, event_bus=request.app.state.event_bus)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
# Race: pre-flight said clean, but persist hit a PK. Re-run pre-flight
|
||||||
|
# so the 409 body has the same shape.
|
||||||
|
report = dedup.preflight_837(result)
|
||||||
|
return _build_409_response(
|
||||||
|
result=result,
|
||||||
|
report=report,
|
||||||
|
error="Duplicate claim (race condition)",
|
||||||
|
kind="race",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ... existing response ...
|
||||||
|
if _client_wants_json(request):
|
||||||
|
body = json.loads(result.model_dump_json())
|
||||||
|
if ack:
|
||||||
|
ack_body = _build_and_persist_ack(rec.id)
|
||||||
|
if ack_body is not None:
|
||||||
|
body["ack"] = ack_body
|
||||||
|
# If force=true, the store.add silently skipped some claims.
|
||||||
|
# Surface what was skipped so the UI can show a "skipped" badge.
|
||||||
|
if force:
|
||||||
|
body["skipped_claim_ids"] = sorted({
|
||||||
|
c.claim_id for c in result.claims
|
||||||
|
if _claim_skipped(c.claim_id, rec.id)
|
||||||
|
})
|
||||||
|
return JSONResponse(content=body)
|
||||||
|
# ... streaming response ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Where:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _build_409_response(
|
||||||
|
result, report, error: str, kind: str
|
||||||
|
) -> JSONResponse:
|
||||||
|
"""Build the standard 409 body for any dedup failure."""
|
||||||
|
if kind == "cross_batch":
|
||||||
|
detail = (
|
||||||
|
f"{len(report.colliding_claim_ids)} of {report.total_claims} "
|
||||||
|
f"claims collide with prior batch {report.existing_batch_id}. "
|
||||||
|
f"Force-insert to skip the duplicates, or delete the prior batch."
|
||||||
|
)
|
||||||
|
elif kind == "within_file":
|
||||||
|
detail = (
|
||||||
|
f"CLM01(s) {', '.join(report.within_file_duplicate_ids)} appear "
|
||||||
|
f"twice in this file. Force-insert will keep the first occurrence "
|
||||||
|
f"and skip the rest."
|
||||||
|
)
|
||||||
|
else: # race
|
||||||
|
detail = (
|
||||||
|
f"Another process ingested a colliding batch between the check "
|
||||||
|
f"and the persist. Re-upload to retry with the latest state."
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"error": error,
|
||||||
|
"detail": detail,
|
||||||
|
"batch_id": None,
|
||||||
|
"existing_batch_id": report.existing_batch_id,
|
||||||
|
"collisions": {
|
||||||
|
"colliding_claim_ids": report.colliding_claim_ids,
|
||||||
|
"total_collisions": len(report.colliding_claim_ids),
|
||||||
|
"total_claims": report.total_claims,
|
||||||
|
"new_claims_after_skip": report.total_claims - len(report.colliding_claim_ids),
|
||||||
|
},
|
||||||
|
"parse_result": json.loads(result.model_dump_json()),
|
||||||
|
}
|
||||||
|
return JSONResponse(status_code=409, content=body)
|
||||||
|
```
|
||||||
|
|
||||||
|
`force=true` does NOT bypass validation (still 422 for bad data). It
|
||||||
|
only bypasses the pre-flight dedup. The `store.add` dedup still skips
|
||||||
|
colliding claims silently, but the response surfaces the skip list.
|
||||||
|
|
||||||
|
### 7.4 Modified `parse_835` endpoint
|
||||||
|
|
||||||
|
Same pattern, with `dedup.preflight_835` and the 835 parse result. Not
|
||||||
|
shown in detail; the structure mirrors 837.
|
||||||
|
|
||||||
|
### 7.5 New `DELETE /api/batches/{id}`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.delete("/api/batches/{batch_id}")
|
||||||
|
def delete_batch(batch_id: str) -> dict:
|
||||||
|
"""Hard-delete a batch and all its child rows.
|
||||||
|
|
||||||
|
Returns 204 on success, 404 if missing, 409 if the batch has any
|
||||||
|
claims/remits in a non-`submitted` state (must unreconcile first).
|
||||||
|
"""
|
||||||
|
from cyclone import db
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
batch = s.get(db.Batch, batch_id)
|
||||||
|
if batch is None:
|
||||||
|
raise HTTPException(404, f"Batch {batch_id} not found")
|
||||||
|
# Refuse if any claim/remittance is past 'submitted' state
|
||||||
|
non_submitted = s.execute(
|
||||||
|
select(db.Claim.id)
|
||||||
|
.where(db.Claim.batch_id == batch_id)
|
||||||
|
.where(db.Claim.state != "submitted")
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
if non_submitted is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
409,
|
||||||
|
f"Batch {batch_id} has claims in non-submitted state; "
|
||||||
|
f"unreconcile first before deleting.",
|
||||||
|
)
|
||||||
|
# Record tombstone activity event before the cascade
|
||||||
|
s.add(db.ActivityEvent(
|
||||||
|
ts=utcnow(),
|
||||||
|
kind="batch_deleted",
|
||||||
|
batch_id=batch_id,
|
||||||
|
payload_json={"message": f"Batch {batch_id} deleted"},
|
||||||
|
))
|
||||||
|
s.flush()
|
||||||
|
s.delete(batch)
|
||||||
|
s.commit()
|
||||||
|
return {"ok": True, "batch_id": batch_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
The FKs in the schema (`migrations/0001_initial.sql` and later) declare
|
||||||
|
`ON DELETE CASCADE` on `claims.batch_id`, `remittances.batch_id`, etc.
|
||||||
|
SQLite handles the cascade at the engine level. We verify this assumption
|
||||||
|
in the implementation test by deleting a batch with child rows and
|
||||||
|
asserting the child rows are gone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend
|
||||||
|
|
||||||
|
### 8.1 `src/lib/api.ts`
|
||||||
|
|
||||||
|
`ApiError` carries more collision data:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public status: number,
|
||||||
|
message: string,
|
||||||
|
public existingBatchId: string | null = null,
|
||||||
|
public collisions: CollisionSummary | null = null,
|
||||||
|
public parseResult: unknown = null,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CollisionSummary = {
|
||||||
|
colliding_claim_ids: string[];
|
||||||
|
total_collisions: number;
|
||||||
|
total_claims: number;
|
||||||
|
new_claims_after_skip: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`parse837` adds `?force=true` to the URL when called for the
|
||||||
|
"force-insert" action:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function parse837(
|
||||||
|
file: File,
|
||||||
|
options: { onProgress?: (p: number) => void; force?: boolean } = {},
|
||||||
|
): Promise<ParseResult> {
|
||||||
|
const url = `${base}/api/parse-837${options.force ? "?force=true" : ""}`;
|
||||||
|
// ... existing fetch + body parse ...
|
||||||
|
if (!res.ok) {
|
||||||
|
const { message, existingBatchId, collisions, parseResult } = await readErrorBody(res);
|
||||||
|
throw new ApiError(res.status, message, existingBatchId, collisions, parseResult);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 `src/pages/Upload.tsx`
|
||||||
|
|
||||||
|
New state:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type UploadError = {
|
||||||
|
kind: "duplicate";
|
||||||
|
existingBatchId: string | null;
|
||||||
|
collisions: CollisionSummary;
|
||||||
|
parseResult: ParseResult;
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
|
const [uploadError, setUploadError] = useState<UploadError | null>(null);
|
||||||
|
const [forceInserting, setForceInserting] = useState(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel JSX (above the streaming results):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{uploadError ? (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-destructive/40 bg-destructive/5 p-4 mx-auto max-w-3xl"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
|
||||||
|
409
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{uploadError.collisions.total_collisions} of {uploadError.collisions.total_claims} claims
|
||||||
|
collide
|
||||||
|
{uploadError.existingBatchId
|
||||||
|
? ` with batch ${uploadError.existingBatchId}`
|
||||||
|
: " within this file"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
File <span className="font-mono">{uploadError.filename}</span> would persist
|
||||||
|
{" "}{uploadError.collisions.new_claims_after_skip} of {uploadError.collisions.total_claims} claims.
|
||||||
|
Colliding CLM01s: {uploadError.collisions.colliding_claim_ids.join(", ")}.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
disabled={forceInserting}
|
||||||
|
onClick={async () => {
|
||||||
|
setForceInserting(true);
|
||||||
|
try {
|
||||||
|
// re-call with force=true; the response will be 200 + skipped_claim_ids
|
||||||
|
const result = await parse837(file, { onProgress: () => {}, force: true });
|
||||||
|
setParseResult(result);
|
||||||
|
setUploadError(null);
|
||||||
|
toast.success(
|
||||||
|
`Force-inserted: ${result.summary.total_claims - (result.skipped_claim_ids?.length ?? 0)} of ${result.summary.total_claims} claims (skipped ${result.skipped_claim_ids?.length ?? 0} dups)`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Force-insert failed");
|
||||||
|
} finally {
|
||||||
|
setForceInserting(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Force insert (skip {uploadError.collisions.total_collisions} dups)
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{uploadError.existingBatchId ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}>
|
||||||
|
Open prior batch
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm(`Delete batch ${uploadError.existingBatchId}? This cannot be undone.`)) return;
|
||||||
|
await deleteBatch(uploadError.existingBatchId);
|
||||||
|
toast.success(`Deleted ${uploadError.existingBatchId}`);
|
||||||
|
setUploadError(null);
|
||||||
|
pickFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete prior batch
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Button variant="ghost" onClick={() => { setUploadError(null); pickFile(null); }}>
|
||||||
|
Pick a different file
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The full parse result is rendered below so the user can see what was parsed. */}
|
||||||
|
<details className="mt-3 text-sm">
|
||||||
|
<summary>Show parsed claims ({uploadError.parseResult.claims.length})</summary>
|
||||||
|
<pre className="mt-2 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||||
|
{JSON.stringify(uploadError.parseResult.summary, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Database
|
||||||
|
|
||||||
|
Migration 0013 already exists on the `claims-unique-fix` worktree. It
|
||||||
|
drops the `UNIQUE(batch_id, patient_control_number)` inline constraint.
|
||||||
|
After it runs:
|
||||||
|
|
||||||
|
- The 409 fires only on actual CLM01 collisions (not the `member_id`
|
||||||
|
dedup that was over-constraining before).
|
||||||
|
- Multi-claim 837P files with shared `member_id` ingest cleanly for
|
||||||
|
the first time.
|
||||||
|
|
||||||
|
Migration 0014 (added as Task 1.3 in the plan) further relaxes the schema:
|
||||||
|
it changes the PKs on `claims` and `remittances` from single-column
|
||||||
|
(`id`) to composite (`batch_id`, `id`). This is what allows resubmits and
|
||||||
|
makes the workflow in §3 reachable.
|
||||||
|
|
||||||
|
No new tables. No new columns. The DELETE endpoint relies on existing
|
||||||
|
`ON DELETE CASCADE` FKs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Files changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` | new (DONE on `claims-unique-fix`) |
|
||||||
|
| `backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql` | new: composite PK `(batch_id, id)` on `claims` and `remittances`; updates FKs |
|
||||||
|
| `backend/src/cyclone/store.py` | new `find_existing_batch_for_claim` / `find_existing_batch_for_remit` (DONE) + new `delete_batch` method |
|
||||||
|
| `backend/src/cyclone/dedup.py` | new file: pre-flight `preflight_837` / `preflight_835` + `CollisionReport` dataclass |
|
||||||
|
| `backend/src/cyclone/api.py` | 837/835 endpoints: pre-flight check, force param, new 409 body, race handler, new DELETE endpoint |
|
||||||
|
| `src/lib/api.ts` | `ApiError` adds `collisions` + `parseResult`; `parse837`/`parse835` accept `force`; new `deleteBatch` |
|
||||||
|
| `src/pages/Upload.tsx` | new `UploadError` state, error panel JSX, force-insert handler, delete-prior handler |
|
||||||
|
| `src/pages/Upload.test.tsx` | new tests (4 cases from §11) |
|
||||||
|
| `backend/tests/test_db_migrate.py` | 0013 idempotency + UNIQUE-dropped tests (DONE); 0014 composite-PK + FK-cascade tests |
|
||||||
|
| `backend/tests/test_store.py` | `find_existing_batch_for_claim`/`remit` tests (DONE) |
|
||||||
|
| `backend/tests/test_dedup.py` | new tests for `preflight_837` / `preflight_835` (§11) |
|
||||||
|
| `backend/tests/test_api_parse_persists.py` | new tests: pre-flight 409, force-insert, within-file 409, race 409, DELETE endpoint (§11) |
|
||||||
|
|
||||||
|
No new dependencies. No config changes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Test plan
|
||||||
|
|
||||||
|
### Backend (pytest)
|
||||||
|
|
||||||
|
| Test | File | Asserts |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_preflight_837_finds_no_collisions_on_empty_db` | `test_dedup.py` | empty DB → empty `colliding_claim_ids`, no `existing_batch_id` |
|
||||||
|
| `test_preflight_837_finds_cross_batch_collision` | `test_dedup.py` | pre-seed a claim; pre-flight returns that claim_id in `colliding_claim_ids` and the seeded batch in `existing_batch_id` |
|
||||||
|
| `test_preflight_837_finds_within_file_duplicate` | `test_dedup.py` | parsed result has the same CLM01 twice; pre-flight returns it in both `colliding_claim_ids` and `within_file_duplicate_ids` |
|
||||||
|
| `test_preflight_837_returns_most_recent_batch_id` | `test_dedup.py` | pre-seed 3 batches with the same CLM01 at different times; pre-flight returns the most-recent batch_id |
|
||||||
|
| `test_preflight_835_mirrors_837` | `test_dedup.py` | same shape for remittances |
|
||||||
|
| `test_parse_837_409_includes_parse_result_and_collisions` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with a colliding CLM01; assert 409 with `parse_result`, `collisions.colliding_claim_ids`, `existing_batch_id` |
|
||||||
|
| `test_parse_837_409_within_file_duplicate_has_null_batch_id` | `test_api_parse_persists.py` | upload a file with the same CLM01 twice; assert 409 with `existing_batch_id: null` and `within_file_duplicate_ids` populated |
|
||||||
|
| `test_parse_837_force_true_persists_non_colliding_claims` | `test_api_parse_persists.py` | pre-seed a claim; upload a file with 3 claims, 1 colliding; assert 200 with `skipped_claim_ids: [colliding_id]`, the 2 non-colliding claims persist |
|
||||||
|
| `test_parse_837_force_true_does_not_bypass_validation` | `test_api_parse_persists.py` | a file that fails validation still returns 422 with `force=true` |
|
||||||
|
| `test_parse_837_race_409_uses_same_body_shape` | `test_api_parse_persists.py` | mock `store.add` to raise IntegrityError; assert 409 body has the same shape as the pre-flight 409 |
|
||||||
|
| `test_delete_batch_cascades_to_claims` | `test_api_parse_persists.py` | persist a batch with 2 claims; DELETE; assert batch and both claims are gone |
|
||||||
|
| `test_delete_batch_404_on_unknown` | `test_api_parse_persists.py` | DELETE /api/batches/does-not-exist → 404 |
|
||||||
|
| `test_delete_batch_409_on_reconciled_claims` | `test_api_parse_persists.py` | persist a batch, mark a claim state='paid'; DELETE → 409 |
|
||||||
|
| `test_parse_837_after_delete_succeeds` | `test_api_parse_persists.py` | pre-seed a colliding claim; DELETE that batch; re-upload the same file; assert 200 |
|
||||||
|
|
||||||
|
### Frontend (vitest)
|
||||||
|
|
||||||
|
| Test | File | Asserts |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_error_panel_renders_on_409_with_collisions` | `Upload.test.tsx` | mock `parse837` to throw `ApiError(409, ..., PRIOR, collisions, parseResult)`; assert panel visible with all collision data |
|
||||||
|
| `test_force_insert_button_re_calls_with_force_true` | `Upload.test.tsx` | user clicks "Force insert"; assert `parse837` is called with `{ force: true }` |
|
||||||
|
| `test_delete_prior_button_calls_deleteBatch` | `Upload.test.tsx` | user clicks "Delete prior batch"; assert `deleteBatch(existingBatchId)` is called |
|
||||||
|
| `test_pick_different_clears_error` | `Upload.test.tsx` | user clicks "Pick a different file"; assert `uploadError` is cleared and file picker is reset |
|
||||||
|
| `test_no_panel_on_non_409` | `Upload.test.tsx` | 400 error; assert panel absent |
|
||||||
|
| `test_within_file_duplicate_omits_prior_batch_actions` | `Upload.test.tsx` | 409 with `existingBatchId: null`; assert "Open prior batch" and "Delete prior batch" buttons are absent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Out of scope
|
||||||
|
|
||||||
|
* Batch editing (update claim state, edit claim fields). Future SP.
|
||||||
|
* Cross-batch dedup REPORT (a "find all CLM01s in batches B1+B2+B3"
|
||||||
|
query). Future SP.
|
||||||
|
* Migration reversibility for 0013 — the recreation preserves data but
|
||||||
|
not schema history. Acceptable since 0013 just drops an inline
|
||||||
|
constraint; recreating the constraint would be a separate migration.
|
||||||
|
* Audit event for force-insert skips. The user explicitly chose to
|
||||||
|
skip silently; we honor that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Risk
|
||||||
|
|
||||||
|
* **Pre-flight check race**: between the check and the persist, a
|
||||||
|
concurrent process could ingest a colliding claim. The persist would
|
||||||
|
then raise `IntegrityError`; the handler returns the same 409 shape
|
||||||
|
with `detail` mentioning the race. The user re-runs. Acceptable.
|
||||||
|
* **DELETE on a large batch**: cascade through `claims`, `remittances`,
|
||||||
|
`matches`, `line_reconciliations`, `activity_events`. SQLite handles
|
||||||
|
the cascade in a single transaction; a 140-claim batch deletes in
|
||||||
|
<100ms. The endpoint refuses if any claim is past `submitted` state.
|
||||||
|
* **`force=true` silent skip**: the user clicks "Force insert" and
|
||||||
|
the response says "X of Y claims persisted, Z skipped". They
|
||||||
|
acknowledged this in the panel before clicking. No undo.
|
||||||
|
* **Within-file duplicates and force-insert**: the user can force-insert
|
||||||
|
a file with the same CLM01 twice. The first instance persists, the
|
||||||
|
second is silently skipped. This is intentional — within-file dupes
|
||||||
|
are usually a typo, and the user has explicitly asked to proceed.
|
||||||
|
* **`existing_batch_id` may be stale**: the helper returns the
|
||||||
|
most-recent batch by `state_changed_at` (or `received_at` for 835).
|
||||||
|
The user clicks "Open prior batch" and the batch may have been
|
||||||
|
deleted in the meantime. The BatchesList page already handles 404
|
||||||
|
gracefully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Rollout
|
||||||
|
|
||||||
|
1. **Schema**: migration 0013 applies on next `cyclone` startup.
|
||||||
|
Idempotent and reversible only by rebuilding the `claims` table
|
||||||
|
(acceptable; production data preserved by the INSERT...SELECT).
|
||||||
|
2. **Backend API**: new `force` param + new 409 body shape + new
|
||||||
|
DELETE endpoint. Existing clients that don't pass `force` see the
|
||||||
|
same behavior as before for collision-free files. Collision cases
|
||||||
|
now get a richer 409 body that includes `parse_result`; clients
|
||||||
|
that ignore the new fields keep working.
|
||||||
|
3. **Frontend**: `Upload.tsx` panel replaces the toast on 409. Users
|
||||||
|
who don't read the panel still see the toast and the 409 message
|
||||||
|
in the streaming view.
|
||||||
|
4. **No data migration**: nothing to migrate. 0013 is structural only.
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
# Cyclone Auth (admin / user / viewer) — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-22
|
||||||
|
**Status:** Draft (pending user review)
|
||||||
|
**Scope:** Adds username/password authentication and three predefined roles (admin, user, viewer) to the existing Cyclone FastAPI backend and React frontend. Browser-based login form, server-side SQLite sessions, HttpOnly cookie, role-gated endpoints. Single-machine deployment; no external IdP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Cyclone currently has no authentication. The README states the system is "local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure. Built for one operator, one machine." That was true for the original single-operator design, but the system is now being prepared for production deployment where multiple humans will share the box (a clearinghouse operator, billing staff, and read-only auditors).
|
||||||
|
|
||||||
|
This sub-project adds the minimum viable role-based access control:
|
||||||
|
- A `/login` page that posts username + password to the backend.
|
||||||
|
- Server-side sessions in SQLite, keyed by an HttpOnly cookie.
|
||||||
|
- Three predefined roles — `admin`, `user`, `viewer` — with a static permission matrix.
|
||||||
|
- An admin-only user-management surface so the first admin can create other accounts.
|
||||||
|
- All existing endpoints get a `current_user` dependency; write-affording endpoints get a `require_role` gate.
|
||||||
|
|
||||||
|
After this, an operator can:
|
||||||
|
1. Start the stack with `CYCLONE_ADMIN_USERNAME=admin CYCLONE_ADMIN_PASSWORD=...` (or use a CLI command) so the first admin account is created on first boot.
|
||||||
|
2. Open `http://localhost:8081/`, get redirected to `/login`, sign in.
|
||||||
|
3. Browse the Dashboard, Claims, Remittances, etc. as a `user` or `viewer`.
|
||||||
|
4. As `admin`, visit a new Users page to create accounts, change roles, disable users, and reset passwords.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **Authenticate every API request.** Every existing endpoint under `/api/*` returns 401 unless a valid session cookie is present. The only unauthenticated paths are `/api/healthz` (declared before any auth dependency in `cyclone.api`) and `POST /api/auth/login` itself.
|
||||||
|
2. **Three predefined roles with a static permission matrix.** `admin` = everything including user management. `user` = read + write on claims/remits/batches/reconciliation + uploads. `viewer` = read-only — no uploads, no state changes.
|
||||||
|
3. **Server-side sessions in SQLite.** New `users` and `sessions` tables. 24-hour sliding expiry. HttpOnly cookie named `cyclone_session`.
|
||||||
|
4. **Bootstrap the first admin.** If `users` table is empty on startup, read `CYCLONE_ADMIN_USERNAME` / `CYCLONE_ADMIN_PASSWORD` env vars and create the first admin. If those env vars are also missing, refuse to start with a clear error message.
|
||||||
|
5. **Frontend `/login` route + auth context.** `AuthProvider` loads `/api/auth/me` on mount, redirects to `/login` on 401, restores the user on reload. Sidebar shows the real current user instead of the hardcoded "Jordan K.".
|
||||||
|
6. **Disable write-affording UI for `viewer`.** Upload, parse, resubmit, acknowledge, reconcile buttons render disabled with a tooltip when the role lacks permission. Server still gates as the source of truth — disabling the UI is a UX nicety.
|
||||||
|
7. **Audit log includes the acting user.** The existing `audit_log` table (SP11) gets a `user_id` column on a new migration; entries record the user that triggered each event.
|
||||||
|
8. **CLI command for user management.** `python -m cyclone users create <username> --role admin --password ...` works as an alternative to the admin UI for ops.
|
||||||
|
|
||||||
|
## 3. Non-goals (this sub-project)
|
||||||
|
|
||||||
|
- **LDAP / SAML / OIDC.** No external identity providers. Auth is local-only — credentials live in the SQLite `users` table.
|
||||||
|
- **Password reset emails / "forgot password" flow.** Admins reset passwords via the admin UI or CLI. There is no self-service flow.
|
||||||
|
- **Multi-factor authentication (MFA / TOTP).** Username + password is the only factor.
|
||||||
|
- **Per-resource ACLs / per-claim visibility.** The role applies globally. There is no concept of "this user can only see claims for provider X".
|
||||||
|
- **Account lockout after N failed attempts.** We rate-limit per username (5 fails per 5 min) but never permanently lock the account.
|
||||||
|
- **Cross-device session sync / "see my active sessions" UI.** Sessions are opaque cookie values; the admin UI shows the user list, not their sessions.
|
||||||
|
- **Branding / SSO.** Out of scope.
|
||||||
|
|
||||||
|
## 4. Stack
|
||||||
|
|
||||||
|
**Backend additions:**
|
||||||
|
- New module `cyclone.auth` (`users`, `sessions`, `permissions`, `routes`, `admin`, `deps`, `rate_limit`).
|
||||||
|
- `passlib[bcrypt]` for password hashing (industry standard, slow on purpose).
|
||||||
|
- `secrets.token_urlsafe(32)` for session IDs (256 bits of entropy).
|
||||||
|
- SQLAlchemy ORM (already in use) — new `User` and `Session` models on the same `cyclone.db.Base`.
|
||||||
|
- Migration `0015_users_and_sessions.py` creates both tables.
|
||||||
|
- FastAPI dependency injection for `get_current_user` and `require_role`.
|
||||||
|
|
||||||
|
**Frontend additions:**
|
||||||
|
- New `src/auth/` module: `AuthProvider` context, `useAuth` hook, `RoleGate` component, fetch wrapper that handles 401.
|
||||||
|
- New `/login` page (`src/pages/Login.tsx`).
|
||||||
|
- No new build tools; the existing Vite + React + react-query stack stays.
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- nginx `proxy_cookie_path /api/ /;` so the session cookie path survives the frontend → backend reverse proxy.
|
||||||
|
- `docker-compose.yml` adds `CYCLONE_ADMIN_USERNAME` and `CYCLONE_ADMIN_PASSWORD` env vars to the backend service.
|
||||||
|
- Backend image installs `passlib[bcrypt]`.
|
||||||
|
|
||||||
|
## 5. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ ┌──────────────────────────────────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ React SPA │ │ Cookie jar │ │
|
||||||
|
│ │ ┌────────────────────────────────────┐ │ │ cyclone_session=<id> │ │
|
||||||
|
│ │ │ <AuthProvider> │ │ │ HttpOnly, Lax, │ │
|
||||||
|
│ │ │ on mount: GET /api/auth/me │──┼──┼──Path=/api │ │
|
||||||
|
│ │ │ on 401: hard nav to /login │ │ └──────────────────────┘ │
|
||||||
|
│ │ │ </AuthProvider> │ │ │
|
||||||
|
│ │ │ ┌─────────────────────────────┐ │ │ │
|
||||||
|
│ │ │ │ <RoleGate allow=...> │ │ │ │
|
||||||
|
│ │ │ │ disables write buttons │ │ │ │
|
||||||
|
│ │ │ │ for users without role │ │ │ │
|
||||||
|
│ │ │ └─────────────────────────────┘ │ │ │
|
||||||
|
│ │ └────────────────────────────────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────┬─────────────────────────────────────────┘
|
||||||
|
│ HTTPS (or HTTP behind LAN)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ nginx (frontend container) │
|
||||||
|
│ - serves SPA static bundle │
|
||||||
|
│ - location /api/* → proxy_pass http://cyclone-backend:8000 │
|
||||||
|
│ - proxy_cookie_path /api/ /; (rewrites cookie path) │
|
||||||
|
└────────────────────────────────┬─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI (backend container) │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ cyclone.auth.routes POST /api/auth/login, logout │ │
|
||||||
|
│ │ GET /api/auth/me │ │
|
||||||
|
│ │ cyclone.auth.admin CRUD /api/admin/users │ │
|
||||||
|
│ │ cyclone.api (existing) every endpoint gains │ │
|
||||||
|
│ │ Depends(get_current_user) │ │
|
||||||
|
│ │ sensitive endpoints gain │ │
|
||||||
|
│ │ Depends(require_role("admin")) │ │
|
||||||
|
│ ├──────────────────────────────────────────────────────────────────┤ │
|
||||||
|
│ │ cyclone.auth.deps get_current_user, require_role │ │
|
||||||
|
│ │ cyclone.auth.sessions create/validate/expire │ │
|
||||||
|
│ │ cyclone.auth.users create/get/update/disable (bcrypt) │ │
|
||||||
|
│ │ cyclone.auth.permissions Role enum + matrix │ │
|
||||||
|
│ │ cyclone.auth.rate_limit in-memory 5-fail-per-5-min per username│ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ cyclone.db (SQLAlchemy) │ │
|
||||||
|
│ │ users(id, username UNIQUE, password_hash, role, │ │
|
||||||
|
│ │ created_at, disabled_at) │ │
|
||||||
|
│ │ sessions(id PK, user_id FK, expires_at, created_at) │ │
|
||||||
|
│ │ audit_log (existing — gets user_id column via 0016) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Data flow on login:**
|
||||||
|
1. User opens `/login` (the SPA redirects there on first visit because `/api/auth/me` returned 401).
|
||||||
|
2. User submits `{username, password}` → `POST /api/auth/login`.
|
||||||
|
3. Backend rate-limit check: if username has ≥5 failed logins in the last 5 min, return 429 with `Retry-After`.
|
||||||
|
4. Backend looks up user by username. If not found OR `bcrypt.verify(password, password_hash)` fails, increment the rate-limit counter and return 401 `{error: "invalid_credentials"}`. The error message is generic so it doesn't leak whether the username exists.
|
||||||
|
5. If user.disabled_at is not None, return 403 `{error: "account_disabled"}`.
|
||||||
|
6. Create a `sessions` row: `id = secrets.token_urlsafe(32)`, `user_id`, `expires_at = now() + 24h`.
|
||||||
|
7. Set `Set-Cookie: cyclone_session=<id>; HttpOnly; SameSite=Lax; Path=/api; Max-Age=86400` (also `Secure` when behind HTTPS — detected via `request.url.scheme` or a `BEHIND_HTTPS=1` env var).
|
||||||
|
8. Return `200 {id, username, role, createdAt}`.
|
||||||
|
|
||||||
|
**Data flow on a normal request:**
|
||||||
|
1. Browser sends request with `Cookie: cyclone_session=<id>`.
|
||||||
|
2. FastAPI middleware / dependency reads the cookie, looks up `sessions` row by `id`.
|
||||||
|
3. If missing, expired (`expires_at < now`), or user is disabled → raise `HTTPException(401, "session_expired")`.
|
||||||
|
4. Otherwise, attach `User` to `request.state.user`. The `get_current_user` dependency returns it.
|
||||||
|
5. For endpoints with `Depends(require_role("admin"))`, check `user.role == "admin"`; else raise `HTTPException(403, "forbidden")`.
|
||||||
|
|
||||||
|
**Data flow on logout:**
|
||||||
|
1. SPA sends `POST /api/auth/logout`.
|
||||||
|
2. Backend deletes the `sessions` row matching the cookie's id.
|
||||||
|
3. Backend sets `Set-Cookie: cyclone_session=; Max-Age=0` to clear the cookie.
|
||||||
|
4. SPA's `AuthProvider` clears its state and navigates to `/login`.
|
||||||
|
|
||||||
|
**Sliding expiry:**
|
||||||
|
Every successful authenticated request refreshes `sessions.expires_at = now() + 24h` (cheap UPDATE) **and** re-emits the `Set-Cookie` header with a fresh `Max-Age=86400`. Without re-emitting the cookie, the browser would log the user out after 24h regardless of activity (because the original cookie's Max-Age expires). With this loop, an active user stays logged in indefinitely; an inactive user is logged out 24h after their last request.
|
||||||
|
|
||||||
|
## 6. Backend changes
|
||||||
|
|
||||||
|
### 6.1 New module `backend/src/cyclone/auth/`
|
||||||
|
|
||||||
|
```
|
||||||
|
auth/
|
||||||
|
├── __init__.py # re-exports the public API
|
||||||
|
├── users.py # User model, CRUD, bcrypt hashing
|
||||||
|
├── sessions.py # Session model, create/validate/expire
|
||||||
|
├── permissions.py # Role enum, permission matrix
|
||||||
|
├── deps.py # get_current_user, require_role
|
||||||
|
├── routes.py # /api/auth/login, logout, me
|
||||||
|
├── admin.py # /api/admin/users/* (admin-only)
|
||||||
|
└── rate_limit.py # per-username failed-login counter
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Data model
|
||||||
|
|
||||||
|
New SQLAlchemy models in `cyclone.db`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[int] = mapped_column(primary_key=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
role: Mapped[str] = mapped_column(String(16)) # "admin" | "user" | "viewer"
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
disabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
id: Mapped[str] = mapped_column(String(64), primary_key=True) # token_urlsafe(32)
|
||||||
|
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration `0015_users_and_sessions.py` creates both tables and adds indexes on `users.username`, `sessions.expires_at`, `sessions.user_id`.
|
||||||
|
|
||||||
|
Migration `0016_audit_log_user_id.py` adds `user_id INT NULL` to the existing `audit_log` table (SP11's hash-chained audit log).
|
||||||
|
|
||||||
|
### 6.3 Permissions matrix
|
||||||
|
|
||||||
|
`cyclone/auth/permissions.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class Role(str, Enum):
|
||||||
|
ADMIN = "admin"
|
||||||
|
USER = "user"
|
||||||
|
VIEWER = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
# Endpoint path prefix → set of roles allowed.
|
||||||
|
PERMISSIONS: dict[str, set[Role]] = {
|
||||||
|
# Public paths (no auth required). Empty set = anyone, including unauthenticated.
|
||||||
|
"GET /api/healthz": set(),
|
||||||
|
"POST /api/auth/login": set(),
|
||||||
|
|
||||||
|
# Auth surface (authenticated, all roles).
|
||||||
|
"POST /api/auth/logout": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/auth/me": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
|
||||||
|
# Admin-only user management.
|
||||||
|
"GET /api/admin/users": {Role.ADMIN},
|
||||||
|
"POST /api/admin/users": {Role.ADMIN},
|
||||||
|
"PATCH /api/admin/users": {Role.ADMIN},
|
||||||
|
"DELETE /api/admin/users": {Role.ADMIN},
|
||||||
|
|
||||||
|
# Read endpoints (everyone authenticated can read).
|
||||||
|
"GET /api/claims": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/remittances": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/providers": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/batches": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/dashboard/summary": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/activity": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/inbox/lanes": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/reconcile": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
"GET /api/audit-log": {Role.ADMIN}, # SP11 — admin only
|
||||||
|
|
||||||
|
# Write endpoints (admin + user, no viewer).
|
||||||
|
"POST /api/parse-837": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/parse-835": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/inbox": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/reconcile": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/resubmit": {Role.ADMIN, Role.USER},
|
||||||
|
"POST /api/acks": {Role.ADMIN, Role.USER},
|
||||||
|
# ...every other POST/PATCH/DELETE goes here too.
|
||||||
|
|
||||||
|
# CSV export — read-only, so all roles.
|
||||||
|
"GET /api/export.csv": {Role.ADMIN, Role.USER, Role.VIEWER},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `require_role` dependency reads `(method, path)` from the request and looks up the allowed roles. Endpoints not in the matrix default to **deny** — fail-closed.
|
||||||
|
|
||||||
|
### 6.4 Dependencies
|
||||||
|
|
||||||
|
```python
|
||||||
|
# deps.py
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
|
||||||
|
async def get_current_user(request: Request) -> User:
|
||||||
|
sid = request.cookies.get("cyclone_session")
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
|
||||||
|
session = await sessions.get_valid(sid)
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "session_expired")
|
||||||
|
user = await users.get(session.user_id)
|
||||||
|
if not user or user.disabled_at is not None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "account_disabled")
|
||||||
|
# Sliding expiry refresh.
|
||||||
|
await sessions.touch(sid)
|
||||||
|
request.state.user = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*allowed: Role):
|
||||||
|
async def _dep(request: Request, user: User = Depends(get_current_user)) -> User:
|
||||||
|
method = request.method
|
||||||
|
path = request.url.path
|
||||||
|
key = f"{method} {path}"
|
||||||
|
# Longest-prefix match: e.g. /api/claims/CLM-1 matches "GET /api/claims".
|
||||||
|
allowed_roles = _lookup_permissions(key)
|
||||||
|
if user.role not in allowed_roles:
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden")
|
||||||
|
return user
|
||||||
|
return _dep
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 Endpoints
|
||||||
|
|
||||||
|
**Auth (`cyclone/auth/routes.py`):**
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| POST | `/api/auth/login` | public | `{username, password}` | `200 {id, username, role, createdAt}` + Set-Cookie |
|
||||||
|
| POST | `/api/auth/logout` | session | — | `204` + cookie cleared |
|
||||||
|
| GET | `/api/auth/me` | session | — | `200 {id, username, role, createdAt}` |
|
||||||
|
|
||||||
|
**Admin (`cyclone/auth/admin.py`):**
|
||||||
|
|
||||||
|
| Method | Path | Auth | Body | Response |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| GET | `/api/admin/users` | admin | — | `200 [{id, username, role, createdAt, disabledAt}]` |
|
||||||
|
| POST | `/api/admin/users` | admin | `{username, password, role}` | `201 {id, username, role, createdAt}` |
|
||||||
|
| PATCH | `/api/admin/users/{id}` | admin | `{role?, password?, disabled?}` | `200 {id, username, role, createdAt, disabledAt}` |
|
||||||
|
| DELETE | `/api/admin/users/{id}` | admin | — | `204` (only if user has no authored data; otherwise 409) |
|
||||||
|
|
||||||
|
**Error shapes:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 401
|
||||||
|
{ "error": "session_expired", "detail": "Session is missing or expired." }
|
||||||
|
// 403
|
||||||
|
{ "error": "forbidden", "detail": "Your role lacks permission for this action." }
|
||||||
|
// 429
|
||||||
|
{ "error": "rate_limited", "detail": "Too many login attempts. Try again in N seconds." }
|
||||||
|
// Login 401 (generic — never leak username existence)
|
||||||
|
{ "error": "invalid_credentials", "detail": "Username or password is incorrect." }
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.6 Rate limit
|
||||||
|
|
||||||
|
In-memory dict in `rate_limit.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
_FAILS: dict[str, list[float]] = {} # username → [timestamp, ...] of recent fails
|
||||||
|
WINDOW_SECONDS = 300
|
||||||
|
MAX_FAILS = 5
|
||||||
|
|
||||||
|
def check(username: str) -> int:
|
||||||
|
"""Return Retry-After seconds, or 0 if allowed."""
|
||||||
|
now = time.monotonic()
|
||||||
|
fails = [t for t in _FAILS.get(username, []) if now - t < WINDOW_SECONDS]
|
||||||
|
if len(fails) >= MAX_FAILS:
|
||||||
|
return int(WINDOW_SECONDS - (now - fails[0]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def record_failure(username: str) -> None:
|
||||||
|
_FAILS.setdefault(username, []).append(time.monotonic())
|
||||||
|
|
||||||
|
def reset(username: str) -> None:
|
||||||
|
_FAILS.pop(username, None)
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-process. Resets on backend restart. Acceptable for v1.
|
||||||
|
|
||||||
|
### 6.7 Bootstrap (first admin)
|
||||||
|
|
||||||
|
In `cyclone/__main__.py`, before `uvicorn.run(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def bootstrap_admin():
|
||||||
|
async with SessionLocal() as db:
|
||||||
|
if await db.scalar(select(func.count()).select_from(User)) > 0:
|
||||||
|
return
|
||||||
|
username = os.environ.get("CYCLONE_ADMIN_USERNAME")
|
||||||
|
password = os.environ.get("CYCLONE_ADMIN_PASSWORD")
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cyclone has no users yet. Set CYCLONE_ADMIN_USERNAME and "
|
||||||
|
"CYCLONE_ADMIN_PASSWORD env vars, or run "
|
||||||
|
"`python -m cyclone users create <username> --role admin` first."
|
||||||
|
)
|
||||||
|
if len(password) < 12:
|
||||||
|
raise RuntimeError("CYCLONE_ADMIN_PASSWORD must be at least 12 characters.")
|
||||||
|
await users.create(db, username=username, password=password, role=Role.ADMIN)
|
||||||
|
print(f"[cyclone] bootstrap admin user '{username}' created")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.8 CLI command
|
||||||
|
|
||||||
|
`python -m cyclone users ...`:
|
||||||
|
|
||||||
|
```
|
||||||
|
python -m cyclone users create <username> --role {admin|user|viewer} [--password <pw>]
|
||||||
|
python -m cyclone users list
|
||||||
|
python -m cyclone users disable <username>
|
||||||
|
python -m cyclone users reset-password <username> [--password <pw>]
|
||||||
|
python -m cyclone users set-role <username> --role {admin|user|viewer}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `--password` is omitted, the CLI prompts interactively (no echo). On Windows / non-tty, refuse and require `--password` from env to avoid accidental empty-password accounts.
|
||||||
|
|
||||||
|
## 7. Frontend changes
|
||||||
|
|
||||||
|
### 7.1 New module `src/auth/`
|
||||||
|
|
||||||
|
```
|
||||||
|
auth/
|
||||||
|
├── AuthProvider.tsx # React context + reducer
|
||||||
|
├── AuthProvider.test.tsx
|
||||||
|
├── useAuth.ts # hook: {user, login, logout, status}
|
||||||
|
├── api.ts # fetch wrapper that handles 401
|
||||||
|
├── RoleGate.tsx # disables children when role not allowed
|
||||||
|
└── RoleGate.test.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 AuthProvider
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// AuthProvider.tsx (sketch)
|
||||||
|
type Status = "loading" | "authenticated" | "unauthenticated";
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
status: Status;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState & { login, logout }>(...);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [state, setState] = useState<AuthState>({ status: "loading", user: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.getAuthMe()
|
||||||
|
.then(user => setState({ status: "authenticated", user }))
|
||||||
|
.catch(err => {
|
||||||
|
if (err.status === 401) setState({ status: "unauthenticated", user: null });
|
||||||
|
else setState({ status: "unauthenticated", user: null }); // network errors also treat as logged out
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Expose login() that POSTs /api/auth/login + sets state on success.
|
||||||
|
// Expose logout() that POSTs /api/auth/logout + clears state + nav to /login.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 Login page
|
||||||
|
|
||||||
|
`src/pages/Login.tsx`:
|
||||||
|
- Centered card on the existing dark background, ~400px wide.
|
||||||
|
- Username + password fields, "Sign in" button, error message slot.
|
||||||
|
- On submit: POST `/api/auth/login`, on success → `useNavigate()/<prev>` or `/`.
|
||||||
|
- On `401 invalid_credentials` → "Username or password is incorrect."
|
||||||
|
- On `403 account_disabled` → "Account is disabled. Contact your administrator."
|
||||||
|
- On `429 rate_limited` → "Too many attempts. Try again in N seconds." (N from `Retry-After`).
|
||||||
|
- The page renders *without* the sidebar — it lives outside the protected `<Layout>`.
|
||||||
|
|
||||||
|
### 7.4 API wrapper
|
||||||
|
|
||||||
|
`src/auth/api.ts` re-exports the existing `lib/api.ts` but wraps `fetch` so any 401 response:
|
||||||
|
1. Calls `POST /api/auth/logout` (best-effort, ignore failure).
|
||||||
|
2. Clears the auth context.
|
||||||
|
3. `window.location.href = "/login?next=" + encodeURIComponent(currentPath)`.
|
||||||
|
|
||||||
|
Use a hard navigation (not `<Navigate>`) so the SPA's in-memory state is wiped.
|
||||||
|
|
||||||
|
### 7.5 RoleGate
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// RoleGate.tsx
|
||||||
|
interface Props {
|
||||||
|
allow: Role[];
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode; // optional explicit "no permission" UI
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleGate({ allow, children, fallback }: Props) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) return null;
|
||||||
|
if (allow.includes(user.role)) return <>{children}</>;
|
||||||
|
if (fallback) return <>{fallback}</>;
|
||||||
|
return (
|
||||||
|
<Tooltip content={`Your role (${user.role}) cannot perform this action.`}>
|
||||||
|
<span className="pointer-events-none opacity-50">{children}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Applied at the call sites — e.g. wrap the Upload dropzone, the Parse button, the Resubmit button, the Acknowledge action, the Reconcile "match" button.
|
||||||
|
|
||||||
|
### 7.6 Sidebar
|
||||||
|
|
||||||
|
`src/components/Sidebar.tsx` — replace the hardcoded "Jordan K. / Administrator" block with `<CurrentUser />` which reads `useAuth()`. Shows the user's actual username + role.
|
||||||
|
|
||||||
|
### 7.7 Routes
|
||||||
|
|
||||||
|
`src/main.tsx`:
|
||||||
|
- `QueryClientProvider`
|
||||||
|
- `AuthProvider`
|
||||||
|
- `<BrowserRouter>` with two route groups:
|
||||||
|
- **Public:** `/login` (no `<Layout>` wrap).
|
||||||
|
- **Protected:** everything else, wrapped in a `<RequireAuth>` guard that waits for `AuthProvider.status === "loading"` to resolve, then renders `<Outlet />` or `<Navigate to="/login" />`.
|
||||||
|
|
||||||
|
### 7.8 react-query keys
|
||||||
|
|
||||||
|
`useAuth()` itself is not stored in react-query (it's a long-lived auth state, not a query). Existing query keys (`['claims', ...]`, etc.) are unchanged. On logout, the app does a hard reload to `/login` which wipes all react-query state.
|
||||||
|
|
||||||
|
## 8. Infrastructure
|
||||||
|
|
||||||
|
### 8.1 nginx (`frontend/nginx.conf`)
|
||||||
|
|
||||||
|
The existing nginx config already proxies `/api/*` to the backend. With cookie-based auth, the only requirement is that the cookie's `Path` matches a prefix of the request URL the browser sends. We set `Path=/api` on the cookie (matching the proxied path), and the browser sends it automatically on every `/api/*` request to the frontend nginx — no `proxy_cookie_path` rewrite needed.
|
||||||
|
|
||||||
|
We do add `proxy_set_header X-Forwarded-Proto $scheme;` so the backend can detect when it's behind HTTPS and emit the `Secure` cookie flag.
|
||||||
|
|
||||||
|
### 8.2 docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
CYCLONE_ADMIN_USERNAME: ${CYCLONE_ADMIN_USERNAME:?set me}
|
||||||
|
CYCLONE_ADMIN_PASSWORD: ${CYCLONE_ADMIN_PASSWORD:?set me}
|
||||||
|
# CYCLONE_BEHIND_HTTPS=1 # uncomment if behind an HTTPS reverse proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
A `.env.example` documents the required vars.
|
||||||
|
|
||||||
|
### 8.3 Cookie `Secure` flag
|
||||||
|
|
||||||
|
Detected at request time: if `request.url.scheme == "https"` OR `os.environ.get("CYCLONE_BEHIND_HTTPS") == "1"`, the cookie gets `Secure`. This lets the same binary work in dev (`http://localhost`) and prod (HTTPS in front of nginx).
|
||||||
|
|
||||||
|
## 9. Testing
|
||||||
|
|
||||||
|
### 9.1 Backend (`backend/tests/`)
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|---|---|
|
||||||
|
| `test_auth_users.py` | bcrypt hash/verify round-trip; create/get/disable; password never returned in any response shape; username uniqueness; role must be one of the enum |
|
||||||
|
| `test_auth_sessions.py` | create/validate/expire; expired session is rejected; cookie attrs (HttpOnly, Path, Max-Age) on the login response |
|
||||||
|
| `test_auth_routes.py` | login success path returns 200 + cookie; login with bad password returns 401 with `invalid_credentials`; login with disabled user returns 403; logout deletes the session row + clears cookie; /me with valid cookie returns user; /me with no cookie returns 401 |
|
||||||
|
| `test_auth_permissions.py` | for each role × each endpoint, assert the right HTTP code (200/401/403); fail-closed: endpoints not in the matrix return 403 |
|
||||||
|
| `test_auth_admin.py` | admin can GET/POST/PATCH/DELETE users; non-admin gets 403 on every /api/admin/* path; admin cannot delete themselves (409); admin cannot demote themselves below admin (409) |
|
||||||
|
| `test_auth_bootstrap.py` | empty users + env vars → admin created; empty users + missing env vars → backend refuses to start; non-empty users → env vars ignored |
|
||||||
|
| `test_auth_login_rate_limit.py` | 5 failed logins OK, 6th returns 429 with Retry-After; counter resets after window; successful login resets the counter |
|
||||||
|
| `test_audit_log_user_id.py` | existing audit-log entries get NULL user_id (back-compat); new entries after auth lands record the acting user's id |
|
||||||
|
| `test_existing_endpoints_require_auth.py` | spot-check 10 existing endpoints (claims GET, parse-837 POST, etc.) all return 401 without a cookie |
|
||||||
|
|
||||||
|
### 9.2 Frontend (`src/**/__tests__/`)
|
||||||
|
|
||||||
|
| File | Coverage |
|
||||||
|
|---|---|
|
||||||
|
| `src/auth/AuthProvider.test.tsx` | /me on mount populates user; 401 from /me leaves user null; login() POSTs and updates state; logout() POSTs and clears state |
|
||||||
|
| `src/auth/RoleGate.test.tsx` | renders children for allowed role; renders disabled-with-tooltip for disallowed role; renders fallback when provided |
|
||||||
|
| `src/pages/Login.test.tsx` | form submits with username/password; error message on 401; redirect to `next` query param on success; rate-limit message on 429 |
|
||||||
|
| `src/lib/api.test.ts` (extended) | fetch wrapper hard-navigates to `/login` on 401; passes through on other statuses |
|
||||||
|
|
||||||
|
### 9.3 End-to-end
|
||||||
|
|
||||||
|
`/tmp/verify_auth.py` (Playwright) — login with seeded admin, verify dashboard renders, verify viewer cannot see Upload dropzone enabled, verify viewer clicking Upload sees a disabled state with tooltip.
|
||||||
|
|
||||||
|
## 10. Risk + open questions
|
||||||
|
|
||||||
|
- **Cookie path rewriting is fragile.** If a future deployment puts the API at a different prefix than `/api/`, the `proxy_cookie_path` will need to change. Documented in the README.
|
||||||
|
- **The audit-log migration (0016) is technically out of scope** for "add auth" — but the user said "I want admin, user, viewer. or something like that" and not seeing *who* did *what* in the audit log is a half-measure. Include it.
|
||||||
|
- **The CLI command on Windows** will need to handle non-tty differently from Linux/macOS. Stubbed: require `--password` from env on non-tty platforms.
|
||||||
|
- **No logout-everywhere UI.** If an admin wants to invalidate all sessions for a user, they currently have to wait for sessions to expire or wipe the `sessions` table directly. Acceptable for v1; admin can `DELETE FROM sessions WHERE user_id = ?` from `sqlite3 /data/cyclone.db` if urgent.
|
||||||
|
|
||||||
|
## 11. Rollout
|
||||||
|
|
||||||
|
- All code lands in one PR (sub-project is small enough).
|
||||||
|
- Migrations 0015 + 0016 ship together; both are backwards-compatible (additive).
|
||||||
|
- The Docker image rebuilds pick up `passlib[bcrypt]` automatically.
|
||||||
|
- `README.md` gets a new "Auth" section explaining env vars, the bootstrap admin, the CLI command, and the role matrix.
|
||||||
|
- Auth applies everywhere once enabled — both the Docker deployment (`http://localhost:8081`) and the Vite dev server (`http://localhost:5173`, which proxies `/api/*` to the backend on port 8000). Operators who want to disable auth in dev can set `CYCLONE_AUTH_DISABLED=1` env var; the bootstrap function is a no-op when this is set, and the `get_current_user` dependency returns a synthetic admin user. This is purely a developer-experience escape hatch — production deployments leave it unset.
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
# Cyclone Ubuntu Docker Deployment — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-22 (spec drafted); 2026-06-23 (reconciled with auth work that landed in main on 2026-06-23)
|
||||||
|
**Status:** Approved (2026-06-23)
|
||||||
|
**Branch:** `sp23-ubuntu-docker-deployment`
|
||||||
|
**Aesthetic direction:** No new UI surface in v1 — the nginx + reverse-proxy posture is invisible to the operator; auth + dashboard styling is unchanged from what's already on main.
|
||||||
|
**Depends on:** [2026-06-19-cyclone-production-readiness-design.md](2026-06-19-cyclone-production-readiness-design.md) (in-memory store + react-query wiring; local-only); the auth work that merged into `main` on 2026-06-23 (`a25504b`..`39ae988`, the `feat(auth):` series).
|
||||||
|
**Replaces:** The "no auth, no Docker, local-only" posture of the parent spec with a production-grade posture for real PHI on a single Ubuntu server.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1.1 Delta vs. `main` at 2026-06-23
|
||||||
|
|
||||||
|
The auth work that landed in main before SP23 began means sections 6 (auth modules + routes + matrix) and 7 (frontend login + AuthProvider + RoleGate) describe code that **already exists on main**. SP23's delta is therefore narrower than the original draft:
|
||||||
|
|
||||||
|
- **§6 (auth) — already on main.** The `cyclone.auth` package, `users` / `sessions` SQLAlchemy models (migrations `0013` + `0014`), bcrypt password hashing, the `PERMISSIONS` matrix + `matrix_gate` app-wide dependency, `Login.tsx` + `AuthProvider` + `RequireAuth` + `RoleGate`, the `cyclone admin` CLI, and `CYCLONE_AUTH_DISABLED=1` escape hatch are all merged. SP23 **reuses** them; it does not re-implement. §6 is kept in this spec as the contract that Docker + secrets + compose env vars must satisfy, not as work to do.
|
||||||
|
- **§7 (login UI) — already on main.** Reused.
|
||||||
|
- **§6.6 (new migration) — N/A.** No new SQL migration is needed for SP23; auth tables already exist.
|
||||||
|
- **What SP23 adds (the actual work):** §6.8 backend Dockerfile, §7.8 frontend Dockerfile + nginx.conf, §8.2 Docker secrets wiring, §9.4 image tag strategy + `:stable` aliases, §10 docker-compose.yml, §11 healthcheck + restart policy, §12.2 `tests/test_docker.py`, §9.5 bootstrap script, §9.6 RUNBOOK.md, post-deploy + smoke scripts.
|
||||||
|
- **Role name reconciliation:** the original draft used `admin / operator / viewer`. Main uses `admin / user / viewer`. All references to `operator` in this spec should be read as `user` against the live codebase.
|
||||||
|
- **Hashing algorithm reconciliation:** the original draft specified `argon2id`. Main uses `bcrypt`. The Docker secret posture is unaffected — both algorithms need only a key file or a salt, neither of which SP23 introduces.
|
||||||
|
- **Lockout timing:** original draft was 5 fails / 15 min. Auth on main is 5 fails / 5 min (`cyclone.auth.rate_limit`). SP23 inherits the main behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Cyclone's first sub-project shipped a usable local-only system: parse 837/835 files, browse the data, edit and resubmit rejected claims, export a corrected 837 ZIP. Everything ran on `127.0.0.1` with no auth, in-memory storage, and a `python -m cyclone serve` invocation.
|
||||||
|
|
||||||
|
This sub-project graduates that to a production-grade deployment on a single Ubuntu Linux server:
|
||||||
|
|
||||||
|
- **Two-container Docker Compose** (backend + frontend) replacing the dev-server split
|
||||||
|
- **App-layer authentication** with username + password, argon2id hashing, session cookies, and three fixed roles (admin / operator / viewer)
|
||||||
|
- **SQLCipher encryption at rest** for the SQLite DB, with the key as a Docker secret
|
||||||
|
- **Daily encrypted backups** with the existing AES-256-GCM `BackupService`, scheduler autostart, 14-day retention
|
||||||
|
- **LAN-only bind** (single operator, single Ubuntu server); VPN handles outside access
|
||||||
|
- **Manual `docker compose pull` updates** with semver tags; one-env-var rollback
|
||||||
|
- **Operator runbook** + bootstrap scripts so the deploy is reproducible
|
||||||
|
|
||||||
|
Real PHI flows through this deployment. The SFTP wire stays stubbed — operators download the corrected 837 ZIP and hand it to the Gainwell MFT UI manually. That's intentional and documented as v1 scope; SFTP wire is v2.
|
||||||
|
|
||||||
|
After this ships, the operator can:
|
||||||
|
1. `git clone <repo> /opt/cyclone && cd /opt/cyclone`
|
||||||
|
2. `docker compose build`
|
||||||
|
3. `bash scripts/cyclone-init.sh` → generates `/etc/cyclone/secrets/{db,secret,admin_pw}.key`, prints the admin password once
|
||||||
|
4. `docker compose up -d`
|
||||||
|
5. Open `http://<lan-ip>:8080/login` → log in as `admin`, change password, create operators
|
||||||
|
6. Upload parsed 837s, edit claims, export ZIPs, hand to MFT UI
|
||||||
|
7. Trust daily encrypted backups + the operator's off-box cron copy for ≤24h loss
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **Dockerize the existing stack** so it runs the same way in production as in development. One repo, one `docker compose up -d`.
|
||||||
|
2. **Add app-layer authentication** with username/password + 3-role RBAC, replacing the current "no auth at all" posture.
|
||||||
|
3. **Encrypt the SQLite DB at rest** via SQLCipher; key as a Docker secret (not an env var, not a keychain on macOS).
|
||||||
|
4. **Wire the existing encrypted backup system** to autostart on container boot with sane defaults (24h interval, 14-day retention).
|
||||||
|
5. **Add an operator runbook + bootstrap scripts** so the system is reproducible from `git clone` and supportable without tribal knowledge.
|
||||||
|
6. **Preserve all existing functionality** — the parser, editor, exporter, scheduler, audit log, backup/restore flow all keep working unchanged from the user's perspective.
|
||||||
|
|
||||||
|
## 3. Non-goals (this sub-project)
|
||||||
|
|
||||||
|
- **Real SFTP wire (paramiko)** — keep stub. Operators download ZIPs and use the MFT UI. Listed as v2.
|
||||||
|
- **Public TLS / public domain / Caddy reverse proxy** — LAN-only bind for v1; VPN handles outside access. Listed as v2.
|
||||||
|
- **Multi-host HA / DB replication / load balancing** — single Ubuntu server. Listed as v2.
|
||||||
|
- **Prometheus / Grafana / real alerting** — v1 uses the simple `curl healthz | mail` cron pattern. Listed as v2.
|
||||||
|
- **Off-box automated backup shipping** — operator's responsibility via host cron / rsync. Documented in runbook, not automated.
|
||||||
|
- **2FA / SSO / OAuth** — v2.
|
||||||
|
- **Self-service password reset** — v2; v1 admin resets via `cyclone admin reset-password` CLI.
|
||||||
|
- **Per-file encryption of prodfiles / sftp_staging volumes** — v2; v1 relies on volume-level filesystem permissions + the SQLCipher-encrypted DB + the host being physically secure.
|
||||||
|
- **LUKS full-disk encryption** — out of scope; assumed to be an operator-level concern (Ubuntu installer offers it).
|
||||||
|
- **Automatic update mechanism** (Watchtower etc.) — v1 is manual `docker compose pull`. Listed as v2.
|
||||||
|
- **New X12 transaction types or validation rules** — not this sub-project.
|
||||||
|
|
||||||
|
## 4. Stack
|
||||||
|
|
||||||
|
**Backend additions:**
|
||||||
|
- `argon2-cffi` (new dep) for password hashing
|
||||||
|
- `cyclone.auth` module: `User` / `Session` SQLAlchemy models, password hashing helpers, lockout logic
|
||||||
|
- `cyclone.api.auth` routes: `/api/auth/{login,logout,change-password,me}`
|
||||||
|
- `cyclone.api.users` routes: `/api/admin/users` (admin-only CRUD)
|
||||||
|
- `cyclone.cli` additions: `cyclone admin {create-user,reset-password,list-users}`
|
||||||
|
- Migration `0012_users_sessions.sql` for `users` + `sessions` tables
|
||||||
|
- Existing `BackupService` (SP17) and `audit_log` machinery reused unchanged
|
||||||
|
|
||||||
|
**Backend Dockerfile** (`backend/Dockerfile`):
|
||||||
|
- Multi-stage: builder stage installs `.[sqlcipher]` (sqlcipher is optional but recommended; the engine falls back to plain SQLite if the package isn't actually present, same graceful default as today), runtime stage copies installed site-packages + source
|
||||||
|
- Base: `python:3.11-slim-bookworm` (matches `requires-python = ">=3.11"`)
|
||||||
|
- Non-root user `cyclone` (uid 1000)
|
||||||
|
- `HEALTHCHECK CMD curl -fs http://localhost:8000/api/healthz || exit 1`
|
||||||
|
- Entrypoint: `python -m cyclone serve`
|
||||||
|
|
||||||
|
**Frontend additions:**
|
||||||
|
- `nginx.conf` for SPA routing + reverse proxy to backend
|
||||||
|
- `Dockerfile.frontend`: multi-stage builder (node:20-alpine runs `npm ci && npm run build`) + runtime (nginx:1.27-alpine serving `dist/`)
|
||||||
|
- `useCurrentUser()` hook (react-query)
|
||||||
|
- `Login.tsx` page
|
||||||
|
- `<AuthGate>` wrapper in `App.tsx`
|
||||||
|
- `<NavBar>` shows username + role badge + logout button
|
||||||
|
- `/admin/users` page (admin-only)
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- `docker-compose.yml` at repo root
|
||||||
|
- `scripts/cyclone-init.sh` — generates `/etc/cyclone/secrets/*` with `openssl rand -hex 32`
|
||||||
|
- `scripts/post-deploy.sh` — sets up logrotate + healthcheck cron on host
|
||||||
|
- `scripts/smoke.sh` — bring-up + login + parse + export end-to-end test
|
||||||
|
- `RUNBOOK.md` — daily/weekly/quarterly/as-needed ops procedures
|
||||||
|
- `.dockerignore` files for both build contexts
|
||||||
|
|
||||||
|
## 5. Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Ubuntu Linux server (your machine) │
|
||||||
|
│ │
|
||||||
|
│ cyclone_network (bridge) │
|
||||||
|
│ ┌─────────────────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ cyclone-backend │ │ cyclone-frontend │ │
|
||||||
|
│ │ FastAPI │◄───┤ nginx (SPA + proxy)│ │
|
||||||
|
│ │ :8000 (internal) │ │ :8080 (host) │ │
|
||||||
|
│ └──────┬──────────────┘ └─────────┬──────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
└─────────┼─────────────────────────────┼────────────┘
|
||||||
|
│ │
|
||||||
|
┌──────▼──────────────────┐ ┌──────▼──────────┐
|
||||||
|
│ Named volumes │ │ Bind mounts │
|
||||||
|
│ cyclone_db │ │ ./dist (built) │
|
||||||
|
│ cyclone_backups │ │ ./nginx.conf │
|
||||||
|
│ cyclone_prodfiles │ └─────────────────┘
|
||||||
|
│ cyclone_sftp_staging │
|
||||||
|
└─────────────────────────┘
|
||||||
|
|
||||||
|
/etc/cyclone/secrets/ (host, chmod 600, root:root)
|
||||||
|
├─ db.key → /run/secrets/cyclone_db_key
|
||||||
|
├─ secret.key → /run/secrets/cyclone_secret_key
|
||||||
|
└─ admin_pw → /run/secrets/cyclone_admin_password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why two containers:** nginx serves the React build faster than FastAPI would, decouples frontend rebuild cadence from backend release cadence, and is the standard ops pattern. nginx also reverse-proxies `/api/*` to the backend so the browser only talks to one origin (no CORS needed, no preflight requests for the `Cookie` header).
|
||||||
|
|
||||||
|
**Why LAN-only bind:** nginx listens on `0.0.0.0:8080` inside the host network namespace, but the operator is expected to bind to the LAN IP via firewall rules / not exposing on the WAN. VPN handles outside access. No public TLS needed for v1.
|
||||||
|
|
||||||
|
**Container-to-container networking:** the frontend container reaches the backend at `http://backend:8000` over the compose-managed bridge network. The browser only ever sees `http://<lan-ip>:8080`.
|
||||||
|
|
||||||
|
**Healthcheck:** container-level `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries. Compose `restart: unless-stopped` on healthcheck failure.
|
||||||
|
|
||||||
|
**Reverse proxy in nginx:**
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# API + auth: proxy to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 300s; # long enough for big parse streams
|
||||||
|
client_max_body_size 50m; # claims files can be large
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA: serve index.html for all non-/api routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Backend changes
|
||||||
|
|
||||||
|
### 6.1 New module `backend/src/cyclone/auth/__init__.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Role:
|
||||||
|
VIEWER = "viewer"
|
||||||
|
OPERATOR = "operator"
|
||||||
|
ADMIN = "admin"
|
||||||
|
|
||||||
|
ROLE_RANK = {Role.VIEWER: 0, Role.OPERATOR: 1, Role.ADMIN: 2}
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id: Mapped[str] # uuid4 hex
|
||||||
|
username: Mapped[str] # UNIQUE
|
||||||
|
password_hash: Mapped[str] # argon2id
|
||||||
|
role: Mapped[str] # viewer | operator | admin
|
||||||
|
created_at: Mapped[datetime]
|
||||||
|
last_login_at: Mapped[datetime | None]
|
||||||
|
failed_count: Mapped[int] = 0
|
||||||
|
locked_until: Mapped[datetime | None] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
id: Mapped[str] # 32-byte hex token
|
||||||
|
user_id: Mapped[str]
|
||||||
|
created_at: Mapped[datetime]
|
||||||
|
expires_at: Mapped[datetime]
|
||||||
|
ip: Mapped[str | None]
|
||||||
|
user_agent: Mapped[str | None]
|
||||||
|
```
|
||||||
|
|
||||||
|
Password hashing uses `argon2-cffi` with the OWASP-recommended parameters (`time_cost=2`, `memory_cost=19456`, `parallelism=1`, `hash_len=32`).
|
||||||
|
|
||||||
|
### 6.2 Auth routes in `backend/src/cyclone/api.py` (new router `auth_router`)
|
||||||
|
|
||||||
|
| Method | Path | Behavior | Auth |
|
||||||
|
|---|---|---|---|
|
||||||
|
| POST | `/api/auth/login` | `{username, password}` → verify argon2id, increment `failed_count` on miss (lock at 5/15min), create session, set `HttpOnly; Secure; SameSite=Lax` cookie | none |
|
||||||
|
| POST | `/api/auth/logout` | delete session row, clear cookie | any logged-in |
|
||||||
|
| GET | `/api/auth/me` | `{user: {username, role}}` or 401 | any logged-in |
|
||||||
|
| POST | `/api/auth/change-password` | `{old_password, new_password}` → verify, rehash | any logged-in |
|
||||||
|
| POST | `/api/admin/users` | `{username, password, role}` → create user | admin |
|
||||||
|
| GET | `/api/admin/users` | list users | admin |
|
||||||
|
| PATCH | `/api/admin/users/{id}` | `{role?, password?}` | admin |
|
||||||
|
| DELETE | `/api/admin/users/{id}` | soft-disable (set `disabled_at`) | admin |
|
||||||
|
|
||||||
|
### 6.3 The `require_role` dependency
|
||||||
|
|
||||||
|
Every existing protected route gets a `dependencies=[Depends(require_role(Role.OPERATOR))]` annotation:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def require_role(min_role: str):
|
||||||
|
"""FastAPI dependency factory: read cookie, load user, enforce role."""
|
||||||
|
def dep(request: Request) -> User:
|
||||||
|
sid = request.cookies.get("cyclone_session")
|
||||||
|
if not sid:
|
||||||
|
raise HTTPException(401, "not authenticated")
|
||||||
|
with db.SessionLocal()() as s:
|
||||||
|
sess = s.get(Session, sid)
|
||||||
|
if sess is None or sess.expires_at < utcnow():
|
||||||
|
raise HTTPException(401, "session expired")
|
||||||
|
user = s.get(User, sess.user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(401, "user gone")
|
||||||
|
if user.locked_until and user.locked_until > utcnow():
|
||||||
|
raise HTTPException(423, "account locked")
|
||||||
|
if ROLE_RANK[user.role] < ROLE_RANK[min_role]:
|
||||||
|
raise HTTPException(403, f"requires {min_role}")
|
||||||
|
request.state.user = user
|
||||||
|
request.state.actor = user.username # existing audit-log contract
|
||||||
|
return user
|
||||||
|
return dep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Role → action matrix:**
|
||||||
|
|
||||||
|
| Action | viewer | operator | admin |
|
||||||
|
|---|:-:|:-:|:-:|
|
||||||
|
| View claims, batches, acks | ✓ | ✓ | ✓ |
|
||||||
|
| `POST /api/parse-837`, `/api/parse-835` | — | ✓ | ✓ |
|
||||||
|
| Edit claim fields, resubmit rejected | — | ✓ | ✓ |
|
||||||
|
| `POST /api/batches/{id}/export-837` | — | ✓ | ✓ |
|
||||||
|
| `POST /api/clearhouse/submit` | — | ✓ | ✓ |
|
||||||
|
| `/api/admin/users` CRUD | — | — | ✓ |
|
||||||
|
| `/api/admin/backup/*` | — | — | ✓ |
|
||||||
|
| `/api/config/*` (clearhouse, payers) | — | — | ✓ |
|
||||||
|
| `/api/admin/audit-log` | — | — | ✓ |
|
||||||
|
| Backup scheduler on/off | — | — | ✓ |
|
||||||
|
|
||||||
|
**Login rate-limit:** the existing `cyclone.api.security.request_rejected` machinery already rate-limits by IP; we add per-username lockout on top:
|
||||||
|
- `failed_count >= 5` within 15min → `locked_until = now + 15min`
|
||||||
|
- Locked accounts return 423; the lock clears after the cooldown
|
||||||
|
|
||||||
|
### 6.4 Audit log wiring
|
||||||
|
|
||||||
|
The existing `audit_log.append(actor=..., ...)` calls already accept an actor string. The new auth dependency sets `request.state.actor = user.username`, and a small middleware reads it for every request. No audit-log schema change.
|
||||||
|
|
||||||
|
### 6.5 CLI additions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cyclone admin create-user --username X --role admin --password <from_stdin or --password-file>
|
||||||
|
cyclone admin reset-password --username X --new-password <from_stdin or --password-file>
|
||||||
|
cyclone admin list-users
|
||||||
|
```
|
||||||
|
|
||||||
|
Used by `scripts/cyclone-init.sh` for the bootstrap admin, and by the operator for password recovery.
|
||||||
|
|
||||||
|
### 6.6 Migration `0012_users_sessions.sql`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('admin', 'operator', 'viewer')),
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
last_login_at TIMESTAMP,
|
||||||
|
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_until TIMESTAMP,
|
||||||
|
disabled_at TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_users_username ON users(username);
|
||||||
|
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP NOT NULL,
|
||||||
|
expires_at TIMESTAMP NOT NULL,
|
||||||
|
ip TEXT,
|
||||||
|
user_agent TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_user_id ON sessions(user_id);
|
||||||
|
CREATE INDEX idx_sessions_expires_at ON sessions(expires_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.7 Session timeout config
|
||||||
|
|
||||||
|
| Setting | Default | Env var |
|
||||||
|
|---|---|---|
|
||||||
|
| Session absolute lifetime | 30 days | `CYCLONE_SESSION_ABSOLUTE_DAYS` |
|
||||||
|
| Session sliding expiry | 8 hours | `CYCLONE_SESSION_SLIDING_HOURS` |
|
||||||
|
| Cookie name | `cyclone_session` | `CYCLONE_COOKIE_NAME` |
|
||||||
|
| Cookie `Secure` flag | true | always true in prod |
|
||||||
|
|
||||||
|
### 6.8 Backend Dockerfile
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Builder stage
|
||||||
|
FROM python:3.11-slim-bookworm AS builder
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential libsqlcipher-dev libffi-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
WORKDIR /build
|
||||||
|
COPY pyproject.toml .
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher]'
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM python:3.11-slim-bookworm
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libsqlcipher-dev curl tini \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --create-home --uid 1000 cyclone
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=builder /wheels /wheels
|
||||||
|
RUN pip install --no-cache-dir --no-index --find-links /wheels cyclone
|
||||||
|
COPY src/ /app/src/
|
||||||
|
USER cyclone
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD curl -fs http://localhost:8000/api/healthz || exit 1
|
||||||
|
ENTRYPOINT ["tini", "--"]
|
||||||
|
CMD ["python", "-m", "cyclone", "serve"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`sqlcipher` is included but the engine falls back to plain SQLite if the package isn't actually installed — same graceful default as today, just biased toward enabling encryption in prod.
|
||||||
|
|
||||||
|
## 7. Frontend changes
|
||||||
|
|
||||||
|
### 7.1 New login page `src/pages/Login.tsx`
|
||||||
|
|
||||||
|
A simple form: username + password inputs, submit button, error message slot. On success, navigate to `/`. Lives outside the auth-gated shell, served from a route that doesn't require authentication.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
export default function Login() {
|
||||||
|
const login = useMutation({
|
||||||
|
mutationFn: ({ username, password }) => api.login(username, password),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['me'] }),
|
||||||
|
});
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 `useCurrentUser` hook
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export function useCurrentUser() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['me'],
|
||||||
|
queryFn: () => api.me(),
|
||||||
|
retry: false,
|
||||||
|
staleTime: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.3 AuthGate in `App.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { data: user, isLoading } = useCurrentUser();
|
||||||
|
const location = useLocation();
|
||||||
|
if (isLoading) return <FullPageSkeleton />;
|
||||||
|
if (!user) return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap every route except `/login` in `<AuthGate>`. `/login` itself does *not* require auth.
|
||||||
|
|
||||||
|
### 7.4 Role-based UI gating
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const ROLE_RANK = { viewer: 0, operator: 1, admin: 2 };
|
||||||
|
export function useCan(minRole: 'viewer' | 'operator' | 'admin'): boolean {
|
||||||
|
const { data: user } = useCurrentUser();
|
||||||
|
if (!user) return false;
|
||||||
|
return ROLE_RANK[user.role] >= ROLE_RANK[minRole];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Used in NavBar to hide "Admin" links from operators/viewers, and on the `/admin/users` page as a guard (`if (!useCan('admin')) return <Forbidden />`).
|
||||||
|
|
||||||
|
### 7.5 NavBar changes
|
||||||
|
|
||||||
|
- Username + role badge (admin/operator/viewer) shown right-aligned
|
||||||
|
- Logout button (calls `api.logout()`, clears react-query cache, navigates to `/login`)
|
||||||
|
- "Admin" dropdown appears only when `useCan('admin')`
|
||||||
|
|
||||||
|
### 7.6 `/admin/users` page (admin only)
|
||||||
|
|
||||||
|
Table of users (username, role, last login, status). Actions:
|
||||||
|
- **Create user** (modal with username + password + role)
|
||||||
|
- **Edit role** (inline select)
|
||||||
|
- **Reset password** (modal)
|
||||||
|
- **Disable / enable** (toggle; sets `disabled_at`)
|
||||||
|
|
||||||
|
### 7.7 api.ts additions
|
||||||
|
|
||||||
|
```ts
|
||||||
|
api.login(username: string, password: string): Promise<{ user: User }>
|
||||||
|
api.logout(): Promise<void>
|
||||||
|
api.me(): Promise<{ user: User }> // throws 401 if not logged in
|
||||||
|
api.changePassword(oldPw: string, newPw: string): Promise<void>
|
||||||
|
api.listUsers(): Promise<User[]>
|
||||||
|
api.createUser(...): Promise<User>
|
||||||
|
api.updateUser(id: string, ...): Promise<User>
|
||||||
|
api.deleteUser(id: string): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
All `api.*` calls send `credentials: 'include'` so the session cookie flows.
|
||||||
|
|
||||||
|
### 7.8 Frontend Dockerfile + nginx.conf
|
||||||
|
|
||||||
|
`frontend/Dockerfile`:
|
||||||
|
```dockerfile
|
||||||
|
# Build stage
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /build
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=builder /build/dist /usr/share/nginx/html
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:8080/ >/dev/null || exit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The nginx config shown in §5 lives at `frontend/nginx.conf`.
|
||||||
|
|
||||||
|
## 8. Data, secrets, backups
|
||||||
|
|
||||||
|
### 8.1 Named volumes
|
||||||
|
|
||||||
|
| Volume | Mount path in container | Contents |
|
||||||
|
|---|---|---|
|
||||||
|
| `cyclone_db` | `/var/lib/cyclone/db/cyclone.db` | SQLCipher-encrypted SQLite |
|
||||||
|
| `cyclone_backups` | `/var/lib/cyclone/backups/` | AES-256-GCM `.bin` + `.meta.json` |
|
||||||
|
| `cyclone_prodfiles` | `/var/lib/cyclone/prodfiles/` | Uploaded 837 `.txt`, downloaded 835 |
|
||||||
|
| `cyclone_sftp_staging` | `/var/lib/cyclone/sftp_staging/` | SFTP stub's outbound/inbound |
|
||||||
|
|
||||||
|
These are managed by Docker (`docker volume create` / compose `volumes:`); they live under `/var/lib/docker/volumes/` on the host.
|
||||||
|
|
||||||
|
### 8.2 Docker secrets (host-managed)
|
||||||
|
|
||||||
|
| File | Mounted as | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/etc/cyclone/secrets/db.key` | `/run/secrets/cyclone_db_key` | SQLCipher `PRAGMA key` — 64 hex chars |
|
||||||
|
| `/etc/cyclone/secrets/secret.key` | `/run/secrets/cyclone_secret_key` | Cookie signing key — 64 hex chars |
|
||||||
|
| `/etc/cyclone/secrets/admin_pw` | `/run/secrets/cyclone_admin_password` | One-time bootstrap admin password |
|
||||||
|
|
||||||
|
`scripts/cyclone-init.sh` generates all three with `openssl rand -hex 32` (db.key + secret.key) and a memorable-but-random admin password, sets permissions to `chmod 600 root:root`, and prints the admin password once.
|
||||||
|
|
||||||
|
### 8.3 Backup strategy (≤24h loss is acceptable)
|
||||||
|
|
||||||
|
- The existing `BackupService` (SP17) is already implemented and tested: takes online snapshot via SQLite `.backup()` API, encrypts with AES-256-GCM, writes `.bin` + `.meta.json` in `cyclone_backups` volume
|
||||||
|
- `CYCLONE_BACKUP_AUTOSTART=1` in compose starts the in-container backup scheduler on container start
|
||||||
|
- `CYCLONE_BACKUP_INTERVAL_HOURS=24` (default)
|
||||||
|
- 14-day retention (default; configurable via `CYCLONE_BACKUP_RETENTION_DAYS`)
|
||||||
|
- Restore via existing `/api/admin/backup/{id}/restore/{initiate,confirm}` (admin-only after auth is wired)
|
||||||
|
- Existing audit-log call `actor="backup-scheduler"` becomes `actor="backup-scheduler"` (unchanged — system actor, not a user)
|
||||||
|
|
||||||
|
**Off-box backup copy** (operator's responsibility, documented in runbook):
|
||||||
|
|
||||||
|
The operator sets up a host cron / systemd timer that rsyncs `/var/lib/docker/volumes/cyclone_backups/_data/` to an external drive or NAS nightly. The `.bin` files are already encrypted so the destination doesn't need its own encryption. Documented in `RUNBOOK.md` as a required post-deploy step.
|
||||||
|
|
||||||
|
### 8.4 Data loss scenarios
|
||||||
|
|
||||||
|
| Scenario | Outcome |
|
||||||
|
|---|---|
|
||||||
|
| Container crashes | Docker restarts; SQLite WAL recovers; no loss |
|
||||||
|
| Disk corruption | Restore from latest local backup (≤24h old) |
|
||||||
|
| Disk total loss | Restore from off-box backup copy (depends on operator's cron cadence) |
|
||||||
|
| DB key compromise | Rotate via `POST /api/admin/db/rotate-key`; old backups re-encrypted on next cycle |
|
||||||
|
| Operator forgets admin password | Reset via `cyclone admin reset-password --username admin --new-password X` |
|
||||||
|
|
||||||
|
### 8.5 Encryption-at-rest coverage
|
||||||
|
|
||||||
|
| Asset | Encrypted at rest (v1) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Live SQLite DB | ✓ SQLCipher | AES-256, key in Docker secret |
|
||||||
|
| Backup `.bin` files | ✓ AES-256-GCM | Key from backup passphrase |
|
||||||
|
| Prodfiles (uploaded 837s) | — plain on disk | v2: per-file encryption or move to DB |
|
||||||
|
| sftp_staging | — plain on disk | v2: per-file encryption |
|
||||||
|
| Log files | — plain on disk | rotated, contained |
|
||||||
|
|
||||||
|
v1 assumes the host is physically secure (single-operator server, locked room) and that LUKS is operator-level.
|
||||||
|
|
||||||
|
## 9. Logging, monitoring, updates, ops
|
||||||
|
|
||||||
|
### 9.1 Logging
|
||||||
|
|
||||||
|
- JSON to stdout (already configured via `logging_config.py`) — Docker collects via `docker logs`
|
||||||
|
- Bind-mount `/var/log/cyclone/` from the host so logrotate can manage retention (configured in `scripts/post-deploy.sh`)
|
||||||
|
- Log levels: INFO in prod, DEBUG opt-in via `CYCLONE_LOG_LEVEL=DEBUG`
|
||||||
|
- Every login event (success + failure) logged with username, ip, user-agent, role
|
||||||
|
- Every state-changing API call carries `actor=<user_id>` (existing audit-log machinery; we wire the auth user into `request.state.actor`)
|
||||||
|
- New audit events: `auth.login`, `auth.logout`, `auth.login_failed`, `auth.password_changed`, `auth.user_created`, `auth.user_updated`, `auth.user_disabled`
|
||||||
|
|
||||||
|
### 9.2 Healthcheck
|
||||||
|
|
||||||
|
- Container-level: `curl -fs http://localhost:8000/api/healthz` every 30s, 3 retries (configured in Dockerfile)
|
||||||
|
- Compose `restart: unless-stopped` on healthcheck failure (max 3 restarts then backoff)
|
||||||
|
- `GET /api/healthz` returns `{db_ok: bool, scheduler_running: bool, last_backup_at: timestamp}` (existing endpoint, unchanged)
|
||||||
|
|
||||||
|
### 9.3 Monitoring (v1 minimal)
|
||||||
|
|
||||||
|
- No Prometheus/Grafana in v1
|
||||||
|
- Host-level cron: `curl -fs http://127.0.0.1:8080/api/healthz >/dev/null || echo "Cyclone down at $(date)" | mail -s "cyclone DOWN" you@example.com`
|
||||||
|
- Set up by `scripts/post-deploy.sh` during initial bootstrap
|
||||||
|
|
||||||
|
### 9.4 Updates
|
||||||
|
|
||||||
|
- Images tagged semver from `package.json` / `pyproject.toml`: `cyclone-backend:0.1.0`, `cyclone-frontend:0.1.0`
|
||||||
|
- Two tag aliases per image:
|
||||||
|
- `latest` — set automatically by `docker compose build` (most recent build)
|
||||||
|
- `stable` — manually promoted by the operator once a release has run in prod for ≥1 week without issues, via `docker tag cyclone-backend:0.1.0 cyclone-backend:stable && docker tag cyclone-frontend:0.1.0 cyclone-frontend:stable`. Compose defaults to `:stable` so a new build doesn't get auto-rolled-out.
|
||||||
|
- Update procedure (to pick up a new `:stable`):
|
||||||
|
```bash
|
||||||
|
cd /opt/cyclone
|
||||||
|
docker compose pull # pulls newer :stable tags
|
||||||
|
docker compose up -d # recreates containers, preserves volumes + secrets
|
||||||
|
```
|
||||||
|
- DB migrations run automatically on backend start; migrations are forward-only with documented downgrade procedures (existing pattern)
|
||||||
|
- Rollback: `TAG=0.0.9 docker compose up -d` (one env var override; compose reads `${TAG:-stable}` for the image tag)
|
||||||
|
- Previous image stays in Docker's local cache for one cycle so rollback is offline
|
||||||
|
|
||||||
|
### 9.5 Initial bootstrap
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> /opt/cyclone && cd /opt/cyclone
|
||||||
|
docker compose build # or: docker compose pull
|
||||||
|
bash scripts/cyclone-init.sh # generates /etc/cyclone/secrets/*, prints admin password
|
||||||
|
docker compose up -d # starts both containers
|
||||||
|
# wait ~10s for migrations
|
||||||
|
docker compose exec backend cyclone admin create-user \
|
||||||
|
--username admin --role admin --password-file /run/secrets/cyclone_admin_password
|
||||||
|
# open browser to http://<lan-ip>:8080/login
|
||||||
|
# log in as admin, change password, create operators
|
||||||
|
bash scripts/post-deploy.sh # logrotate + healthcheck cron
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.6 Daily ops (operator runbook)
|
||||||
|
|
||||||
|
- **Daily:** check `GET /api/healthz` (or trust the healthcheck email)
|
||||||
|
- **Weekly:** review `/admin/audit-log` page for unusual activity
|
||||||
|
- **Quarterly:** rotate DB key via `POST /api/admin/db/rotate-key`
|
||||||
|
- **As needed:** create operators via `/admin/users`; restore from backup via the restore UI
|
||||||
|
- **Annual:** rotate cookie signing key (re-login all users)
|
||||||
|
|
||||||
|
Full procedures live in `RUNBOOK.md` shipped in the repo.
|
||||||
|
|
||||||
|
## 10. docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: cyclone
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
image: cyclone-backend:${TAG:-stable}
|
||||||
|
build: ./backend
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
CYCLONE_DB_URL: "sqlite:////var/lib/cyclone/db/cyclone.db"
|
||||||
|
CYCLONE_BACKUP_AUTOSTART: "1"
|
||||||
|
CYCLONE_BACKUP_INTERVAL_HOURS: "24"
|
||||||
|
CYCLONE_BACKUP_RETENTION_DAYS: "14"
|
||||||
|
CYCLONE_LOG_LEVEL: "INFO"
|
||||||
|
CYCLONE_LOG_FILE: "/var/log/cyclone/cyclone.log"
|
||||||
|
CYCLONE_LOG_JSON: "1"
|
||||||
|
CYCLONE_SESSION_ABSOLUTE_DAYS: "30"
|
||||||
|
CYCLONE_SESSION_SLIDING_HOURS: "8"
|
||||||
|
CYCLONE_COOKIE_SECURE: "1"
|
||||||
|
secrets:
|
||||||
|
- cyclone_db_key
|
||||||
|
- cyclone_secret_key
|
||||||
|
volumes:
|
||||||
|
- cyclone_db:/var/lib/cyclone/db
|
||||||
|
- cyclone_backups:/var/lib/cyclone/backups
|
||||||
|
- cyclone_prodfiles:/var/lib/cyclone/prodfiles
|
||||||
|
- cyclone_sftp_staging:/var/lib/cyclone/sftp_staging
|
||||||
|
- cyclone_logs:/var/log/cyclone
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-fs", "http://localhost:8000/api/healthz"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
networks: [cyclone_network]
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: cyclone-frontend:${TAG:-stable}
|
||||||
|
build: ./frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
networks: [cyclone_network]
|
||||||
|
|
||||||
|
secrets:
|
||||||
|
cyclone_db_key:
|
||||||
|
file: /etc/cyclone/secrets/db.key
|
||||||
|
cyclone_secret_key:
|
||||||
|
file: /etc/cyclone/secrets/secret.key
|
||||||
|
cyclone_admin_password:
|
||||||
|
file: /etc/cyclone/secrets/admin_pw
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
cyclone_db:
|
||||||
|
cyclone_backups:
|
||||||
|
cyclone_prodfiles:
|
||||||
|
cyclone_sftp_staging:
|
||||||
|
cyclone_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
cyclone_network:
|
||||||
|
driver: bridge
|
||||||
|
```
|
||||||
|
|
||||||
|
The `8080:8080` port is the only one published to the host. The operator is expected to bind to the LAN IP at the firewall level (UFW rules or similar).
|
||||||
|
|
||||||
|
## 11. Error handling
|
||||||
|
|
||||||
|
- **Login failures:** increment `failed_count`; lock at 5 fails / 15min; log `auth.login_failed`; 423 on locked accounts.
|
||||||
|
- **Session expiry:** 401; frontend redirects to `/login` with `state.from` preserved.
|
||||||
|
- **Missing role:** 403 with explicit message; UI shows a "Forbidden" page instead of trying to render.
|
||||||
|
- **Backend crash:** Docker healthcheck restarts the container; SQLite WAL recovers in-flight writes.
|
||||||
|
- **DB key missing:** container starts but `db.is_encryption_enabled()` returns False; `BackupService` refuses to run (existing behavior); compose logs a warning.
|
||||||
|
- **Migration failure on startup:** container exits with non-zero; compose `restart: unless-stopped` backs off after 3 attempts; operator checks logs via `docker compose logs backend`.
|
||||||
|
- **Backup failure:** logged at ERROR; audit event `backup.failed`; existing retry behavior in `BackupService`.
|
||||||
|
|
||||||
|
## 12. Testing
|
||||||
|
|
||||||
|
### 12.1 New unit tests
|
||||||
|
|
||||||
|
- `tests/test_auth.py` (~12 tests)
|
||||||
|
- `test_create_user_with_argon2_hash`
|
||||||
|
- `test_login_with_correct_password_returns_session`
|
||||||
|
- `test_login_with_wrong_password_increments_failed_count`
|
||||||
|
- `test_login_locks_account_after_5_failures`
|
||||||
|
- `test_login_lock_clears_after_15min`
|
||||||
|
- `test_session_cookie_is_httponly_secure_samesite_lax`
|
||||||
|
- `test_session_expires_after_sliding_window`
|
||||||
|
- `test_session_absolute_lifetime_enforced`
|
||||||
|
- `test_logout_deletes_session_and_clears_cookie`
|
||||||
|
- `test_change_password_invalidates_other_sessions`
|
||||||
|
- `test_require_role_rejects_below_minimum`
|
||||||
|
- `test_require_role_returns_user_object_to_dependency`
|
||||||
|
- `tests/test_users.py` (~6 tests)
|
||||||
|
- `test_admin_can_create_user`
|
||||||
|
- `test_operator_cannot_create_user`
|
||||||
|
- `test_admin_can_change_user_role`
|
||||||
|
- `test_admin_can_disable_user`
|
||||||
|
- `test_disabled_user_cannot_login`
|
||||||
|
- `test_audit_event_for_user_lifecycle`
|
||||||
|
|
||||||
|
Total new backend tests: **~18**.
|
||||||
|
|
||||||
|
### 12.2 Docker smoke tests
|
||||||
|
|
||||||
|
- `tests/test_docker.py` (~4 tests)
|
||||||
|
- `test_compose_config_validates`
|
||||||
|
- `test_dockerfile_backend_builds`
|
||||||
|
- `test_dockerfile_frontend_builds`
|
||||||
|
- `test_compose_up_brings_up_healthy_stack` (uses `testcontainers-python` or skips if not available; gated on a `DOCKER_TESTS=1` env var)
|
||||||
|
|
||||||
|
### 12.3 Smoke script
|
||||||
|
|
||||||
|
`scripts/smoke.sh`:
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 1. Bring up stack
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 2. Wait for healthcheck
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -fs http://127.0.0.1:8080/api/healthz > /dev/null; then break; fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# 3. Login
|
||||||
|
COOKIE_JAR=$(mktemp)
|
||||||
|
curl -fs -c "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/auth/login \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"username\":\"admin\",\"password\":\"$(cat /etc/cyclone/secrets/admin_pw)\"}"
|
||||||
|
|
||||||
|
# 4. Parse a sample 837
|
||||||
|
curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/parse-837 \
|
||||||
|
-F "file=@docs/goodclaim.x12"
|
||||||
|
|
||||||
|
# 5. List batches
|
||||||
|
curl -fs -b "$COOKIE_JAR" http://127.0.0.1:8080/api/batches
|
||||||
|
|
||||||
|
# 6. Export
|
||||||
|
curl -fs -b "$COOKIE_JAR" -X POST http://127.0.0.1:8080/api/batches/<id>/export-837 \
|
||||||
|
-H 'Content-Type: application/json' -d '{"claim_ids":["..."]}' \
|
||||||
|
-o /tmp/export.zip
|
||||||
|
|
||||||
|
unzip -l /tmp/export.zip | grep -E '\.x12$'
|
||||||
|
|
||||||
|
echo "smoke: OK"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 12.4 Existing tests
|
||||||
|
|
||||||
|
- All existing backend tests must continue to pass
|
||||||
|
- All existing frontend tests must continue to pass
|
||||||
|
- Pre-existing prodfile smoke failures (rate-limit / orphan-set refs on 999/TA1) remain pre-existing and out of scope
|
||||||
|
|
||||||
|
## 13. Migration / rollout
|
||||||
|
|
||||||
|
### 13.1 First-time deploy
|
||||||
|
|
||||||
|
For a brand-new install on a fresh Ubuntu 22.04 server, follow §9.5 above. No data to migrate — this is the first deployment.
|
||||||
|
|
||||||
|
### 13.2 Upgrading an existing dev install
|
||||||
|
|
||||||
|
If an operator has been running `python -m cyclone serve` locally with the in-memory store or a plaintext SQLite file, the upgrade path is:
|
||||||
|
|
||||||
|
1. **Stop the existing server.**
|
||||||
|
2. **Export any production data:** SQLite is at `~/.local/share/cyclone/cyclone.db`. The operator takes a final plaintext backup *before* the SQLCipher transition; we don't try to encrypt-in-place (would need a different migration).
|
||||||
|
3. **Run `scripts/cyclone-init.sh`** to generate secrets.
|
||||||
|
4. **Run `docker compose up -d`** — the backend starts with an empty SQLCipher DB. Migrations run.
|
||||||
|
5. **Bootstrap admin user** via the CLI in the container.
|
||||||
|
6. **Re-import data** from the plaintext export (manual step; documented in RUNBOOK).
|
||||||
|
|
||||||
|
The in-memory store from the parent spec is *lost* on upgrade (by design — local-only). The plaintext SQLite DB *can* be carried over manually if needed.
|
||||||
|
|
||||||
|
### 13.3 Updating the running stack
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/cyclone
|
||||||
|
docker compose pull # or: docker compose build
|
||||||
|
docker compose up -d # recreates containers
|
||||||
|
docker compose logs -f backend | head -200 # verify migrations + healthcheck
|
||||||
|
```
|
||||||
|
|
||||||
|
DB migrations run automatically on backend start; they're forward-only. To roll back the code (not the schema): `TAG=0.0.9 docker compose up -d`.
|
||||||
|
|
||||||
|
## 14. Out of scope (explicit)
|
||||||
|
|
||||||
|
- Real SFTP wire (paramiko)
|
||||||
|
- Public TLS / domain / Caddy reverse proxy in compose
|
||||||
|
- Multi-host HA / DB replication / load balancing
|
||||||
|
- Prometheus / Grafana / alerting beyond the simple email cron
|
||||||
|
- Off-box automated backup shipping (operator's cron)
|
||||||
|
- 2FA / SSO / OAuth
|
||||||
|
- Self-service password reset
|
||||||
|
- Per-file encryption of prodfiles / sftp_staging
|
||||||
|
- LUKS full-disk encryption
|
||||||
|
- Watchtower / automatic updates
|
||||||
|
- Linting/pre-commit/dev tooling polish
|
||||||
|
- Component / E2E browser tests (Playwright)
|
||||||
|
|
||||||
|
## 15. Acceptance checklist
|
||||||
|
|
||||||
|
### 15.1 Backend
|
||||||
|
|
||||||
|
- [ ] `pytest tests/test_auth.py tests/test_users.py tests/test_docker.py -v` passes
|
||||||
|
- [ ] All previously-passing tests still pass; new total ≥ previous + 18
|
||||||
|
- [ ] `docker compose config` validates with no errors
|
||||||
|
- [ ] `docker compose build` succeeds for both services
|
||||||
|
- [ ] `docker compose up -d` brings both containers to `healthy` within 60s
|
||||||
|
- [ ] `curl http://127.0.0.1:8080/api/healthz` returns `{db_ok: true, scheduler_running: true}`
|
||||||
|
|
||||||
|
### 15.2 Auth + RBAC
|
||||||
|
|
||||||
|
- [ ] `POST /api/auth/login` with correct creds sets `cyclone_session` cookie (HttpOnly, Secure, SameSite=Lax) and returns `{user: {...}}`
|
||||||
|
- [ ] 5 wrong logins within 15min locks the account; 423 returned
|
||||||
|
- [ ] `GET /api/batches` without session cookie returns 401
|
||||||
|
- [ ] `GET /api/admin/users` as operator returns 403; as admin returns list
|
||||||
|
- [ ] Logout clears the cookie and invalidates the session in the DB
|
||||||
|
- [ ] Password change invalidates other sessions for that user
|
||||||
|
|
||||||
|
### 15.3 Encryption + backups
|
||||||
|
|
||||||
|
- [ ] `cyclone.db_crypto.is_encryption_enabled()` returns True in container
|
||||||
|
- [ ] Inspecting the DB file on disk shows binary ciphertext, not plaintext
|
||||||
|
- [ ] `POST /api/admin/backup/create` creates an encrypted `.bin` in `/var/lib/cyclone/backups/`
|
||||||
|
- [ ] Backup scheduler runs every 24h (`docker compose logs backend | grep backup-scheduler`)
|
||||||
|
- [ ] Restore flow works end-to-end: create backup → wipe DB → restore → verify data present
|
||||||
|
|
||||||
|
### 15.4 Frontend
|
||||||
|
|
||||||
|
- [ ] Unauthenticated browser request to `/` redirects to `/login`
|
||||||
|
- [ ] Login as admin → land on `/`; NavBar shows username + role
|
||||||
|
- [ ] Operator does not see "Admin" link in NavBar
|
||||||
|
- [ ] `/admin/users` as operator shows "Forbidden" page
|
||||||
|
- [ ] Logout button clears session and returns to `/login`
|
||||||
|
- [ ] `npm run build` produces a `dist/` that serves correctly from nginx
|
||||||
|
|
||||||
|
### 15.5 Smoke
|
||||||
|
|
||||||
|
- [ ] `scripts/smoke.sh` passes end-to-end against a fresh stack
|
||||||
|
- [ ] `RUNBOOK.md` exists and contains the §9.6 procedures
|
||||||
|
- [ ] `scripts/post-deploy.sh` sets up logrotate + healthcheck cron without errors
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Cyclone Auth Posture Alignment — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-23
|
||||||
|
**Status:** Approved (in implementation on `sp24-doc-posture-alignment`, 2026-06-23)
|
||||||
|
**Branch:** `sp24-doc-posture-alignment` (off `css-reduction` — `docs/REQUIREMENTS.md` and `docs/ARCHITECTURE.md` were added on `css-reduction` and do not yet exist on `main`)
|
||||||
|
**Depends on:** The auth work that landed in main via the merge of `origin/main` on 2026-06-23 (commits `a25504b`..`39ae988`, 14 commits, the `feat(auth):`/`fix(auth):`/etc. series on `auth-rebased`).
|
||||||
|
**Replaces:** The "no auth, no LAN-bind, single operator" claim in `CLAUDE.md`, `docs/REQUIREMENTS.md`, and `docs/ARCHITECTURE.md` — the codebase no longer matches the doc and the divergence needs to be resolved one way or the other.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The auth work that landed in main is materially more than a stub. The codebase now has:
|
||||||
|
|
||||||
|
- A `users` and `sessions` SQLAlchemy model (migrations 0013 + 0014)
|
||||||
|
- A `User.password_hash` field using bcrypt
|
||||||
|
- An `auth_router` exposing `/api/auth/login`, `/api/auth/logout`, `/api/auth/me`
|
||||||
|
- A `matrix_gate` FastAPI dependency and a permissions matrix that gates every existing endpoint by role (admin / user / viewer)
|
||||||
|
- An `AUTH_DISABLED` flag (module-level in `cyclone.auth.deps`, also settable via `CYCLONE_AUTH_DISABLED=1` at boot) that the conftest autouse fixture flips to `True` so the test suite can run without a login round-trip per request
|
||||||
|
- A `RateLimitMiddleware` (SP19) that the conftest bypasses via `app.state.testing`
|
||||||
|
- A `RequireAuth` route guard and an `AuthProvider` React context on the frontend
|
||||||
|
- A sidebar user indicator + logout button on the shell
|
||||||
|
- A `cyclone admin` CLI for creating users and rotating passwords
|
||||||
|
|
||||||
|
But `CLAUDE.md`, `docs/REQUIREMENTS.md`, and `docs/ARCHITECTURE.md` (all three added or updated on the `css-reduction` branch in commit `12311bf` / `a39bfb2`) still say:
|
||||||
|
|
||||||
|
- `CLAUDE.md:7` — "Local-only by design: binds to `127.0.0.1`, **no auth**, no internet exposure."
|
||||||
|
- `CLAUDE.md:182` — "**Local-only by design.** The backend binds to `127.0.0.1`, has no auth, and the threat model is a stolen/imaged drive, not a remote attacker. **Don't add internet exposure or auth without an explicit spec.**"
|
||||||
|
- `docs/REQUIREMENTS.md:36` — "**Local-only on purpose:** binds `127.0.0.1`, **no auth**, no internet exposure, single operator, single machine, one trading partner"
|
||||||
|
- `docs/REQUIREMENTS.md:39` — "**Threat model:** a process on the same host. No second party to authenticate; no second host to harden against."
|
||||||
|
- `docs/REQUIREMENTS.md:154` — NFR-1: "no auth, no API key"
|
||||||
|
- `docs/ARCHITECTURE.md:16` — "has no authentication because the threat model is a stolen or imaged drive, not a remote attacker"
|
||||||
|
- `docs/ARCHITECTURE.md:703` — "Multi-user / multi-host — **no auth**, no LAN-bind, no Docker, no reverse proxy. The operator is one person, the host is one machine. (SP23 fork if this changes.)"
|
||||||
|
|
||||||
|
The pre-auth claims in the docs are now false. The threat model is no longer "a process on the same host" — there is at minimum a password boundary that requires a deliberate authentication step before any data is returned.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. **Decide the canonical auth posture.** Is this v1 with a single local operator who has to log in, or is it the first step toward SP23 (Ubuntu + Docker + RBAC + LAN-bind)? Pick one and write it down; the docs and the code must agree.
|
||||||
|
2. **Update the three stale top-level docs** (`CLAUDE.md`, `docs/REQUIREMENTS.md`, `docs/ARCHITECTURE.md`) to reflect the chosen posture. Every "no auth" claim is replaced with the truth; every "single operator, single host" claim is qualified by the new auth boundary.
|
||||||
|
3. **Update the relevant Cyclone superpowers skills** that still describe the pre-auth system: at minimum `cyclone-tests` (conftest's `AUTH_DISABLED` flag is now mandatory context for any new test file), `cyclone-api-router` (the `matrix_gate` dependency is now part of every router), and `cyclone-spec` (the SP-N spec template's "threat model" section needs an auth-aware example).
|
||||||
|
4. **Make `AUTH_DISABLED` discoverable in the conftest.** The conftest autouse fixture flips `cyclone.auth.deps.AUTH_DISABLED = True` for every test, and the test suite relies on this. A new test file that imports `cyclone.auth.deps` and reads the flag directly will see it as `True` and may make wrong assumptions. A `cyclone.auth.testing` helper that documents "this is what the test suite does to the auth flag, and how to opt out per-test" would make the dependency explicit.
|
||||||
|
5. **Tighten the migration sequence.** Migrations 0013 and 0014 (auth_users_and_sessions, audit_log_user_id) are auth-related. They currently have no docstring beyond the filename. The migrations table in `docs/ARCHITECTURE.md` should list them and explain the dependency (the `audit_log.user_id` FK in 0014 is a forward reference to `users.id` from 0013, so the renumbering on the `claims-unique-fix` branch — 0013/0014 → 0015/0016 — was necessary to keep the auth migrations at the front of the chain).
|
||||||
|
|
||||||
|
## 3. Non-goals
|
||||||
|
|
||||||
|
- **Reverting the auth work.** It ships in main, it's tested, and the operator can no longer use Cyclone without logging in. Going back is a separate decision the user would have to ask for explicitly; this SP assumes the auth work stays.
|
||||||
|
- **Shipping SP23 (Ubuntu + Docker + LAN-bind).** SP23 is its own design (`docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md`) and is still "awaiting user decision." This SP only reconciles the doc/code divergence that the auth-merge created; it does not advance the LAN-bind / Docker conversation.
|
||||||
|
- **Adding new auth features.** No SSO, no MFA, no per-claim ACL, no audit log viewer UI. The auth in main is what's documented; this SP just describes it.
|
||||||
|
- **Changing the data model.** The `users` / `sessions` / `audit_log.user_id` schema stays. The bcrypt-hashed password and HttpOnly session cookie stay. The `admin` / `user` / `viewer` role enum stays.
|
||||||
|
|
||||||
|
## 4. Decisions to make (questions for the user)
|
||||||
|
|
||||||
|
These are the points that the spec can't answer on its own. They each have a default that matches the auth work in main, but the user may want to override.
|
||||||
|
|
||||||
|
1. **What is the v1 posture?** The auth work in main binds to `127.0.0.1` (still) and gates every endpoint behind `matrix_gate` (now). The default reading is "single-operator local-only, but with a login screen." Alternatives: revert the auth (separate decision), or treat this as a stepping stone to SP23.
|
||||||
|
- **Default:** v1 = single-operator local-only with auth. SP23 is a future decision.
|
||||||
|
2. **How is the first admin created?** `cyclone.auth.bootstrap` reads `CYCLONE_ADMIN_USERNAME` + `CYCLONE_ADMIN_PASSWORD` from the env and creates the row on first boot (no users exist) — see `backend/src/cyclone/auth/bootstrap.py:45-65`. If the env vars are unset and no users exist, `cyclone serve` errors out with a message telling the operator to set them. The `cyclone admin create-user` CLI is a follow-up convenience for adding more users after the first one.
|
||||||
|
- **Default:** the env-var bootstrap stays. Document the env vars in `CLAUDE.md` under the existing `Install` / `Dev` sections.
|
||||||
|
3. **Does the `AUTH_DISABLED` escape hatch ship as a real production knob?** It's a `cyclone.auth.deps.AUTH_DISABLED` module-level boolean that the conftest flips to `True` for the test suite, AND `CYCLONE_AUTH_DISABLED=1` sets it at boot via `cyclone.auth.bootstrap:37-43`. The current behavior is "synthetic admin, no role check" when the flag is set.
|
||||||
|
- **Default:** keep the env-var escape hatch (it makes local dev + tests possible), but add a startup log line in `cyclone serve` that screams `WARNING: CYCLONE_AUTH_DISABLED=1 — all requests treated as admin, dev only` so a misconfigured production deploy fails loudly. Make the conftest path explicit in `cyclone-tests`.
|
||||||
|
4. **Should the docs be explicit about what the auth boundary is and isn't?** The default reading is "the auth boundary is the HTTP layer; the file-system and the SQLite file are still bound by the local-only threat model (stolen/imaged drive, SQLCipher at rest, etc.)."
|
||||||
|
- **Default:** the auth boundary is HTTP-layer only. SQLCipher, Keychain, and the 127.0.0.1 bind are unchanged.
|
||||||
|
|
||||||
|
## 5. Plan
|
||||||
|
|
||||||
|
The plan lives in `docs/superpowers/plans/2026-06-23-cyclone-auth-posture-alignment.md` once the spec is approved. The shape of the plan is:
|
||||||
|
|
||||||
|
1. `CLAUDE.md` — replace lines 7 and 182 with the v1 posture ("local-only with auth; auth boundary is HTTP; default bootstrap on first run"). Update the `Frontend at a glance` / `Backend at a glance` sections to mention `cyclone.auth` and `matrix_gate`. Note the `AUTH_DISABLED` test flag in `cyclone-tests`.
|
||||||
|
2. `docs/REQUIREMENTS.md` — replace the "Local-only on purpose" line 36 and the "Threat model" line 39 with the v1 posture. Update NFR-1 (line 154) to reflect that auth IS now part of the contract. Update §6 (FRs) to add an "Auth boundary" subsection. Update §11 (Roadmap) to note that SP24 lands the auth posture and SP23 is a separate future decision.
|
||||||
|
3. `docs/ARCHITECTURE.md` — replace line 16 and line 703. Add an "Auth" section to the module map (`cyclone.auth` package, the `matrix_gate` dependency, the conftest bypass). Update the migrations table to list 0013 and 0014 and explain why the renumbering on the SP22 branch was necessary.
|
||||||
|
4. The three Cyclone superpowers skills under `.superpowers/skills/` that describe tests, routing, and spec shape: each gets a one-paragraph addendum.
|
||||||
|
5. `cyclone serve` startup log: emit a `WARNING: AUTH_DISABLED` line if `cyclone.auth.deps.AUTH_DISABLED` is `True` at boot. The flag is off by default; only the conftest flips it.
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
- The three top-level docs no longer contain "no auth" / "no authentication" / "no second party to authenticate" claims that are false.
|
||||||
|
- The Cyclone skills addenda are present and self-consistent.
|
||||||
|
- The startup warning fires when `AUTH_DISABLED = True` and is silent otherwise.
|
||||||
|
- The existing test suite still passes (the auth changes are in main; this SP only touches docs + the startup warning).
|
||||||
|
|
||||||
|
## 7. Out of scope
|
||||||
|
|
||||||
|
- Any new feature beyond docs + skills addenda + startup warning.
|
||||||
|
- Any change to the `users` / `sessions` / `audit_log` schema.
|
||||||
|
- Any change to the role matrix or the per-endpoint gate.
|
||||||
|
- Any change to SP23 (separate spec, separate decision).
|
||||||
|
- The CLAUDE.md contradiction is the canary; the rest of the doc set may have other places where the pre-auth claim leaked in. The implementation step sweeps `docs/` and `*.md` files under `.superpowers/skills/` for the strings "no auth", "no authentication", "single operator, single host" and either fixes them or explicitly marks them as historical context.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Long-lived connections for the live-tail NDJSON streams
|
||||||
|
# (snapshot + live events can hold open for minutes at a time).
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
|
||||||
|
# Claims files can be large; 50 MiB matches what FastAPI accepts.
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
# API + auth: proxy to backend over the compose-managed bridge network.
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# The backend sets the cookie with Path=/api/. Rewrite to / so
|
||||||
|
# the browser sends it on every subsequent request to the SPA
|
||||||
|
# (which never has a /api/ prefix on the URL).
|
||||||
|
proxy_cookie_path /api/ /;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback: serve index.html for any non-/api route so deep-links
|
||||||
|
# to /claims/123, /admin/users, etc. round-trip through reload.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Don't cache index.html — the SPA references hashed asset filenames,
|
||||||
|
# so index.html is the only file that needs to be revalidated.
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
|
||||||
|
expires off;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
-512
@@ -669,24 +669,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||||
@@ -704,24 +686,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||||
@@ -739,24 +703,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openharmony"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||||
@@ -4919,420 +4865,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"aix"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-arm": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/android-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/darwin-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-arm": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-ia32": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-loong64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
|
||||||
"cpu": [
|
|
||||||
"loong64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
|
||||||
"cpu": [
|
|
||||||
"mips64el"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
|
||||||
"cpu": [
|
|
||||||
"ppc64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
|
||||||
"cpu": [
|
|
||||||
"riscv64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-s390x": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
|
||||||
"cpu": [
|
|
||||||
"s390x"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/linux-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"netbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"openbsd"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/sunos-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"sunos"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-arm64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-ia32": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
|
||||||
"cpu": [
|
|
||||||
"ia32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@esbuild/win32-x64": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/@vitest/mocker": {
|
"node_modules/vitest/node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.9",
|
"version": "4.1.9",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz",
|
||||||
@@ -5360,50 +4892,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest/node_modules/esbuild": {
|
|
||||||
"version": "0.28.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
|
||||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"esbuild": "bin/esbuild"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@esbuild/aix-ppc64": "0.28.1",
|
|
||||||
"@esbuild/android-arm": "0.28.1",
|
|
||||||
"@esbuild/android-arm64": "0.28.1",
|
|
||||||
"@esbuild/android-x64": "0.28.1",
|
|
||||||
"@esbuild/darwin-arm64": "0.28.1",
|
|
||||||
"@esbuild/darwin-x64": "0.28.1",
|
|
||||||
"@esbuild/freebsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/freebsd-x64": "0.28.1",
|
|
||||||
"@esbuild/linux-arm": "0.28.1",
|
|
||||||
"@esbuild/linux-arm64": "0.28.1",
|
|
||||||
"@esbuild/linux-ia32": "0.28.1",
|
|
||||||
"@esbuild/linux-loong64": "0.28.1",
|
|
||||||
"@esbuild/linux-mips64el": "0.28.1",
|
|
||||||
"@esbuild/linux-ppc64": "0.28.1",
|
|
||||||
"@esbuild/linux-riscv64": "0.28.1",
|
|
||||||
"@esbuild/linux-s390x": "0.28.1",
|
|
||||||
"@esbuild/linux-x64": "0.28.1",
|
|
||||||
"@esbuild/netbsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/netbsd-x64": "0.28.1",
|
|
||||||
"@esbuild/openbsd-arm64": "0.28.1",
|
|
||||||
"@esbuild/openbsd-x64": "0.28.1",
|
|
||||||
"@esbuild/openharmony-arm64": "0.28.1",
|
|
||||||
"@esbuild/sunos-x64": "0.28.1",
|
|
||||||
"@esbuild/win32-arm64": "0.28.1",
|
|
||||||
"@esbuild/win32-ia32": "0.28.1",
|
|
||||||
"@esbuild/win32-x64": "0.28.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vitest/node_modules/picomatch": {
|
"node_modules/vitest/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "cyclone",
|
"name": "cyclone",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Cyclone — self-hosted EDI claims management frontend (CuNtx)",
|
"description": "Cyclone — self-hosted EDI claims management frontend (CuNtx)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# cyclone-init.sh — first-time host bootstrap for a production Cyclone deploy.
|
||||||
|
#
|
||||||
|
# Generates /etc/cyclone/secrets/{db.key,admin_username,admin_pw} with
|
||||||
|
# openssl rand -hex 32, sets ownership + permissions, and prints the
|
||||||
|
# admin password ONCE for the operator to capture.
|
||||||
|
#
|
||||||
|
# Idempotent: refuses to overwrite existing secrets unless --force.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bash scripts/cyclone-init.sh # generate if missing
|
||||||
|
# bash scripts/cyclone-init.sh --force # overwrite (destroys old data!)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SECRETS_DIR="${CYCLONE_SECRETS_DIR:-/etc/cyclone/secrets}"
|
||||||
|
FORCE=0
|
||||||
|
[[ "${1:-}" == "--force" ]] && FORCE=1
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "ERROR: cyclone-init.sh must run as root (it writes to ${SECRETS_DIR})." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -f "${SECRETS_DIR}/db.key" && $FORCE -eq 0 ]]; then
|
||||||
|
echo "Secrets already exist at ${SECRETS_DIR}. Re-run with --force to overwrite." >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "${SECRETS_DIR}"
|
||||||
|
chmod 700 "${SECRETS_DIR}"
|
||||||
|
|
||||||
|
# 64-hex-char (256-bit) random key for SQLCipher + cookie signing.
|
||||||
|
openssl rand -hex 32 > "${SECRETS_DIR}/db.key"
|
||||||
|
|
||||||
|
# Admin username defaults to "admin" unless CYCLONE_ADMIN_USERNAME is set.
|
||||||
|
ADMIN_USER="${CYCLONE_ADMIN_USERNAME:-admin}"
|
||||||
|
printf '%s' "${ADMIN_USER}" > "${SECRETS_DIR}/admin_username"
|
||||||
|
|
||||||
|
# Memorable-but-random admin password — printed once.
|
||||||
|
ADMIN_PW="$(openssl rand -base64 18 | tr -d '/+=' | head -c 20)Aa1!"
|
||||||
|
printf '%s' "${ADMIN_PW}" > "${SECRETS_DIR}/admin_pw"
|
||||||
|
|
||||||
|
chmod 600 "${SECRETS_DIR}"/*
|
||||||
|
chown -R root:root "${SECRETS_DIR}"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
================================================================
|
||||||
|
Cyclone secrets generated at ${SECRETS_DIR}.
|
||||||
|
|
||||||
|
db.key SQLCipher key + cookie signing (64 hex chars)
|
||||||
|
admin_username ${ADMIN_USER}
|
||||||
|
admin_pw ${ADMIN_PW} <-- capture this NOW; it won't be shown again.
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
cd /opt/cyclone
|
||||||
|
docker compose pull # or: docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
bash scripts/post-deploy.sh # logrotate + healthcheck cron
|
||||||
|
bash scripts/smoke.sh # end-to-end smoke test
|
||||||
|
|
||||||
|
Then open http://<lan-ip>:8080/login and log in as '${ADMIN_USER}'.
|
||||||
|
Change the password from /admin/users immediately.
|
||||||
|
================================================================
|
||||||
|
EOF
|
||||||
Executable
+53
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# post-deploy.sh — set up host-side logrotate and healthcheck cron for a
|
||||||
|
# production Cyclone deploy. Run once after `docker compose up -d`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOG_DIR="/var/log/cyclone"
|
||||||
|
HEALTHCHECK_URL="${CYCLONE_HEALTHCHECK_URL:-http://localhost:8080/api/health}"
|
||||||
|
HEALTHCHECK_EMAIL="${CYCLONE_HEALTHCHECK_EMAIL:-root}"
|
||||||
|
CRON_USER="${CYCLONE_CRON_USER:-root}"
|
||||||
|
|
||||||
|
if [[ $EUID -ne 0 ]]; then
|
||||||
|
echo "ERROR: post-deploy.sh must run as root." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1. Logrotate — keeps 14 days of JSON logs compressed.
|
||||||
|
cat > /etc/logrotate.d/cyclone <<'LOGROTATE'
|
||||||
|
/var/log/cyclone/*.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
copytruncate
|
||||||
|
dateext
|
||||||
|
dateformat -%Y%m%d
|
||||||
|
}
|
||||||
|
LOGROTATE
|
||||||
|
|
||||||
|
mkdir -p "${LOG_DIR}"
|
||||||
|
chmod 755 "${LOG_DIR}"
|
||||||
|
|
||||||
|
# 2. Healthcheck cron — every 5 minutes, email if down.
|
||||||
|
CRON_LINE="*/5 * * * * curl -fsS --max-time 10 ${HEALTHCHECK_URL} >/dev/null 2>&1 || echo 'Cyclone DOWN at \$(date)' | mail -s 'Cyclone health FAIL' ${HEALTHCHECK_EMAIL}"
|
||||||
|
TMP_CRON="$(mktemp)"
|
||||||
|
crontab -u "${CRON_USER}" -l 2>/dev/null > "${TMP_CRON}" || true
|
||||||
|
# Remove any existing cyclone healthcheck line, then re-add.
|
||||||
|
grep -v -F "Cyclone health" "${TMP_CRON}" > "${TMP_CRON}.new" || cp "${TMP_CRON}" "${TMP_CRON}.new"
|
||||||
|
echo "${CRON_LINE}" >> "${TMP_CRON}.new"
|
||||||
|
crontab -u "${CRON_USER}" "${TMP_CRON}.new"
|
||||||
|
rm -f "${TMP_CRON}" "${TMP_CRON}.new"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
post-deploy.sh complete:
|
||||||
|
- logrotate installed at /etc/logrotate.d/cyclone
|
||||||
|
- healthcheck cron for ${HEALTHCHECK_URL} installed for user '${CRON_USER}'
|
||||||
|
- logs rotate daily, 14 days retention
|
||||||
|
|
||||||
|
Verify with:
|
||||||
|
logrotate -d /etc/logrotate.d/cyclone
|
||||||
|
crontab -u ${CRON_USER} -l | grep -i cyclone
|
||||||
|
EOF
|
||||||
Executable
+58
@@ -0,0 +1,58 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# smoke.sh — end-to-end bring-up + login + parse + export smoke test.
|
||||||
|
#
|
||||||
|
# Assumes:
|
||||||
|
# - `docker compose up -d` is running.
|
||||||
|
# - /etc/cyclone/secrets/admin_pw exists (or CYCLONE_SECRETS_DIR overrides).
|
||||||
|
# - The sample 837 at docs/prodfiles/co_medicaid/sample_837p.txt is present.
|
||||||
|
#
|
||||||
|
# Override the URL with CYCLONE_SMOKE_URL, the sample path with
|
||||||
|
# CYCLONE_SMOKE_SAMPLE, the secrets dir with CYCLONE_SECRETS_DIR.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_URL="${CYCLONE_SMOKE_URL:-http://localhost:8080}"
|
||||||
|
SECRETS_DIR="${CYCLONE_SECRETS_DIR:-/etc/cyclone/secrets}"
|
||||||
|
ADMIN_PW="$(cat "${SECRETS_DIR}/admin_pw")"
|
||||||
|
COOKIE_JAR="$(mktemp)"
|
||||||
|
SAMPLE_837="${CYCLONE_SMOKE_SAMPLE:-docs/prodfiles/co_medicaid/sample_837p.txt}"
|
||||||
|
|
||||||
|
cleanup() { rm -f "${COOKIE_JAR}" /tmp/cyclone_smoke_export.zip; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "==> waiting for health..."
|
||||||
|
for i in {1..30}; do
|
||||||
|
if curl -fsS "${BASE_URL}/api/health" >/dev/null 2>&1; then break; fi
|
||||||
|
sleep 2
|
||||||
|
[[ $i -eq 30 ]] && { echo "FAIL: health never came up"; exit 1; }
|
||||||
|
done
|
||||||
|
echo " ok"
|
||||||
|
|
||||||
|
echo "==> logging in as admin..."
|
||||||
|
HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||||
|
-c "${COOKIE_JAR}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-X POST "${BASE_URL}/api/auth/login" \
|
||||||
|
-d "{\"username\":\"admin\",\"password\":\"${ADMIN_PW}\"}")
|
||||||
|
[[ "${HTTP_CODE}" == "200" ]] || { echo "FAIL: login returned ${HTTP_CODE}"; exit 1; }
|
||||||
|
echo " ok (cookie set)"
|
||||||
|
|
||||||
|
echo "==> /api/auth/me..."
|
||||||
|
ME=$(curl -fsS -b "${COOKIE_JAR}" "${BASE_URL}/api/auth/me")
|
||||||
|
echo " ${ME}"
|
||||||
|
|
||||||
|
if [[ -f "${SAMPLE_837}" ]]; then
|
||||||
|
echo "==> parsing sample 837 (${SAMPLE_837})..."
|
||||||
|
PARSE=$(curl -fsS -b "${COOKIE_JAR}" -X POST "${BASE_URL}/api/parse-837" \
|
||||||
|
-F "file=@${SAMPLE_837}")
|
||||||
|
echo " ${PARSE}" | head -c 200
|
||||||
|
echo
|
||||||
|
else
|
||||||
|
echo "==> skipping 837 parse (sample not found at ${SAMPLE_837})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> listing batches..."
|
||||||
|
BATCHES=$(curl -fsS -b "${COOKIE_JAR}" "${BASE_URL}/api/batches")
|
||||||
|
echo " ${BATCHES}" | head -c 200
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "smoke: OK"
|
||||||
+10
-1
@@ -13,6 +13,8 @@ import { Acks } from "@/pages/Acks";
|
|||||||
import { Batches } from "@/pages/Batches";
|
import { Batches } from "@/pages/Batches";
|
||||||
import { BatchDiff } from "@/pages/BatchDiff";
|
import { BatchDiff } from "@/pages/BatchDiff";
|
||||||
import Inbox from "@/pages/Inbox";
|
import Inbox from "@/pages/Inbox";
|
||||||
|
import { Login } from "@/pages/Login";
|
||||||
|
import { RequireAuth } from "@/auth/RequireAuth";
|
||||||
|
|
||||||
function NotFound() {
|
function NotFound() {
|
||||||
return (
|
return (
|
||||||
@@ -27,7 +29,14 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<DrillStackProvider>
|
<DrillStackProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
{/* /login sits OUTSIDE the auth-gated Layout so an
|
||||||
|
unauthenticated operator can reach the sign-in screen. */}
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
{/* Every other route inherits the auth gate via RequireAuth
|
||||||
|
wrapping the Layout outlet. Authenticated operators see
|
||||||
|
the full app; unauthenticated ones are bounced back here
|
||||||
|
with `?next=<current>`. */}
|
||||||
|
<Route element={<RequireAuth><Layout /></RequireAuth>}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="claims" element={<Claims />} />
|
<Route path="claims" element={<Claims />} />
|
||||||
<Route path="remittances" element={<Remittances />} />
|
<Route path="remittances" element={<Remittances />} />
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("./api", () => ({
|
||||||
|
authApi: {
|
||||||
|
me: vi.fn(),
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { authApi } from "./api";
|
||||||
|
import { AuthProvider, useAuth } from "./AuthProvider";
|
||||||
|
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("AuthProvider", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads user on mount", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: "alice",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("authenticated"));
|
||||||
|
expect(result.current.user?.username).toBe("alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("status is unauthenticated when /me fails", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("401")
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
|
||||||
|
expect(result.current.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("login() updates state on success", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("401")
|
||||||
|
);
|
||||||
|
(
|
||||||
|
authApi.login as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue({ id: 1, username: "alice", role: "admin" });
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("unauthenticated"));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.login("alice", "pw");
|
||||||
|
});
|
||||||
|
expect(result.current.user?.username).toBe("alice");
|
||||||
|
expect(result.current.status).toBe("authenticated");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("logout() clears state", async () => {
|
||||||
|
(authApi.me as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
username: "alice",
|
||||||
|
role: "user",
|
||||||
|
});
|
||||||
|
(
|
||||||
|
authApi.logout as unknown as ReturnType<typeof vi.fn>
|
||||||
|
).mockResolvedValue(undefined);
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.status).toBe("authenticated"));
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
expect(result.current.user).toBeNull();
|
||||||
|
expect(result.current.status).toBe("unauthenticated");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState, useCallback, type ReactNode } from "react";
|
||||||
|
import { AuthContext, type AuthContextValue, type AuthStatus } from "./useAuth";
|
||||||
|
import { authApi } from "./api";
|
||||||
|
import type { User } from "@/types";
|
||||||
|
|
||||||
|
// Re-export useAuth so consumers can import both the context provider and
|
||||||
|
// the hook from the same module. The hook itself lives in `./useAuth` so
|
||||||
|
// the type definitions stay tree-shakeable independent of the provider.
|
||||||
|
export { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the app and exposes the auth context. On mount it probes
|
||||||
|
* /api/auth/me to decide whether the existing `cyclone_session`
|
||||||
|
* cookie is still valid. Callers (route guard, sidebar, RoleGate,
|
||||||
|
* Login page) read the resulting `status` + `user` via `useAuth()`.
|
||||||
|
*
|
||||||
|
* - "loading" → while the probe is in flight
|
||||||
|
* - "authenticated" → probe succeeded; user populated
|
||||||
|
* - "unauthenticated"→ probe failed; user is null
|
||||||
|
*
|
||||||
|
* `login` and `logout` mutate local state directly so the UI flips
|
||||||
|
* without waiting for a second /me round-trip.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AuthStatus>("loading");
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
setStatus("loading");
|
||||||
|
try {
|
||||||
|
const me = await authApi.me();
|
||||||
|
setUser(me as User);
|
||||||
|
setStatus("authenticated");
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const login = useCallback(async (username: string, password: string) => {
|
||||||
|
const u = await authApi.login(username, password);
|
||||||
|
setUser(u as User);
|
||||||
|
setStatus("authenticated");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await authApi.logout().catch(() => undefined);
|
||||||
|
setUser(null);
|
||||||
|
setStatus("unauthenticated");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value: AuthContextValue = { status, user, login, logout, refresh };
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Navigate, useLocation } from "react-router-dom";
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route guard. Wrap any subtree that requires a logged-in operator:
|
||||||
|
*
|
||||||
|
* <Route element={<RequireAuth><Layout/></RequireAuth>}>
|
||||||
|
* <Route path="/" element={<Dashboard />} />
|
||||||
|
* ...
|
||||||
|
* </Route>
|
||||||
|
*
|
||||||
|
* Three branches, in priority order:
|
||||||
|
*
|
||||||
|
* 1. `status === "loading"` → render a centered "Loading…"
|
||||||
|
* placeholder so we don't bounce the user to /login before the
|
||||||
|
* cookie has had a chance to prove itself via /api/auth/me.
|
||||||
|
* 2. `status === "unauthenticated"` → redirect to
|
||||||
|
* `/login?next=<current path+search>`. The Login page reads
|
||||||
|
* `next` and bounces the operator back here on success.
|
||||||
|
* 3. otherwise → render children (status is
|
||||||
|
* "authenticated").
|
||||||
|
*/
|
||||||
|
export function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
|
const { status } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
if (status === "loading") {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (status === "unauthenticated") {
|
||||||
|
return (
|
||||||
|
<Navigate
|
||||||
|
to={`/login?next=${encodeURIComponent(location.pathname + location.search)}`}
|
||||||
|
replace
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
|
||||||
|
vi.mock("./useAuth", () => ({
|
||||||
|
useAuth: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
import { RoleGate } from "./RoleGate";
|
||||||
|
|
||||||
|
function mockUser(role: "admin" | "user" | "viewer" | null) {
|
||||||
|
(useAuth as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
user: role ? { id: 1, username: "x", role } : null,
|
||||||
|
status: role ? "authenticated" : "unauthenticated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RoleGate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders children when role is allowed", () => {
|
||||||
|
mockUser("admin");
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
expect(container.querySelector("button")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("Do thing");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders disabled (via span wrap) when role is NOT allowed", () => {
|
||||||
|
mockUser("viewer");
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
const buttons = container.querySelectorAll("button");
|
||||||
|
expect(buttons.length).toBeGreaterThan(0);
|
||||||
|
// The single child element is wrapped in a span that carries the
|
||||||
|
// disabled visual signal — opacity-50 plus pointer-events-none so
|
||||||
|
// the inner control can't be clicked.
|
||||||
|
const wrapper = container.querySelector("span");
|
||||||
|
expect(wrapper).not.toBeNull();
|
||||||
|
expect(wrapper?.className).toContain("opacity-50");
|
||||||
|
expect(wrapper?.className).toContain("pointer-events-none");
|
||||||
|
// The wrapper also carries a native `title` attribute that
|
||||||
|
// explains why the affordance is disabled.
|
||||||
|
expect(wrapper?.getAttribute("title")).toMatch(/role \(viewer\) cannot perform this action/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fallback when provided and role not allowed", () => {
|
||||||
|
mockUser("viewer");
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin"]} fallback={<span>NO</span>}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("NO");
|
||||||
|
// Fallback path does NOT wrap children — original button is absent.
|
||||||
|
expect(container.querySelector("button")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when user is null", () => {
|
||||||
|
mockUser(null);
|
||||||
|
const { container } = render(
|
||||||
|
<RoleGate allow={["admin", "user"]}>
|
||||||
|
<button>Do thing</button>
|
||||||
|
</RoleGate>
|
||||||
|
);
|
||||||
|
expect(container.querySelector("button")).toBeNull();
|
||||||
|
expect(container.textContent).not.toContain("Do thing");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useAuth } from "./useAuth";
|
||||||
|
|
||||||
|
type Role = "admin" | "user" | "viewer";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
/**
|
||||||
|
* Roles allowed to interact with the gated affordance. The user's
|
||||||
|
* role must be in this list for children to render normally.
|
||||||
|
*/
|
||||||
|
allow: Role[];
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* What to render instead when the user lacks the role AND we don't
|
||||||
|
* want to show the disabled affordance at all (e.g. "contact your
|
||||||
|
* admin" hint, or just nothing).
|
||||||
|
*/
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RoleGate wraps any write affordance (parse button, resubmit action,
|
||||||
|
* "match selected", etc.) so it visibly disables itself when the
|
||||||
|
* current user's role isn't in `allow`.
|
||||||
|
*
|
||||||
|
* Behavior matrix:
|
||||||
|
*
|
||||||
|
* - user is null → render nothing (the route guard
|
||||||
|
* already redirects to /login; this
|
||||||
|
* is belt-and-braces).
|
||||||
|
* - user.role in `allow` → render children unchanged.
|
||||||
|
* - user.role NOT in `allow` and
|
||||||
|
* `fallback` is provided → render `fallback`.
|
||||||
|
* - user.role NOT in `allow` and
|
||||||
|
* no `fallback` → wrap children in a span carrying
|
||||||
|
* `opacity-50` + `pointer-events-none`
|
||||||
|
* + a `title` attribute that
|
||||||
|
* explains why. We use the native
|
||||||
|
* HTML `title` instead of a custom
|
||||||
|
* Tooltip component to keep this
|
||||||
|
* dependency-free and accessible
|
||||||
|
* by default.
|
||||||
|
*/
|
||||||
|
export function RoleGate({ allow, children, fallback }: Props) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
if (!user) return null;
|
||||||
|
if (allow.includes(user.role)) return <>{children}</>;
|
||||||
|
if (fallback !== undefined) return <>{fallback}</>;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="pointer-events-none opacity-50 inline-flex"
|
||||||
|
title={`Your role (${user.role}) cannot perform this action.`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user