Files
cyclone/docs/superpowers/specs/2026-06-19-cyclone-production-readiness-design.md
T
Tyler 078de28cc4 docs: add live-data UX section + aesthetic acceptance checks
- Replace 7.6 (skeleton) with hairline + scanline-shimmer design that
  matches the existing precision-instrument voice (Cabinet Grotesk + Geist
  Mono, true-black + electric-blue, hairline chrome, radial light + grain)
- Add 7.7 Live-data UX patterns: skeleton variants, EmptyState primitive,
  ErrorState primitive, Layout-level refetch indicator, Active filter chips,
  newly-streamed row highlight, status color alignment, pagination footer
- Add 13.1 visual / aesthetic acceptance checks
- Voice decision: stay the course — additive only, no new accent / typeface /
  layout primitive / light theme
2026-06-19 18:36:39 -06:00

34 KiB
Raw Blame History

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/ (~5080 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

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.warningspending
  • !validation.passed and no error mentions R050_diagnosis_presentdenied
  • 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:

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:

"dependencies": {
  ...
  "@tanstack/react-query": "^5.59.0"
}

7.2 src/main.tsx

Wrap the existing <App /> in <QueryClientProvider client={queryClient}> 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<T> helper. New methods (all return Promise<T>):

api.listBatches(): Promise<BatchSummary[]>
api.getBatch(id: string): Promise<ParseResult837 | ParseResult835>
api.listClaims(params: ListClaimsParams): Promise<PaginatedResponse<Claim>>
api.listRemittances(params: ListRemittancesParams): Promise<PaginatedResponse<Remittance>>
api.listProviders(params?: ListProvidersParams): Promise<PaginatedResponse<Provider>>
api.listActivity(params?: ListActivityParams): Promise<PaginatedResponse<Activity>>

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

// 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: <Skeleton /> 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.

// Sketch — not the final implementation
export function Skeleton({ className, variant = "row" }: Props) {
  return (
    <div
      className={cn(
        "relative overflow-hidden rounded-md bg-muted/30",
        // hairline border at 0.4 opacity to echo the rest of the chrome
        "ring-1 ring-inset ring-border/40",
        className
      )}
    >
      <div
        className="absolute inset-0 -translate-x-full animate-[shimmer_1.6s_infinite]"
        style={{
          background:
            "linear-gradient(90deg, transparent 0%, hsl(var(--muted-foreground) / 0.08) 50%, transparent 100%)",
        }}
      />
      {variant === "row" ? (
        <div className="grid grid-cols-12 gap-3 p-4">
          <div className="col-span-2 h-3 bg-muted/60 rounded" />
          <div className="col-span-3 h-3 bg-muted/60 rounded" />
          <div className="col-span-2 h-3 bg-muted/60 rounded" />
          <div className="col-span-2 h-3 bg-muted/60 rounded" />
          <div className="col-span-2 h-3 bg-muted/60 rounded ml-auto" />
          <div className="col-span-1 h-3 bg-muted/60 rounded ml-auto" />
        </div>
      ) : null}
    </div>
  );
}

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 35 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.

function EmptyState({ eyebrow, message }: { eyebrow: string; message: string }) {
  return (
    <div className="flex flex-col items-center justify-center gap-2.5 py-14">
      <div className="h-10 w-10 rounded-full ring-1 ring-inset ring-border/60 flex items-center justify-center text-muted-foreground">
        <CircleDashed className="h-4 w-4" strokeWidth={1.5} />
      </div>
      <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
        {eyebrow}
      </div>
      <div className="text-sm text-muted-foreground/80">{message}</div>
    </div>
  );
}

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?").

function ErrorState({ error, onRetry }: { error: Error; onRetry: () => void }) {
  return (
    <div className="surface rounded-xl border border-destructive/30 p-4 flex items-center gap-3">
      <AlertCircle className="h-4 w-4 text-destructive shrink-0" strokeWidth={1.75} />
      <div className="flex-1 min-w-0">
        <div className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-destructive/80">
          Fetch failed
        </div>
        <div className="text-sm text-foreground/90 truncate">{error.message}</div>
      </div>
      <Button variant="outline" size="sm" onClick={onRetry}>
        Retry
      </Button>
    </div>
  );
}

7.7.4 Refetch indicator (Layout-level)

A 1px hairline at the very top of <main> 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.

// In Layout.tsx
const isFetching = useIsFetching();
return (
  <div className="relative min-h-screen z-10">
    {isFetching > 0 ? (
      <div className="fixed top-0 left-0 right-0 z-50 h-px overflow-hidden pointer-events-none">
        <div
          className="h-full w-1/3 bg-accent animate-[scan_1.2s_ease-in-out_infinite]"
          style={{ boxShadow: "0 0 8px hsl(var(--accent) / 0.5)" }}
        />
      </div>
    ) : null}
    <Sidebar />
    <main className="md:pl-60"></main>
  </div>
);

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 <Select> controls stay (they set the values). Above the table, a row of read-only pills makes the active filter set scannable at a glance. Each pill: border-border/60 background, text-xs, a small "×" on the right that clears the filter. A "Clear all" link on the right of the chip row clears every active filter. The chips sit on the same surface card as the Selects, separated by a 1px hairline.

function FilterChips({
  active,
  onRemove,
  onClearAll,
}: {
  active: { key: string; label: string }[];
  onRemove: (key: string) => void;
  onClearAll: () => void;
}) {
  if (active.length === 0) return null;
  return (
    <div className="flex flex-wrap items-center gap-2 mt-4 pt-4 border-t border-border/40">
      <span className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
        Active
      </span>
      {active.map((c) => (
        <span
          key={c.key}
          className="inline-flex items-center gap-1.5 rounded-md border border-border/60 bg-muted/30 pl-2.5 pr-1 py-0.5 text-xs"
        >
          <span className="text-muted-foreground">{c.key}:</span>
          <span className="font-medium">{c.label}</span>
          <button
            type="button"
            onClick={() => onRemove(c.key)}
            className="ml-0.5 h-4 w-4 rounded-sm hover:bg-muted flex items-center justify-center"
            aria-label={`Remove ${c.key} filter`}
          >
            <X className="h-3 w-3" strokeWidth={1.75} />
          </button>
        </span>
      ))}
      {active.length > 1 ? (
        <button
          type="button"
          onClick={onClearAll}
          className="ml-auto text-[11px] text-muted-foreground hover:text-foreground transition-colors"
        >
          Clear all
        </button>
      ) : null}
    </div>
  );
}

7.7.6 Newly-streamed row highlight (Tables)

When react-query invalidates after a successful parse, the affected tables briefly highlight the new rows with a 2-second accent-tinted background fade. Implementation: each row that arrived in the most recent parse carries a data-newer-than={parsedAt} attribute; CSS animates a one-shot bg-accent/10 to bg-transparent over 2s. This is the visual confirmation that "the parse made it into the list" without being noisy.

// in TableRow wrapper
<tr
  data-newer-than={isNewRow ? parsedAt : undefined}
  className="data-[newer-than]:animate-[row-flash_2s_ease-out]"
/>

The row-flash keyframe: from { background-color: hsl(var(--accent) / 0.1); } to { background-color: transparent; }. Lives in tailwind.config.js.

7.7.7 Status color alignment (no new colors)

The new mapper output is draft|submitted|accepted|denied|paid|pending for 837P claims and received|posted|reconciled for 835 remittances — exactly what the existing ClaimStatusBadge and RemitStatusBadge already key on. The palette is unchanged: success (green) for accepted/paid/reconciled, accent (blue) for submitted/received, warning (amber) for pending/posted, destructive (red) for denied, muted for draft. No new tokens, no new semantics.

For list endpoints with total > limit, the existing page footer grows a paginator: 1 2 3N , plus the returned / total / has_more summary on the right. Style: text-xs with num on the numbers, the active page gets bg-muted/60 + a left-aligned 1px accent line (echoing the sidebar nav-active treatment). The component is a small new primitive src/components/ui/pagination.tsx (~40 lines).

8. Docs

8.1 docs/reference/837p.md (~80 lines)

Sections:

  • File extension: .txt. Encoding: ASCII or UTF-8. Delimiters: * element, : component, ~ segment, ^ repetition.
  • Envelope: ISA/GS/ST headers, SE/GE/IEA footers.
  • Loops: 2000A (billing provider), 2000B (subscriber), 2300 (claim), 2400 (service line).
  • Segments Cyclone parses: NM1, N3/N4, REF, CLM, HI, LX, SV1, DTP, BHT.
  • Segments Cyclone skips (preserved in raw_segments but not modeled): too many to enumerate; see the parser source for the full list.
  • CO Medicaid specifics: REF*G1 prior-auth, no patient loop, CLM05 shape 12:B:1 etc.

8.2 docs/reference/835.md (~80 lines)

  • Envelope: same as 837P.
  • Header: BPR (financial), TRN (trace), DTM (dates).
  • Loops: 1000A (payer), 1000B (payee), 2100 (CLP claim payment), 2110 (SVC service payment).
  • Critical balancing rules: BPR02 == sum(CLP04), CLP04 == sum(SVC03).
  • CO Medicaid specifics: BPR10 81-1725341 (TXIX) or 84-0644739 (BHA), N104 7912900843, N1*PR name CO_TXIX or CO_BHA.

8.3 docs/reference/x12naming.md (~50 lines)

  • Segments: 23 letter codes (CLM, NM1, BPR).
  • Elements: *-separated, 1-indexed.
  • Composite elements: :-separated sub-elements, indexed 01, 02, ...
  • Loops: 4-digit IDs, hierarchical (2000 contains 2300 contains 2400).
  • Common qualifiers we care about: B (POS qualifier), ABK (ICD-10 principal diagnosis), MC (Medicaid claim filing).

8.4 docs/reference/co-medicaid.md (~80 lines)

  • Trading partner ID (TPID) — sender / receiver roles in 837P and 835.
  • Payer IDs:
    • 837P: NM1*PR N104 = "SKCO0" (COHCPF).
    • 835: BPR10 = "81-1725341" (TXIX) or "84-0644739" (BHA); N1*PR N104 = "7912900843".
  • Allowed frequency codes (837P CLM05-3): {1, 7, 8}.
  • Allowed status codes (835 CLP02): {1, 2, 3, 4, 19, 20, 21, 22, 23, 25}.
  • POS codes: full CMS set (89 codes; canonical list in cyclone.parsers.payer.CMS_PLACE_OF_SERVICE_CODES).

8.5 README.md (root, rewrite)

Sections:

  • What is Cyclone? — 1 paragraph. EDI claim parser + browser UI.
  • Installgit clone, cd backend && python -m venv .venv && .venv/bin/pip install -e '.[dev]', cd .. && npm install.
  • Dev — two terminals: cd backend && .venv/bin/python -m cyclone serve and npm run dev. Open http://localhost:5173.
  • Testcd backend && .venv/bin/pytest and npm run build.
  • Project layoutbackend/ (Python API + parsers), src/ (React UI), docs/reference/ (companion notes), docs/prodfiles/ (sample EDI files).
  • Roadmap — short list of sub-projects 24 with one-line descriptions and a note that they're not in this build.
  • License — no license file yet; add a LICENSE file when one is decided. The project is internal-use until then.

9. Error handling

  • Backend store errors: none expected (in-memory, no I/O). If a BatchRecord.result doesn't conform (e.g. we tried to store a result from a code path that returns None), the model validator fails loudly at insertion time.
  • GET route errors: 404 for missing batch_id; 400 for invalid query params (FastAPI's default validation handles this); 500 only for unexpected bugs.
  • NDJSON streaming: on a serialization error mid-stream, the response is already partially written; we log the error and end the stream. The client sees a truncated response and the final summary line is missing. The frontend falls back to "show what we have" and shows a toast.
  • Frontend: useQuery exposes isError + error. Each page renders an error block with a retry button that calls refetch(). Network errors show a sonner toast on top of the inline error.
  • Parse failure: the parse route already returns 400 / 422 with a clear message. The Upload page shows a destructive toast. The store is unchanged (failed parses are not persisted).

10. Testing

10.1 Backend (backend/tests/)

  • test_store.py (~8 tests) — add/list/get; concurrent adds under a thread; mapper functions; filter / sort / paginate; distinct providers; activity derivation.
  • test_api_gets.py (~12 tests) — one happy-path + one filter test per new GET endpoint (6 endpoints × 2 = 12); plus one 404 test for /api/batches/{id}.
  • test_api_streaming.py (1 test) — Accept: application/x-ndjson on /api/claims returns parseable lines.
  • test_api_parse_persists.py (2 tests) — parse-837 followed by GET /api/claims returns the claim; failed parse does not create a batch.

Total new backend tests: ~23.

10.2 Frontend (src/)

  • src/lib/api.test.ts — extend with 3 tests: listClaims builds the right URL with params, listBatches hits /api/batches, getBatch returns ParseResult835 when kind === "835".
  • No DOM / component tests in this sub-project. The page refactors are small and the smoke test below is the integration check.

10.3 End-to-end smoke test (manual, documented in the plan)

# Terminal 1
cd backend && .venv/bin/python -m cyclone serve

# Terminal 2
npm run dev

# In a third terminal
cd backend && .venv/bin/python -m cyclone.cli parse-837 tests/fixtures/co_medicaid_837p.txt --output-dir /tmp/co-837
# (this populates a real production-like batch; we don't need the JSON files for the test, just the upload flow)

# In a browser: open http://localhost:5173/upload, drop tests/fixtures/co_medicaid_837p.txt
# Verify: claims stream in, summary toast appears, navigate to /claims and see them

11. Migration / rollout

  • No DB, so no migration.
  • The frontend's existing data adapter stays during the transition. We delete it in a follow-up once no one calls it.
  • The new InMemoryStore is empty on first run. Existing parse-837/parse-835 callers see no behavior change (they still get the parse result; we just also add to the store now).
  • A uvicorn restart wipes the store. That is by design and called out in the README.

12. Out of scope (explicitly)

  • DB persistence, Alembic migrations, transactional safety.
  • 837P ↔ 835 reconciliation (matching payouts to original claims).
  • 999 ACK, 270/271, or other X12 transaction types.
  • More 837P validation rules beyond what already ships.
  • 835 CAS deep-parsing with reason-code explanations.
  • Auth (any kind: API key, JWT, session).
  • Rate limiting, request size limits beyond FastAPI defaults.
  • Structured logging, log levels via env, JSON logs.
  • Health-check enhancements (uptime, parser state, last-batch timestamp).
  • 12-factor env config beyond CYCLONE_PORT and CYCLONE_RELOAD.
  • Docker / docker-compose / any deploy artifact.
  • Pre-commit hooks, Makefile, .editorconfig, CONTRIBUTING.md, lint config.
  • Component tests for the React pages.
  • E2E browser tests (Playwright/Cypress).

13. Acceptance checklist

  • pytest passes with the new store + GET tests; total ≥ 121 + 23 = 144.
  • npm run build still passes.
  • python -m cyclone serve binds to 127.0.0.1:8000 and refuses connections from non-loopback.
  • Upload page streams a 2-claim CO fixture; navigating to /claims shows the same 2 claims.
  • Filtering by status=submitted on /api/claims returns the right subset; UI shows the same.
  • GET /api/claims with Accept: application/x-ndjson returns valid NDJSON.
  • Restarting the backend wipes the in-memory store; the UI shows an empty state on first refresh.
  • All 4 reference notes exist under docs/reference/ and the root README.md is rewritten.
  • The frontend still works without a backend (VITE_API_BASE_URL empty) — pages fall back to the zustand sample store via the existing data adapter.

13.1 Visual / aesthetic acceptance checks

  • Skeleton uses the scanline-shimmer pattern, not a generic animate-pulse on a flat gray block.
  • Empty state on every refactored page uses the EmptyState primitive with an all-caps instrument-label eyebrow (e.g., "Claims · inbox idle"), not a plain text line.
  • Error state on every refactored page uses the ErrorState primitive with a destructive-tinted hairline border, an AlertCircle icon, the error message, and a Retry button — not a full-page takeover.
  • Refetch indicator at the top of <main> appears (1px hairline, accent color, scanning left-to-right) when any query is fetching in the background and disappears when the fetch completes.
  • Active filter chips appear on the Claims page above the table whenever a filter is set, and the × button on each chip clears that single filter.
  • Newly-streamed row highlight flashes the new rows in any list for ~2 seconds after a successful parse (accent-tinted background fade to transparent).
  • Status badges for the new mapper output (draft|submitted|accepted|denied|paid|pending and received|posted|reconciled) use the existing palette and existing ClaimStatusBadge / RemitStatusBadge components — no new colors, no new components.
  • Pagination footer on list pages uses the new Pagination primitive with 1 2 3 … N and the returned / total / has_more summary; active page echoes the sidebar nav-active 1px accent line.
  • No new accent color, no new typeface, no new layout primitive, no light theme — the page refactors and the new UX patterns sit inside the existing aesthetic voice.