# Cyclone Production-Readiness (local-only) — Design **Date:** 2026-06-19 **Status:** Approved (pending user review of this doc) **Scope:** First sub-project of the four-part Cyclone roadmap. Local-only deployment (no auth, `127.0.0.1`-bound). Adds a backend in-memory batch store, GET endpoints, react-query wiring on the frontend, fresh reference notes, and a new root README. Out of scope: DB persistence, reconciliation, additional X12 transaction types, additional 837P/835 validation rules. --- ## 1. Overview The Cyclone EDI suite already has working 837P and 835 parsers, a FastAPI surface for parsing uploads, and a React frontend with an Upload page. What it does not have is a way to *browse* the parsed data — once a file is uploaded, the user sees it in the Upload page, but a refresh or page navigation loses the result, and the existing Dashboard / Claims / Remittances / Providers / Activity pages still read static sample data. This sub-project closes that loop. We add a process-local in-memory store on the backend, expose GET endpoints that match the resource shape the existing UI already expects, wire react-query into the frontend so pages fetch live data and auto-refresh after a parse, and replace the lost reference notes with a clean new docs tree. After this, a user can: 1. Start the backend (`python -m cyclone serve`). 2. Start the Vite dev server (`npm run dev`). 3. Open `http://localhost:5173/upload`, drop in a `.txt` file, watch the claims stream in. 4. Navigate to `/claims`, `/remittances`, `/providers`, `/activity` and see the data they just parsed. 5. The whole stack is bound to `127.0.0.1`; no auth, no internet exposure. ## 2. Goals 1. **Persist parsed data for the session.** A new `InMemoryStore` on the backend keeps every successful parse and serves it to GET requests. Lost on backend restart — that is acceptable for a local-only tool and avoids a DB in this sub-project. 2. **Expose GET endpoints that match the UI's resource model.** `GET /api/claims`, `/api/remittances`, `/api/providers`, `/api/activity` plus `/api/batches` and `/api/batches/{id}`. The list endpoints accept filter / sort / pagination query params and support both JSON and NDJSON streaming. 3. **Wire the existing 4 UI pages to the live API** via `@tanstack/react-query` v5, with loading skeletons, error states, and automatic invalidation after a successful parse. Fall back to the in-memory sample store when `VITE_API_BASE_URL` is empty (the existing `data` adapter pattern stays). 4. **Replace the lost reference notes** with 4 fresh, short notes under `docs/reference/` (~50–80 lines each) covering 837P, 835, X12 naming, and CO Medicaid specifics. Also rewrite the root `README.md`. 5. **Tighten the local-only deploy posture.** `python -m cyclone serve` binds `127.0.0.1:8000` (not `0.0.0.0`); CORS allowlist stays `http://localhost:5173`. No auth, no API key. The README documents the exact command. ## 3. Non-goals (this sub-project) - **Database persistence.** SQLite/Postgres is sub-project 2. - **837P ↔ 835 reconciliation.** Sub-project 2. - **More 837P/835 validation rules** (REF*G1 enforcement, BHT06, CAS deep-parsing). Sub-project 3. - **999 ACK, 270/271, or other X12 transaction types.** Sub-project 3. - **Auth of any kind.** Explicitly out — local-only, single-user, `127.0.0.1`-bound. - **Structured logging, health-check enhancements, 12-factor env config, Dockerfile.** Deferred. - **Dev tooling polish** (pre-commit, Makefile, CONTRIBUTING.md, .editorconfig). Deferred. ## 4. Stack **Backend additions:** - New module `cyclone.store` (`InMemoryStore`, `BatchRecord`, mappers). - FastAPI (already in use). New routes added to `cyclone.api`. - `threading.Lock` around the store's batch list (single-process, but FastAPI may run request handlers in a threadpool). - `pytest` + `fastapi.testclient.TestClient` for new tests. **Frontend additions:** - `@tanstack/react-query` v5 added to `package.json` dependencies. - `QueryClientProvider` in `src/main.tsx`. - `useQuery` / `useMutation` hooks under `src/hooks/`. - `Skeleton` UI primitive added under `src/components/ui/` (re-uses `cn` helper). - No build-tool changes; the existing Vite + TS + Tailwind stack stays. **Docs:** - Plain Markdown under `docs/reference/`. No doc generator, no linter, no CI check. ## 5. Architecture ``` ┌──────────────────────┐ POST /api/parse-{837,835} ┌─────────────────────────┐ │ Vite/React UI │ ────────────────────────────▶ │ FastAPI backend │ │ │ GET /api/{batches,claims, │ (uvicorn 127.0.0.1) │ │ ┌────────────────┐ │ remittances,providers, │ ┌────────────────────┐ │ │ │ react-query v5 │ │ activity,batches/{id}} │ │ InMemoryStore │ │ │ │ QueryClient │ │ ◀──────────────────────────── │ │ - batches: list │ │ │ │ useQuery / │ │ (JSON or NDJSON stream) │ │ - lock │ │ │ │ useMutation │ │ │ │ - mapper funcs │ │ │ └────────────────┘ │ │ └────────────────────┘ │ │ ┌────────────────┐ │ │ ┌────────────────────┐ │ │ │ zustand store │ │ │ │ parse-837/835 │ │ │ │ (sample data) │ │ │ │ (already shipped) │ │ │ └────────────────┘ │ │ └────────────────────┘ │ └──────────────────────┘ └─────────────────────────┘ ▲ │ CORS allow │ http://localhost:5173 │ only ``` **Data flow on parse:** 1. User drops a file in `/upload`, clicks Parse. 2. Frontend `useMutation` POSTs to `/api/parse-837` (or 835) with `onProgress` (NDJSON) or `Accept: application/json` for small files. 3. Backend's existing parse route appends the resulting `ParseResult` to `InMemoryStore` on success. Failures are *not* stored. 4. `useMutation.onSuccess` calls `queryClient.invalidateQueries(['batches'], ['claims'], ['remittances'], ['activity'])` (and 837/835-specific keys). 5. Any open page reactively re-fetches via `useQuery` and re-renders with the new data. **Data flow on browse:** 1. Page mount → `useQuery` fires the GET with the page's filter / sort / pagination params. 2. First render: skeleton. Success: data. Error: error block with retry. 3. Background: `refetchOnWindowFocus: true` (default in v5) so navigating back updates the view. ## 6. Backend changes ### 6.1 New module `backend/src/cyclone/store.py` ```python class BatchRecord(BaseModel): model_config = ConfigDict(extra="ignore") id: str # uuid4 hex kind: Literal["837p", "835"] input_filename: str parsed_at: datetime # tz-aware UTC result: ParseResult | ParseResult835 # union discriminated by .kind class InMemoryStore: def __init__(self) -> None: self._batches: list[BatchRecord] = [] self._lock = threading.Lock() def add(self, record: BatchRecord) -> None: ... def list(self, *, limit: int = 100) -> list[BatchRecord]: ... def get(self, batch_id: str) -> BatchRecord | None: ... def iter_claims(self, *, batch_id: str | None = None, ...) -> Iterable[Claim]: ... def iter_remittances(self, *, batch_id: str | None = None, ...) -> Iterable[Remittance]: ... def distinct_providers(self) -> list[Provider]: ... def recent_activity(self, *, limit: int = 200) -> list[Activity]: ... store = InMemoryStore() # module-level singleton ``` **Mappers** (`to_ui_claim`, `to_ui_remittance`, `to_ui_provider`, `to_activity_event`) live in the same file. They translate the rich backend models (Pydantic, raw segments) to the simpler UI types the GET endpoints return. **Filter / sort API:** each `iter_*` method takes the relevant filter kwargs and applies them in Python. There is no index — the in-memory list is small (single-session, dozens of batches at most). Sorting uses `sorted(..., key=..., reverse=order == "desc")`. **Threading:** every public method acquires `self._lock` (RLock, since `list` may call `get` internally) and releases it. ### 6.2 New routes in `backend/src/cyclone/api.py` | Method | Path | Behavior | |---|---|---| | GET | `/api/batches` | `store.list(limit=100)`, mapped to a `BatchSummary`-shaped response. Newest first. | | GET | `/api/batches/{id}` | `store.get(id)`. 404 if missing. Returns the full `ParseResult` / `ParseResult835`. | | GET | `/api/claims` | `store.iter_claims(...)` mapped to UI `Claim[]`. Supports `batch_id`, `status`, `payer`, `provider_npi`, `date_from`, `date_to`, `sort`, `order`, `limit` (≤ 1000), `offset`. | | GET | `/api/remittances` | `store.iter_remittances(...)` mapped to UI `Remittance[]`. Supports `batch_id`, `payer`, `claim_id`, `date_from`, `date_to`, `sort`, `order`, `limit`, `offset`. | | GET | `/api/providers` | `store.distinct_providers()`. Supports `npi`, `state`, `limit`, `offset`. | | GET | `/api/activity` | `store.recent_activity(limit=200)`. Supports `kind`, `since`, `limit` (≤ 500). | | GET | `/api/health` | (unchanged) | **Streaming:** every list endpoint above accepts `Accept: application/x-ndjson`. When set, the response is a `StreamingResponse` with one JSON object per line: `{type: "item", data: {...}}` for each result, then a final `{type: "summary", data: {total: N, returned: M, has_more: bool}}`. Default JSON response wraps the same data in a `{items: [...], total: N, returned: M, has_more: bool}` envelope so the frontend can paginate uniformly. **Status mapping (837P):** each parsed `ClaimOutput` has a `validation.passed` boolean. The UI's `Claim.status` is one of `draft|submitted|accepted|denied|paid|pending`. The mapper does: - `validation.passed` and `frequency_code == "1"` → `submitted` - `validation.passed` and any `validation.warnings` → `pending` - `!validation.passed` and no error mentions `R050_diagnosis_present` → `denied` - otherwise → `draft` (rare; means validation broke somewhere) **Status mapping (835):** 835 `CLP02` status codes map to `Remittance.status` (`received|posted|reconciled`): - 1, 2, 19, 20 (paid, primary/secondary) → `received` - 4 (denied) → `received` with a `denialReason` populated from the CAS segments - 21, 22 (reversal of previous payment) → `reconciled` (out-of-scope: full reversal handling; the data is in `service_payments[*].adjustments`) - all other valid codes → `received` (default) - any code outside `cfg.allowed_status_codes` → still stored, mapped to `received`, and surfaced as a validation warning ### 6.3 Existing parse routes — wire to the store - `POST /api/parse-837` — on success, build a `BatchRecord(kind="837p", result=result)` and call `store.add(...)` *before* returning the response. On `CycloneParseError` or any unhandled exception, do *not* add. - `POST /api/parse-835` — same pattern with `kind="835"`. - Both routes also stamp `parsed_at` and generate `id = uuid.uuid4().hex`. ### 6.4 Uvicorn invocation `cyclone/__main__.py`'s `serve` branch: ```python sys.argv = [sys.argv[0], "cyclone.api:app", "--host", "127.0.0.1", "--port", "8000"] ``` - `--host 127.0.0.1` (not `0.0.0.0`). - `--port 8000` (configurable via `CYCLONE_PORT` env, default `8000`). - `--reload` only when `CYCLONE_RELOAD=1` (dev convenience). CORS in `api.py` stays as-is: `allow_origins=["http://localhost:5173"]`. ## 7. Frontend changes ### 7.1 Dependencies Add to `package.json`: ```json "dependencies": { ... "@tanstack/react-query": "^5.59.0" } ``` ### 7.2 `src/main.tsx` Wrap the existing `` in `` where `queryClient` is a module-level `new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, refetchOnWindowFocus: true } } })`. ### 7.3 `src/lib/api.ts` — add GET methods Mirror the existing `request` helper. New methods (all return `Promise`): ```ts api.listBatches(): Promise api.getBatch(id: string): Promise api.listClaims(params: ListClaimsParams): Promise> api.listRemittances(params: ListRemittancesParams): Promise> api.listProviders(params?: ListProvidersParams): Promise> api.listActivity(params?: ListActivityParams): Promise> ``` NDJSON streaming is *not* required for the initial frontend wiring (the existing Upload page already streams from the POST); GET is fine as JSON. The streaming option on GET is for future large-batch scenarios and is a backend-side capability only. ### 7.4 `src/hooks/` — new query / mutation hooks ```ts // src/hooks/useBatches.ts export function useBatches() { return useQuery({ queryKey: ['batches'], queryFn: () => api.listBatches() }); } // src/hooks/useClaims.ts export function useClaims(params: ListClaimsParams) { return useQuery({ queryKey: ['claims', params], queryFn: () => api.listClaims(params) }); } // ... useRemittances, useProviders, useActivity similarly ... // src/hooks/useParse.ts export function useParse(kind: '837p' | '835') { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ file, onProgress }) => kind === '837p' ? api.parse837(file, { onProgress }) : api.parse835(file, { onProgress }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['batches'] }); queryClient.invalidateQueries({ queryKey: ['claims'] }); queryClient.invalidateQueries({ queryKey: ['remittances'] }); queryClient.invalidateQueries({ queryKey: ['providers'] }); queryClient.invalidateQueries({ queryKey: ['activity'] }); }, }); } ``` ### 7.5 Page refactors | Page | Current | New | |---|---|---| | `src/pages/Claims.tsx` | `useAppStore((s) => s.claims)` | `useClaims({ status, search })`; falls back to store data when `!api.isConfigured` (handled in the hook) | | `src/pages/Remittances.tsx` | store-backed | `useRemittances({})` | | `src/pages/Providers.tsx` | store-backed | `useProviders({})` | | `src/pages/ActivityLog.tsx` | store-backed | `useActivity({ since: now - 7d })`, `refetchInterval: 30_000` | | `src/pages/Upload.tsx` | uses `useMutation`-less `addParsedBatch` | replace mutation with `useParse(kind)`; keep `addParsedBatch` for the "recent batches" list, but the live-data pages will refresh from the server | Each page gains: `` rows during `isLoading`, an error block with "Retry" button on `isError`, and a small footer showing `{returned} of {total}` when `has_more` is true. **Fallback when `VITE_API_BASE_URL` is empty:** the existing `data` adapter in `src/lib/api.ts` continues to provide a path that reads from the in-memory zustand store. Each new hook checks `api.isConfigured` and either runs the react-query path (default) or returns a synchronous result from the store via `useSyncExternalStore`. Page components don't need to know which path is active. The `data` adapter is removed in a follow-up sub-project once no caller depends on it. ### 7.6 UI primitive: `src/components/ui/skeleton.tsx` A reusable row-shaped skeleton block. Voice: hairline + a slow horizontal scanline gradient (not the generic "pulse the whole block gray" pattern). Renders three bars (id / name / amount) sized to match the typography of the row it stands in for. ~25 lines, no new dep. ```tsx // Sketch — not the final implementation export function Skeleton({ className, variant = "row" }: Props) { return (
{variant === "row" ? (
) : null}
); } ``` The `shimmer` keyframe is added to `tailwind.config.js` next to the existing `fade-in` keyframes. No external library. ### 7.7 Live-data UX patterns The aesthetic is already committed (Cabinet Grotesk + Geist Mono, true-black ground, electric-blue accent, hairline chrome, radial top-right light, fine grain). This sub-project does not introduce a new accent color, a new typeface, a light theme, or a new layout primitive. The additions below are *only* the live-data UX patterns needed to make the page refactors feel as precise as the data they carry. #### 7.7.1 Loading — three skeleton variants Pages render 3–5 `Skeleton` rows (or cards, or KPI tiles) on `isLoading` so the page doesn't pop. Skeleton uses the row-shaped variant (above) inside table surfaces, the `card` variant inside the Provider grid, and the existing `.surface` + `display` text treatment for the page header (which renders immediately, not behind a skeleton). #### 7.7.2 Empty state Replace the existing generic "No claims yet." text with a consistent empty state primitive: a hairline-circle icon (1.5px stroke), an all-caps eyebrow (`text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground`), and a one-line message. The eyebrow uses the *page subject* so it reads as an instrument label: e.g., "Claims · inbox idle" / "Remittances · awaiting first 835" / "Activity · log idle" / "Providers · directory empty". No apologetic language, no illustrations. ```tsx function EmptyState({ eyebrow, message }: { eyebrow: string; message: string }) { return (
{eyebrow}
{message}
); } ``` `CircleDashed` from `lucide-react`. #### 7.7.3 Error state A bordered block at the top of the page content (not a full-page takeover — the user should still be able to navigate away). `border-destructive/30` on a `.surface` background; a destructive-tinted `AlertCircle` icon, the error message in `text-sm`, and a `Retry` button on the right. A sonner toast surfaces the same error at the bottom-right (existing Toaster config keeps this). Network errors get a more verbose message ("Can't reach the backend at `http://127.0.0.1:8000`. Is the FastAPI server running?"). ```tsx function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) { return (
Fetch failed
{error.message}
); } ``` #### 7.7.4 Refetch indicator (Layout-level) A 1px hairline at the very top of `
` that animates in (left → right, accent color, 400ms) whenever *any* query is fetching in the background (e.g., after `invalidateQueries` post-parse). Uses `useIsFetching()` from `@tanstack/react-query`. Implemented as a fixed-position element inside `Layout.tsx`, not page-level — so every page benefits without per-page plumbing. ```tsx // In Layout.tsx const isFetching = useIsFetching(); return (
{isFetching > 0 ? (
) : null}
); ``` The `scan` keyframe is added to `tailwind.config.js`. The element is hairline-thin so it never competes with the content; the only motion on the page during a background refetch is this 1px sliver. #### 7.7.5 Active filter chips (Claims page) The existing `