14 Commits

Author SHA1 Message Date
Tyler 81aebf54ed docs: SP16 Inbound MFT Scheduler section + project-layout updates
Documents the SP16 scheduler control surface (5 endpoints under
/api/admin/scheduler/*) and brings the Project layout tree in sync
with the latest api_routers/ split state:

- new 'Inbound MFT Scheduler (SP16)' section between SFTP Wire-Up
  and Batches/Reconciliation/Activity, covering start/stop/status/
  tick/processed-files with a forward-compat note that
  cyclone.scheduler + cyclone.db.ProcessedInboundFile are in flight
- api_routers/ tree now lists all 14 routers, including the four
  parse_* routers (parse_835/837/999/ta1) and admin.py carrying the
  scheduler endpoints
- api.py comment updated to 'app + middleware; mounts api_routers/
  sub-apps' (no inline route bodies remain)
2026-06-21 09:16:14 -06:00
Tyler 3b5e2af077 refactor(api): split eligibility + admin routers
Extracts 12 endpoints from api.py into two new routers:

  cyclone.api_routers.eligibility (2 ep):
    POST /api/eligibility/request
    POST /api/eligibility/parse-271

  cyclone.api_routers.admin (10 ep):
    GET    /api/admin/audit-log
    GET    /api/admin/audit-log/verify
    POST   /api/admin/db/rotate-key
    POST   /api/admin/reload-config
    POST   /api/admin/scheduler/start
    POST   /api/admin/scheduler/stop
    GET    /api/admin/scheduler/status
    POST   /api/admin/scheduler/tick
    GET    /api/admin/scheduler/processed-files

Module-level _db_rotate_lock moved with the rotate-key endpoint.
8+ dead imports removed from api.py.
No behaviour changes; routers wire identically to the inline versions.

api.py: 1349 -> 949 lines (-400, ~64% reduction from start of refactor).
eligibility.py: 220 lines, admin.py: 332 lines.
2026-06-21 09:03:46 -06:00
Tyler fc73075ef9 refactor(api): split inbox + reconciliation routers
Extracts 10 endpoints from api.py into two new routers:

cyclone.api_routers.inbox (6 ep):
  GET  /api/inbox/lanes
  POST /api/inbox/candidates/{remit_id}/match
  POST /api/inbox/candidates/dismiss
  POST /api/inbox/payer-rejected/acknowledge
  POST /api/inbox/rejected/resubmit
  GET  /api/inbox/export.csv

cyclone.api_routers.reconciliation (4 ep):
  GET  /api/reconciliation/unmatched
  GET  /api/batch-diff
  POST /api/reconciliation/match
  POST /api/reconciliation/unmatch

The two reconciliation/match endpoints delegate to store.manual_match
/ store.manual_unmatch; the router translates the store's exception
hierarchy (AlreadyMatchedError, InvalidStateError, NotMatchedError,
LookupError) into the HTTP error contract.

State-access refactor: endpoints that previously read app.state.dismissed_pairs
directly (lanes / dismiss / export.csv) now take an explicit
request: Request parameter and read request.app.state.dismissed_pairs
— the FastAPI-idiomatic pattern. No behavior change.

Dead-import cleanup: 8 imports left dangling in api.py after extraction
(csv, io, ClaimOutput, ParseResult, serialize_837,
serialize_837_for_resubmit, SerializeError837, Response) — all removed.

api.py: 1753 -> 1342 (-411). Net diff: +443 / -439.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
62/62 inbox + reconciliation tests pass.
Live smoke: all 10 endpoints return expected status codes (200 for
reads, 400 for invalid bodies, 404 for missing ids).
2026-06-21 08:34:24 -06:00
Tyler 27181144f2 docs: list remittances.py in api_routers/ Project layout
Another router extraction landed: api_routers/remittances.py now owns
the three /api/remittances[...] routes (list, stream, detail). Pure
code-organization move from api.py — no new endpoints, no new
functionality.

Update the Project layout block to list remittances.py alongside the
other 9 routers with the routes it owns.
2026-06-21 07:06:08 -06:00
Tyler eb674f890f refactor(api): split remittances router (list + stream + detail)
Extracts the 3 /api/remittances endpoints from api.py into
cyclone.api_routers.remittances:

  GET /api/remittances            (paginated list with filters)
  GET /api/remittances/stream     (NDJSON live-tail)
  GET /api/remittances/{remit_id} (detail with CAS adjustments)

Mirror of the claims router for the 835 / Remittance resource.
No private helpers — every endpoint is a thin wrapper over
store.iter_remittances() / store.get_remittance().

remittances_stream is re-exported at the cyclone.api module level
so test_api_stream_live.py's direct import keeps working.

Route ordering preserved: /stream is declared before /{remit_id} in
the router source so FastAPI's first-match routing doesn't swallow
the literal 'stream' segment as a remittance id.

api.py: 1841 -> 1753 (-88). Net diff: +170 / -96.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
11/11 remittance-targeted tests pass.
Live smoke: all 3 routes return expected status codes (200 for
list/stream, 404 for missing-remit detail).
2026-06-21 06:29:01 -06:00
Tyler 804e557a49 docs: list claims.py in api_routers/ Project layout
Another router extraction landed: api_routers/claims.py now owns the
five /api/claims[...] routes (list, stream, detail, serialize-837,
line-reconciliation). Pure code-organization move from api.py — no
new endpoints, no new functionality.

Update the Project layout block to list claims.py alongside the other
8 routers with the routes it owns.
2026-06-21 05:05:20 -06:00
Tyler ff43f90156 refactor(api): split claims router (list + stream + detail + serialize + line-recon)
Extracts the 5 /api/claims endpoints from api.py into
cyclone.api_routers.claims:

  GET /api/claims                          (paginated list)
  GET /api/claims/stream                   (NDJSON live-tail)
  GET /api/claims/{claim_id}              (drawer detail)
  GET /api/claims/{claim_id}/serialize-837 (X12 regen)
  GET /api/claims/{claim_id}/line-reconciliation (per-line view)

Plus the two private projection helpers _claim_line_dict and
_svc_to_dict which are only used by line-reconciliation.

claims_stream is re-exported at the cyclone.api module level so
test_api_stream_live.py's direct import keeps working (same pattern
as the activity_stream re-export).

Route ordering preserved: /stream is declared before /{claim_id} in
the router source so FastAPI's first-match routing doesn't swallow
the literal 'stream' segment as a claim id.

api.py: 2201 -> 1841 (-360). Net diff: +432 / -389.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
57/57 claims-targeted tests pass.
Live smoke: all 5 routes return expected status codes (200 for
list/stream, 404 for missing-claim detail/serialize/line-recon).
2026-06-21 04:28:11 -06:00
Tyler ea64e6e0f0 docs: list all 8 api_routers/ subpackages in Project layout
Four additional routers landed since the last doc pass:
- activity.py (GET /api/activity, /api/activity/stream)
- batches.py (GET /api/batches, /api/batches/{id}, /api/batch-diff)
- providers.py (GET /api/providers)
- clearhouse.py (GET /api/clearhouse, POST /api/clearhouse/submit)
- config.py (/api/config/providers[/...], /api/config/payers[/...],
  POST /api/admin/reload-config)

Update the Project layout block to list all 8 routers with the routes
they own. No new functionality is introduced — the refactor is purely
a code-organization move from api.py into the api_routers/ subpackage.
No code changes; no tests touched.
2026-06-21 03:05:52 -06:00
Tyler 6ce638553b refactor(api): split batches router (list summary + detail)
Extracts GET /api/batches and GET /api/batches/{batch_id} from api.py
into cyclone.api_routers.batches. The _batch_summary_claim_count
helper moves along (only used by list_batches).

api.py: 2257 -> 2201 (-56). Net diff: +93 / -64.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
Live smoke: /api/batches 200 JSON; /api/batches/{id} 404 on missing;
NDJSON form returns the expected summary envelope.
2026-06-21 02:45:47 -06:00
Tyler e63be87ba9 refactor(api): split activity router (list + live-tail stream)
Extracts GET /api/activity and GET /api/activity/stream from api.py
into cyclone.api_routers.activity. The activity router is small and
self-contained: store.recent_activity() for the snapshot half,
tail_events() (from api_helpers) for the live tail.

activity_stream is re-exported at the cyclone.api module level so
test_api_stream_live.py's direct import keeps working.

api.py: 2310 -> 2257 (-53). Net diff: +110 / -61.
Pytest: 8 failed / 735 passed / 16 skipped — identical to baseline.
Live smoke: /api/activity 200 JSON; /api/activity/stream 200
application/x-ndjson with the expected snapshot lines.
2026-06-21 02:41:20 -06:00
Tyler 6aa440d3e4 refactor(api): trailing newlines + hoist clearhouse.py parser imports
Self-review nits from Checkpoint 2b:
- All three new router files lacked a trailing newline (same nit as 2a).
- clearhouse.py had lazy imports of serialize_837 / parse /
  db as _db inside helper bodies. Hoisted serialize_837 and
  parse to module-level (no import cycle risk: clearhouse.py does
  not import from cyclone.api). Dropped the redundant db as _db
  alias — the top-level from cyclone import db is already in scope.

No behavior change. test_clearhouse_api: 10 / 10 passing.
2026-06-21 02:30:11 -06:00
Tyler 45cafbdb32 refactor(api): split providers + clearhouse + config routers
Checkpoint 2b. Extracts 7 more endpoints (1 + 2 + 4) into three
new routers:

- providers.py: GET /api/providers (list distinct providers from
  claim rows; npi/state filter; NDJSON or paginated JSON).
- clearhouse.py: GET /api/clearhouse + POST /api/clearhouse/submit.
  Carries the two serialize-from-raw helpers (_serialize_claim_for_submit,
  _serialize_claim_from_raw) since they're only used here.
- config.py: GET /api/config/providers + GET /api/config/providers/{npi}
  + GET /api/config/payers + GET /api/config/payers/{payer_id}/configs.
  Payer configs endpoint merges YAML-loaded blocks with any DB-overridden
  live blocks per payer.

api.py shrank from 2474 to 2310 lines (-164) and no longer has any
single resource's full request/response shape — every remaining route
now lives in a dedicated router module.

Verifies:
- Full pytest: 8 failed / 735 passed / 16 skipped — identical to
  clean main baseline (the 8 are pre-existing env/secret/sqlcipher).
- Live smoke: /api/health, /api/providers, /api/clearhouse,
  /api/config/payers, /api/config/payers/co_medicaid/configs all 200.
  /api/config/providers/{npi-not-seeded} returns 404 as expected.

See /tmp/refactor-cyclone.md for the full plan and progress.
2026-06-21 02:28:56 -06:00
Tyler bbf89c9dd8 docs: add Batches, Reconciliation, and Activity endpoint reference
The README's SP-specific endpoint reference blocks (SP3-SP15 in the
Roadmap) cover the per-SP additions, but the pre-existing core operator
surface was never documented as a single block. This pass adds the
missing endpoints:

- GET /api/batches, GET /api/batches/{batch_id} — batch list + detail.
- GET /api/batch-diff?a=<id>&b=<id> — side-by-side diff between two
  batches (added/removed/changed claims + envelope metadata).
- GET /api/reconciliation/unmatched — every claim with no paired
  remit and every remit with no paired claim; powers the
  reconciliation review UI.
- POST /api/reconciliation/match — manual pair (claim_id, remit_id);
  400/404/409 contract.
- POST /api/reconciliation/unmatch — remove a match and reset the
  claim to 'submitted'.
- GET /api/providers — distinct providers from the parsed claim
  stream. Distinct from /api/config/providers/{npi} (the SP9 config
  table endpoint).
- GET /api/activity — recent activity events. Powers the Activity
  page; the streaming counterpart /api/activity/stream is already
  documented under 'Live updates'.

These 7 routes are referenced by the UI (BatchesList, BatchDetail,
BatchDiffView, Reconciliation page, Providers page, Activity page) but
were missing from the README's route inventory. The new section sits
between 'SFTP Wire-Up' and 'Persistence', with a one-paragraph pointer
to the per-SP endpoint reference blocks for SP-specific routes.

No code changes. No tests touched.
2026-06-21 02:07:27 -06:00
Tyler d25f00ac58 docs: surface SP14 (Payer-Rejected UI + acknowledge) + SP15 (key rotation)
The README's most recent merge was SP13. Since then two more sub-projects
landed in main:

- SP14 (5c9365e + 8a65baa) — 5-lane Inbox UI with the Payer-Rejected
  lane rendered alongside Rejected/Candidates/Unmatched/Done today,
  and a bulk Acknowledge action that drops claims from the working
  surface without erasing the original 277CA rejection event. New
  endpoint POST /api/inbox/payer-rejected/acknowledge (idempotent,
  audit-logged). Migration 0010.

- SP15 (47902fd + ab00909) — SQLCipher key rotation in place via
  PRAGMA rekey. New endpoint POST /api/admin/db/rotate-key, serialized
  through a module-level threading.Lock. Switches the SQLAlchemy
  engine to NullPool when SQLCipher is enabled (QueuePool breaks
  SQLCipher thread affinity under FastAPI's per-request threadpool).
  Writes a db.key_rotated audit event with old + new key
  fingerprints and post-rotation table_count.

What this PR does:

- Inbox section + Inbox endpoint table: document the Payer-Rejected
  acknowledge action.
- Encryption at Rest: add a 'Key rotation' sub-section that explains
  the rotate-key handler, the NullPool choice, the threading.Lock,
  and the error code mapping (409/400/503).
- Tamper-Evident Audit Log: note that key rotations + Payer-Rejected
  acknowledgements are part of the audit-logged event set.
- Project layout: add api_routers/ (health.py, acks.py, ta1_acks.py)
  as the FastAPI APIRouter subpackage extracted from api.py.
- Roadmap: bump 'shipped 2-13' to 'shipped 2-15'; add SP14 and SP15
  entries to the shipped list; add SP14 + SP15 endpoint reference
  sections at the end.

Verification:

- pytest --collect-only collects 759 tests (up from 733 at the
  previous doc pass).
- git diff --check clean.
- No code changes; no tests touched.
2026-06-21 01:07:44 -06:00
511 changed files with 6572 additions and 118089 deletions
-13
View File
@@ -1,13 +0,0 @@
# 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/
+4 -37
View File
@@ -1,40 +1,7 @@
# Cyclone — environment configuration
# Copy this file to `.env.local` and fill in values for your environment.
# Required on first boot. Cyclone refuses to start without these unless
# at least one user already exists (e.g. seeded via `python -m cyclone users create`).
# Min 12 chars for password.
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
# ─── Gainwell / HCPF SFTP credentials (operator convention) ─────────────
# These mirror what /home/tyler/EDI/scripts/upload_claims.sh exports and
# what the operator's SFTP client uses. Cyclone's secrets module looks up
# CYCLONE_SFTP_PASSWORD (or _FILE), not GAINWELL_SFTP_PASS — to bridge:
#
# export CYCLONE_SFTP_PASSWORD="$GAINWELL_SFTP_PASS"
#
# or for the daemon, set CYCLONE_SFTP_PASSWORD_FILE to a 0600 file.
GAINWELL_SFTP_USER=colorado-fts\coxix_prod_11525703
GAINWELL_SFTP_HOST=mft.gainwelltechnologies.com
GAINWELL_SFTP_PASS=
GAINWELL_REMOTE_DIR=/CO XIX/PROD/coxix_prod_11525703/ToHPE
# CYCLONE_SFTP_PASSWORD= # mirror of GAINWELL_SFTP_PASS
# CYCLONE_SFTP_PASSWORD_FILE= # path to a 0600 file containing it
# CYCLONE_SCHEDULER_AUTOSTART= # 1/true/yes to start the MFT poll loop on API launch
# CYCLONE_SCHEDULER_POLL_SECONDS=60 # poll interval when autostart is on
# CYCLONE_TAIL_HEARTBEAT_S=15 # NDJSON stream heartbeat interval
# Base URL for the Python (FastAPI) backend that powers the Upload page and
# the real /api/parse-837 + /api/parse-835 endpoints. Leave empty to keep
# the in-memory sample data store and disable real EDI parsing.
VITE_API_BASE_URL=http://localhost:8000
+2 -28
View File
@@ -20,24 +20,10 @@ build/
*.swp
*.swo
# macOS extraction residue (AppleDouble / __MACOSX from unzip on macOS).
# These are paired with every regular file in an extracted zip — never data.
__MACOSX/
._*
# Operator drop zone (untracked working dir; contents are HCPF-delivered
# inbound files, never source). Use `mkdir -p ingest && touch ingest/.gitkeep`
# if you want the directory itself in the repo.
ingest/
# Local config
.env
.env.local
.env.*.local
# Edifabric / EdiNation operator-supplied dev key (env file).
# Lives at backend/.env.cyclone-edifabric; source it in the shell.
.env.cyclone-edifabric
backend/.env.cyclone-edifabric
# Production data (handled by ops, not committed)
docs/prodfiles/*/
@@ -49,17 +35,5 @@ claims_output/
# Worktrees (subagent-driven development)
.worktrees/
# Brainstorm session artifacts (visual companion mockups, events, server state).
# Skills under .superpowers/skills/ are committed project-scoped guidance.
.superpowers/brainstorm/
# SP33+ scratch / production-data ingest. Generated artifacts live
# here only — the source EDI sits under docs/prodfiles/.
ingest/
# SP41 dev/ scratch dir — generated 837P artifacts from rebill pipeline
# runs. Hundreds of thousands of files; never source. The scripts that
# build them are tracked (dev/unbilled-july2026/scripts/...) but the
# generated x12 artifacts are not.
dev/rebills/
dev/rebills/2026-*
# Brainstorm session artifacts (visual companion mockups, events, server state)
.superpowers/
@@ -1,205 +0,0 @@
---
name: cyclone-api-router
description: "Cyclone FastAPI router conventions (api_routers/, api_helpers.py, response shapes, error envelopes). Use when: adding or changing an HTTP endpoint, splitting a route out of api.py, or wiring a new helper into api_helpers.py."
---
# cyclone-api-router
Cyclone splits its FastAPI surface two ways: resource-group routers
live in `backend/src/cyclone/api_routers/<topic>.py` and are mounted
bare into `api.py`; the parse endpoints (`/api/parse-837`,
`/api/parse-835`, plus the SP40 `/api/admin/validate-837` etc.) stay
as top-level decorators in `api.py` because they span multiple store
modules. This skill codifies the conventions so additions stay
consistent with the routers already shipped.
As of this writing: **22 router modules** under
`backend/src/cyclone/api_routers/` (`acks`, `activity`, `admin`,
`batches`, `claim_acks`, `claims`, `clearhouse`, `config`,
`dashboard`, `eligibility`, `health`, `inbox`, `parse`, `payers`,
`providers`, `rebill`, `reconciliation`, `remittances`, `submission`,
`ta1_acks`, plus a `_shared.py` for cross-router helpers), **one
shared helpers module** at `backend/src/cyclone/api_helpers.py`
(NDJSON primitives + content negotiation + `tail_events`), and
`api.py` itself is now 378 LOC (down from ~3,145 pre-SP36).
## Auth gate (SP23)
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 SP23 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
- **Adding an endpoint.** You're adding a new GET / POST handler —
you need to know whether it belongs in `api.py` (parse / streaming)
or in a new router under `api_routers/`, and what the response
shape and test file conventions look like.
- **Splitting a route.** You're moving a route out of `api.py` into a
dedicated `api_routers/<topic>.py` module and need the import /
mounting rules (`from cyclone.api_routers import <topic>` then
`app.include_router(<topic>.router)`).
- **Adding a helper.** You're wiring a new function into
`api_helpers.py` (NDJSON primitive, content-negotiation probe,
tail-event helper) and need to keep it private to the API layer.
- **Defining an error response.** You're raising from a route handler
and need the conventional `HTTPException(status_code=..., detail=...)`
shape used everywhere else in the API surface.
## Conventions
1. **No new top-level routes in `api.py` for resource groups.** Any
endpoint grouped under a resource (`/api/<resource>` and its
`/{id}` detail) lives in `backend/src/cyclone/api_routers/<topic>.py`
as an `APIRouter`. The streaming list endpoints
(`/api/claims/stream`, `/api/remittances/stream`,
`/api/activity/stream`, `/api/acks/stream`, `/api/ta1-acks/stream`)
were split into their per-resource routers as part of SP36; only
the cross-resource parse endpoints (`/api/parse-*` and the SP40
`validate-837`) stay in `api.py` because they span multiple store
modules.
2. **Reuse `api_helpers.py`.** NDJSON primitives (`ndjson_line`,
`ndjson_stream_list`, `ndjson_stream_837`, `ndjson_stream_835`),
content negotiation (`client_wants_json`, `wants_ndjson`), the
strict / `raw_segments` rewrites (`strict_rewrite_837`,
`strict_rewrite_835`, `drop_raw_segments_837`, `drop_raw_segments_835`),
and the shared live-tail generator (`tail_events`,
`heartbeat_seconds`) all live there. Don't duplicate them in a
router. The module's docstring (`api_helpers.py:1-18`) declares it
private to the API layer — no business logic, no DB writes.
3. **Response shape.** Every successful response is a **plain dict**
produced by a per-router `<entity>_to_ui(row)` helper (see
`_ack_to_ui` at `api_routers/acks.py:29-48` and `_ta1_to_ui` at
`api_routers/ta1_acks.py:23-37`). This dict shape **must** match
the matching `<entity>_written` event payload so live-tail pages
don't drift (see `cyclone-store` for the serializer contract).
Errors use FastAPI's `HTTPException` with a `detail` dict of the
form `{"error": "<Title>", "detail": "<message>"}`
`acks.py:85-88`, `ta1_acks.py:72`. There is **no** shared
`ErrorEnvelope` Pydantic model; the `detail` dict is the contract.
4. **Mounting.** Routers are mounted bare in `api.py:251-256`
`app.include_router(<name>.router)` with **no** `prefix=`
argument. Each `@router.<verb>` decorator carries the **full**
`/api/<resource>` path itself (see `acks.py:51,75`,
`ta1_acks.py:49,67`, `admin.py:23`, `health.py:28`). The
`router = APIRouter()` declaration carries no `tags=` either —
keep it minimal.
5. **Streaming endpoints.** Use
`StreamingResponse(media_type="application/x-ndjson")` and feed it
either `ndjson_stream_list(items, total, returned, has_more)` (for
list pages) or `tail_events(request, bus, kinds)` (for live-tail
pages). See `api_routers/acks.py:62-66` for the list-stream
skeleton and `api_routers/claims.py` (the `/api/claims/stream`
handler) for the full live-tail pattern (snapshot →
`snapshot_end` → subscription → heartbeats). See `cyclone-tail`
for the wire format.
6. **Tests.** Every new endpoint gets a `test_api_<topic>_<verb>.py`
under `backend/tests/` (see `cyclone-tests` for the naming
convention + autouse `conftest.py`). Existing examples:
`test_api_validate_provider.py` (admin),
`test_api_parse_persists_ack.py` (acks).
## Patterns
### A new `APIRouter` skeleton
Pattern from `backend/src/cyclone/api_routers/acks.py:1-72`. Module
docstring names the resource, imports the shared helpers, declares
`router = APIRouter()` with no prefix, and defines a `_foo_to_ui(row)`
mapper at module scope.
```python
"""``/api/foo`` — list & detail endpoints for <topic>."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.store import store
router = APIRouter()
def _foo_to_ui(row) -> dict:
"""Map a Foo ORM row to the UI shape used by ``/api/foo``."""
return {"id": row.id, "name": row.name}
@router.get("/api/foo")
def list_foo(
request: Request,
limit: int = Query(100, ge=1, le=1000),
):
"""Return the list of persisted Foo rows, newest first."""
rows = store.list_foo()
items = [_foo_to_ui(r) for r in rows[:limit]]
total = len(rows)
returned = len(items)
has_more = total > returned
if wants_ndjson(request):
return StreamingResponse(
ndjson_stream_list(items, total, returned, has_more),
media_type="application/x-ndjson",
)
return {"items": items, "total": total, "returned": returned, "has_more": has_more}
```
### A `get_<topic>` detail endpoint with 404
Pattern from `api_routers/acks.py:75-104` and `ta1_acks.py:67-76`.
Path param is `<entity>_id` (not `id`) so it doesn't shadow
FastAPI's internal `id` and the OpenAPI docs stay self-describing.
```python
@router.get("/api/foo/{foo_id}")
def get_foo(foo_id: int) -> dict:
"""Return one persisted Foo row with its parsed detail.
Path param is ``foo_id`` (not ``id``) to avoid shadowing
FastAPI's internal ``id`` name and to keep OpenAPI docs
self-describing. Returns 404 when the row is missing — never 500.
"""
row = store.get_foo(foo_id)
if row is None:
raise HTTPException(
status_code=404,
detail={"error": "Not found", "detail": f"Foo {foo_id} not found"},
)
return _foo_to_ui(row)
```
### Mounting in `api.py`
Pattern from `backend/src/cyclone/api.py:246-256`. The block lives
just after middleware registration and just before the first
`@app.<verb>` decorator.
```python
# Resource-group routers. Each module owns its own APIRouter and is
# registered below. New resources go in `cyclone.api_routers.<name>`
# and are wired in here.
from cyclone.api_routers import ( # noqa: E402
acks, activity, admin, batches, claim_acks, claims, clearhouse,
config, dashboard, eligibility, health, inbox, parse, payers,
providers, rebill, reconciliation, remittances, submission, ta1_acks,
)
app.include_router(health.router)
app.include_router(acks.router)
app.include_router(activity.router)
app.include_router(admin.router)
# ... 18 more include_router calls in api.py ...
```
## Anti-patterns
- **Don't import from `cyclone.api` into a router — the dependency runs the other way.** Routers are mounted *into* `api.py` (`api_routers/acks.py` etc. know nothing about `cyclone.api`). A circular import would silently break the `from cyclone.api_routers import ...` block at `api.py:251`.
- **Don't introduce Pydantic response models where the codebase returns dicts.** Every existing list / detail endpoint returns a plain dict produced by a `<entity>_to_ui(row)` helper (see `acks.py:29-48`, `ta1_acks.py:23-37`). That dict shape **is** the event payload that `<entity>_written` carries — see `cyclone-store`. Introducing a Pydantic model on one side drifts the event payload from the list shape and silently breaks live-tail dedup.
- **Don't bypass `CycloneStore` to query the ORM directly from a route.** Always call `store.<method>(...)` (`store.list_acks()`, `store.get_ta1_ack(ack_id)`) so the read path picks up the same session + snapshot serializer as the live-tail subscriber. A raw `with db.SessionLocal()() as s: s.get(Foo, foo_id)` in a handler bypasses the serializer contract and breaks the event-payload match. See `cyclone-store`.
- **Don't set `prefix=` on `APIRouter`.** Mount the router bare (`app.include_router(<name>.router)`) and put the full `/api/<resource>` path in the decorator. Mixing the two styles scatters the URL across two files and breaks `grep "/api/foo"` audits.
## Related skills
- **`cyclone-store`** — most routes call `store.<method>(...)` and the dict payload **is** the `<entity>_written` event payload; load when adding a route so the read path stays aligned with the pubsub contract.
- **`cyclone-tail`** — streaming endpoints (`/api/<resource>/stream`) and the NDJSON wire format; load when adding a live-tail route or changing the wire format.
- **`cyclone-edi`** — parse endpoints (`/api/parse-837`, `/api/parse-835`, `/api/parse-999`, `/api/parse-ta1`, `/api/parse-277ca`) currently live in `api.py`; load when adding or changing a parse endpoint.
- **`cyclone-tests`** — endpoint tests follow the `test_api_<topic>_<verb>.py` naming under `backend/tests/`; load when writing the test for a new endpoint.
-197
View File
@@ -1,197 +0,0 @@
---
name: cyclone-cli
description: "Cyclone CLI subcommand conventions (cli.py — Click group + subcommands parse-837/parse-835/validate-npi/validate-tax-id/backup, --yes + click.confirm for destructive ops, exit codes 0/1/2, CliRunner smoke tests in backend/tests/test_cli_*.py). Use when: adding a CLI subcommand, changing an exit code, adding a smoke test, or wiring a security-sensitive command (backup, key rotation, anything touching secrets.py)."
---
# cyclone-cli
The operator-facing CLI is a **Click** group at `cli.py:45` (`@click.group()` for `main`), mounted as the `cyclone` console script in `pyproject.toml:54` (`cyclone = "cyclone.cli:main"`). The CLI has grown well past the original seven subcommands; the full inventory is:
- **Parser / validator commands**: `parse-837`, `parse-835`, `validate-npi`, `validate-tax-id`, `validate-837` (SP40, Edifabric fail-closed pre-upload gate).
- **DB plumbing**: `seed` (deterministic sample claims), `backfill-rendering-npi`, `backfill-999-rejections` (SP33).
- **Submission flow**: `submit-batch` (SP37, canonical parse → DB-write → SFTP-upload), `resubmit-rejected-claims` (SP33), `recover-ingest` (re-parse a local path), `pull-inbound` (scheduler trigger).
- **Rebill + reissue**: `rebill-from-835` (SP41), `reissue-claims` (SP24).
- **Ack orphans + resubmissions**: `ack-orphans status / reconcile`, `resubmissions status`.
- **User management** (`users` group): `create`, `list`, `disable`, `reset-password`, `set-role`.
- **Backup group** (`backup`): `init-passphrase`, `create`, `list`, `verify`, `restore`, `prune`, `status`.
`serve` lives separately in `__main__.py:19` (dispatches `uvicorn cyclone.api:app`).
## When to use
- **Adding a subcommand.** You need a new operator command (e.g. `cyclone rotate-key`) and want to match the existing Click decorator + smoke-test rhythm.
- **Changing an exit code.** You're tweaking which `sys.exit(N)` a subcommand raises and need the 0/1/2 contract used by `parse_837`, `parse_835`, `validate_npi_cmd`, `validate_tax_id_cmd`, and the `backup` group.
- **Adding a smoke test.** You need `backend/tests/test_cli_<name>.py` using `click.testing.CliRunner` (NOT `subprocess.run`) and want the canonical fixture + monkeypatch layout (Keychain stub, fresh SQLite, `CYCLONE_BACKUP_DIR`).
- **Wiring a security-sensitive command.** Anything touching `cyclone/secrets.py` (Keychain writes), DB key rotation, or destructive restores needs the two-step confirm dance used by `backup restore` / `backup prune` (`cli.py:462,509`).
## Conventions
1. **Click decorator pattern, not argparse.** Each subcommand is a
top-level function decorated with `@main.command("<name>")` and
one `@click.option` / `@click.argument` per parameter. Group
dispatch is implicit — no `set_defaults(func=...)` and no
`cmd_<name>(args) -> int` signature. Top-level commands at
`cli.py:77,151,241,261,303`; the `backup` sub-group nests a
second `@main.group()` (`cli.py:303`) with its own
`@backup.command("<name>")` children.
2. **Long-form flags for safety.** Prefer `--rotate-key`,
`--backup-dir`, `--from-stdin` over positional args for anything
that mutates state or takes a secret. `init-passphrase`
(`cli.py:308-311`) demonstrates the canonical "flag OR stdin"
pattern: `--passphrase` for automation, `--from-stdin` for
interactive `getpass()` prompting.
3. **Exit codes: 0 / 1 / 2.** `0` = success. `1` = user / input
error (invalid NPI/EIN at `cli.py:258,277,285`; Keychain write
failure at `cli.py:341,351`; tampered-backup verify at
`cli.py:459`). `2` = operator / parse error
(`CycloneParseError` at `cli.py:106,178`; passphrase
empty/mismatch/short at `cli.py:331,337`). Document the codes
in the docstring (see `validate_npi_cmd` at `cli.py:244-250`).
Use `click.UsageError(...)` for usage mistakes; reserve
`sys.exit(2)` for "the file failed to parse" semantics.
4. **Smoke test with `click.testing.CliRunner`.** Every new
subcommand gets `backend/tests/test_cli_<name>.py` that
imports `from cyclone.cli import main` and invokes via
`CliRunner().invoke(main, [...], catch_exceptions=False)`. The
test must stub Keychain (`monkeypatch.setattr(secrets_mod,
"get_secret"/"set_secret", ...)`) and pin a temp SQLite DB via
`CYCLONE_DB_URL` + `db._reset_for_tests()` — see
`backend/tests/test_cli_backup.py:13-69` for the canonical
`_cli_env` fixture. CliRunner captures output and exit codes
in-process; do NOT shell out to `subprocess.run`.
5. **Destructive ops need `--yes` + `click.confirm(abort=True)`.**
`backup restore` (`cli.py:462-506`) and `backup prune`
(`cli.py:509-537`) both gate the destructive action behind a
`--yes` is_flag and an interactive `click.confirm(..., abort=True)`
prompt. CliRunner auto-aborts confirm prompts, so smoke tests
assert `exit_code != 0` when `--yes` is omitted
(`test_cli_backup.py:122-137`). A `--dry-run` flag is NOT yet
implemented anywhere; if needed, mirror the `--yes` pattern.
## Patterns
### A Click subcommand — `@main.command("<name>")` + options
From `cli.py:241-258` (smallest standalone subcommand):
```python
@main.command("validate-npi")
@click.argument("npi")
@click.option("--log-level", default="WARNING", show_default=True,
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_npi_cmd(npi: str, log_level: str) -> None:
"""Validate a 10-digit NPI's Luhn checksum locally (SP20). Exit 0 valid, 1 invalid. PHI — don't log the value."""
setup_logging(level=log_level)
from cyclone.npi import is_valid_npi
if is_valid_npi(npi):
click.echo(f"OK: {len(npi)}-digit NPI passes Luhn checksum")
return
click.echo(f"INVALID: {npi!r} fails NPI Luhn checksum", err=True)
sys.exit(1)
```
### A smoke test — `CliRunner` + Keychain stub + temp SQLite
From `test_cli_backup.py:13-69`. Stable hex salt keeps multiple `CliRunner` invocations consistent within one test. The `Batch` seed at `cli_backup.py:27-35` is omitted — only the Keychain + DB plumbing is the convention:
```python
@pytest.fixture
def _cli_env(tmp_path, monkeypatch):
from cyclone import db, secrets as secrets_mod
from cyclone import backup_service as svc_mod
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
# ... seed any DB rows the subcommand needs (see cli_backup.py:27-35) ...
store = {svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT: "cli-test-passphrase",
svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT:
"0123456789abcdef0123456789abcdef"}
monkeypatch.setattr(secrets_mod, "get_secret", lambda n: store.get(n))
monkeypatch.setattr(secrets_mod, "set_secret",
lambda n, v: store.__setitem__(n, v) or True)
monkeypatch.setenv("CYCLONE_BACKUP_DIR", str(tmp_path / "backups"))
yield tmp_path / "backups"
db._reset_for_tests()
def test_backup_create_list_verify_status(_cli_env):
from cyclone.cli import main
runner = CliRunner()
r = runner.invoke(main, ["backup", "create"], catch_exceptions=False)
assert r.exit_code == 0, r.output
assert "created backup id=" in r.output
```
### A destructive subcommand — `--yes` + `click.confirm(abort=True)`
From `cli.py:462-506` (`backup restore`). Two-step: announce, prompt unless `--yes`, then execute. Restore uses an explicit init/confirm round-trip so the operator can back out between phases. Matching smoke test asserts the guard fires:
```python
@backup.command("restore")
@click.argument("backup_id", type=int)
@click.option("--yes", is_flag=True, help="Skip the interactive confirm prompt")
@click.option("--actor", default="operator-cli", show_default=True)
def backup_restore(backup_id: int, yes: bool, actor: str) -> None:
"""Restore the live DB from a backup (two-step, requires --yes)."""
# ... db.init_db() + service config omitted ...
click.echo(f"Initiating restore from backup {backup_id}...")
init = svc.restore_initiate(backup_id)
# ... echo init summary (filename, fp, table_count, ttl) ...
if not yes:
click.confirm(
"Replace the live DB with this backup? "
"This will dispose the engine and rebuild it.",
abort=True,
)
click.echo("Confirming restore...")
result = svc.restore_confirm(backup_id, init.restore_token, actor=actor)
def test_backup_restore_requires_yes_flag(_cli_env):
runner = CliRunner()
runner.invoke(main, ["backup", "create"], catch_exceptions=False)
r = runner.invoke(main, ["backup", "restore", "1"], catch_exceptions=False)
# CliRunner auto-aborts confirm prompts → exit_code != 0.
assert r.exit_code != 0
```
## Anti-patterns
- **Don't `sys.exit(2)` for usage errors.** Reserve code 2 for
parse/operator errors (`CycloneParseError`, Keychain not
initialized, passphrase policy violation). For "you passed the
wrong flag" use `raise click.UsageError(...)` — Click formats
it as a clean help message and exits 2 on its own.
- **Don't print errors to stdout.** Use `click.echo(msg, err=True)`
for all error output. `print(..., file=sys.stderr)` and bare
`logging.error(...)` bypass Click's stdout/stderr split and leak
into test `result.output` — breaking `assert "FAIL" in r.output`
style assertions.
- **Don't add a subcommand without a smoke test.** Every new
`@main.command(...)` ships a sibling
`backend/tests/test_cli_<name>.py` exercising the happy path
AND at least one error path (missing input, invalid arg,
tampered ciphertext — see `test_cli_backup.py:102-119`).
- **Don't reuse the `parse` command name.** Existing subcommands
are type-specific (`parse-837`, `parse-835`); a generic `parse`
would shadow them or force an `--type` flag — neither is the
codebase pattern.
## Related skills
- **`cyclone-store`** — `backup` subcommands and the parse
subcommands both round-trip through `CycloneStore` and the DB
session; load when the increment changes write paths or the
`<entity>_written` event contract.
- **`cyclone-api-router`** — `cyclone serve` (via `__main__.py:19`)
launches the FastAPI app; the CLI parse subcommands share the
same `CycloneParseError` exception and Pydantic result models
as the matching HTTP endpoints.
- **`cyclone-edi`** — `parse-837` / `parse-835` are the CLI smoke
entry points for the parser/validator surface; load when adding
a parser or R-code rule.
- **`cyclone-tests`** — every CLI subcommand gets a pytest smoke
case under `backend/tests/test_cli_<name>.py`; the `_cli_env`
fixture pattern (fresh SQLite + Keychain stub) is documented
there.
- **`cyclone-spec`** — load when the SP-N spec introduces a new operator command or reserves a new exit-code category.
-193
View File
@@ -1,193 +0,0 @@
---
name: cyclone-edi
description: "Cyclone EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1). Use when: adding or changing a parser, adding a validator rule (R010/R020/R100/R200-R210/R835_*/NPI Luhn/EIN/CAS), or mapping a new CAS adjustment reason code."
---
# cyclone-edi
Cyclone parses seven X12 EDI transaction types (837P, 835, 999, 270, 271, 277CA, TA1) into typed Pydantic models, then runs per-claim / per-batch validator rules that surface as R-coded `ValidationIssue` records. This skill codifies the conventions so new parsers and rules stay consistent with the seven that already exist.
As of this writing: **7 parser modules** under `backend/src/cyclone/parsers/parse_<edi>.py` (837P, 835, 999, TA1, 270, 271, 277CA), **~25 per-claim rules** numbered `R010``R100` and `R200``R210` in `validator.py`, plus a parallel set of **835-specific rules** prefixed `R835_*` in `validator_835.py`. The most recently shipped increment touching this surface is **SP41** (in-window rebill pipeline); the next free increment after this SP42 doc-pass is **SP43**.
## When to use
- **Adding or changing a parser.** You are about to touch `backend/src/cyclone/parsers/parse_<edi>.py` or its paired `models_<edi>.py` and need the orchestrator signature, the segment walker convention, and the re-export in `parsers/__init__.py`.
- **Adding a validator rule.** You are writing a new `_rule_R<n>_<name>` (or `_r<n>_<name>` per the existing snake-case style) and need the rule signature, the R-code numbering scheme, and the `ValidationIssue` shape.
- **Wiring a new CAS / CARC code.** The 835 carries Claim Adjustment Reason Codes in `CAS` segments; the lookup lives in `backend/src/cyclone/parsers/cas_codes.py` and the UI reads through `claim_status_label()`.
- **Debugging a parse failure on a prodfiles sample.** You dropped a real EDI file into `docs/prodfiles/<source>/` and the parser is choking — load this skill to confirm the tokenizer path, the orchestrator entry point, and which fixture in `backend/tests/fixtures/` matches the transaction type.
## Conventions
1. **Parser signature.** Every parser module exports exactly one public entry function. Two flavors coexist in the codebase:
- `parse(text: str, *, input_file: str = "") -> <TypedResult>` — used by `parse_270.py:337`, `parse_271.py:356`.
- `parse(text: str, payer_config: <PayerConfig>, input_file: str = "") -> <TypedResult>` — used by `parse_837.py:319` and `parse_835.py:459` because both need payer-specific config to validate segments against.
- `parse_<edi>_text(text: str, *, input_file: str = "") -> <TypedResult>` — the legacy name-suffixed form, still in use at `parse_ta1.py:143`, `parse_999.py:220`, `parse_277ca.py:280`. The `<TypedResult>` is always a Pydantic model from `models_<edi>.py` (or co-located `models.py` for 837P).
2. **Segment walk.** Parsers consume `backend/src/cyclone/parsers/segments.py` — there are exactly three public pieces: `Delimiters` (frozen dataclass holding the four ISA-derived separators), `_detect_delimiters(isa_segment)` (private), and `tokenize(text) -> list[list[str]]` (returns ISA prepended as the first segment). Parsers then index into the `list[list[str]]` directly — there is **no** `Segment` / `Loop` / `next_segment` helper class. Whole-document problems (missing ISA, wrong transaction set) raise `CycloneParseError`; per-segment problems on acks (999/277CA) are surfaced on the result, not raised.
3. **Validator rules.** Numbered rules live in `backend/src/cyclone/parsers/validator.py` (for 837P — R010R100 general + R200R210 SP9 CO MAP / HCPF naming) and `backend/src/cyclone/parsers/validator_835.py` (for 835 — names prefixed `R835_*` because the same numeric space would collide with 837P). Each rule is a function `_r<n>_<name>(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]` registered in the module-level `_RULES` list and run by `validate(claim, config)`. Issues carry the rule name as a stable string (`rule="R021_npi_checksum"`) — the R-code **is** how the UI surfaces the error, so never invent an unnumbered rule.
4. **NPI / EIN / CAS format logic.** Identity-format checks live in their own modules — never duplicate them in a parser or validator:
- `backend/src/cyclone/npi.py``is_valid_npi(npi)` runs the Luhn checksum with the `80840` NPPES prefix; `is_valid_tax_id(ein)` enforces `XX-XXXXXXX` (or 9 raw digits).
- `backend/src/cyclone/parsers/cas_codes.py``reason_label(group, reason)` and `all_known_codes()` for the CARC lookup; snapshot date is exported as `LAST_UPDATED`.
- `backend/src/cyclone/parsers/models_271.py``SERVICE_TYPE_CODES` + `service_type_description()` for 271 EB benefit codes.
5. **Prodfiles reuse.** When adding a parser for a new transaction type, ship at least one fixture in `backend/tests/fixtures/<edi>/<sample>.txt` (the existing 13 fixtures are **flat** at the top level of `fixtures/` — no per-test subdirectories). Copy from `docs/prodfiles/<source>/<file>.txt`; never reach into `docs/prodfiles/` from a test. The matching test should declare the path as a module-level `Path` constant.
## Patterns
### Minimal `parse_<edi>.py` — using `segments.py`, exporting `parse_ta1_text`
Taken from `backend/src/cyclone/parsers/parse_ta1.py:1-29` (the smallest parser — TA1 is just ISA + TA1 + IEA). The same skeleton scales to every other EDI type by adding `_consume_<segment>` helpers.
```python
"""Parse an X12 TA1 (Interchange Acknowledgment) file.
Whole-document problems (missing ISA, no TA1) raise CycloneParseError.
"""
from __future__ import annotations
import logging
from datetime import date
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_ta1 import ParseResultTa1, Ta1Ack
from cyclone.parsers.segments import tokenize
log = logging.getLogger(__name__)
def _parse_yyyymmdd(s: str) -> date | None:
"""Parse an 8-digit CCYYMMDD string. Returns None on bad input."""
...
def _build_envelope(segments: list[list[str]], input_file: str) -> Envelope:
"""Build the envelope from ISA. TA1 has no GS/ST — just ISA → TA1 → IEA."""
...
def _consume_ta1(segments: list[list[str]], idx: int) -> tuple[Ta1Ack, int]:
"""Read a TA1 segment and return a Ta1Ack. Returns (model, next_idx)."""
...
def parse_ta1_text(text: str, *, input_file: str = "") -> ParseResultTa1:
"""Parse a complete TA1 document and return a ParseResultTa1."""
segments = tokenize(text)
envelope = _build_envelope(segments, input_file=input_file)
ta1_idx = next(
(i for i, seg in enumerate(segments) if seg[0] == "TA1"), None,
)
if ta1_idx is None:
raise CycloneParseError("No TA1 segment found")
ta1, _ = _consume_ta1(segments, ta1_idx)
...
return ParseResultTa1(envelope=envelope, ta1=ta1, summary=summary, ...)
__all__ = ["parse_ta1_text"]
```
The orchestrator pattern is the same in every parser: `tokenize``_build_envelope` → segment consumers in order → wrap into a `ParseResult<EDI>` model. The Pydantic result is what the API / store layer consumes.
### A validator rule — `_r<n>_<name>` registered in `_RULES`
Taken from `backend/src/cyclone/parsers/validator.py:23-66` (the canonical R010R100 block).
```python
from collections.abc import Iterable
from cyclone.parsers.models import ClaimOutput, ValidationIssue
from cyclone.parsers.payer import PayerConfig
NPI_RE = re.compile(r"^\d{10}$")
Rule = Callable[[ClaimOutput, PayerConfig], Iterable[ValidationIssue]]
def _r020_npi_format(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
if claim.billing_provider.npi and not NPI_RE.match(claim.billing_provider.npi):
yield ValidationIssue(
rule="R020_npi_format",
severity="error",
message=f"Billing provider NPI must be 10 digits, got {claim.billing_provider.npi!r}",
)
def _r021_npi_checksum(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
"""SP20: validate the billing-provider NPI's Luhn check digit."""
npi = claim.billing_provider.npi
if not npi or not NPI_RE.match(npi):
return # R020 already flagged the format — skip silently.
try:
from cyclone.npi import is_valid_npi
except ImportError:
return
if not is_valid_npi(npi):
yield ValidationIssue(
rule="R021_npi_checksum",
severity="warning",
message=f"Billing provider NPI {npi!r} fails Luhn checksum (likely typo)",
)
_RULES: list[Rule] = [
_r010_clm01_present,
_r011_total_charge_positive,
_r020_npi_format,
_r021_npi_checksum,
# ... R030, R031, R032-R035, R050, R060, R070, R100, R200-R210
]
```
For 835 rules, prefix the rule string with `R835_` (e.g. `R835_BPR01_handling_code_allowed`) and target the `ParseResult835` model instead of `ClaimOutput` — see `backend/src/cyclone/parsers/validator_835.py:38-79`.
### A test that uses a prodfiles fixture
Taken from `backend/tests/test_api_999.py:53-72`. The autouse `conftest.py` already provides a per-test SQLite DB; most tests just add a `client` fixture and reference the fixture path as a module-level constant.
```python
"""Tests for the FastAPI surface in cyclone.api for the 999 endpoint."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
# Fixture reference — flat, module-level Path constant. NEVER reach into
# docs/prodfiles/ from a test; the fixtures/ dir is the test-consumed surface.
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_parse_999_endpoint_happy_path(client: TestClient):
text = ACCEPTED.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ack"]["ack_code"] == "A"
```
For pure-unit parser tests (no API), the same path is reused — see `backend/tests/test_parse_837.py:8` (`FIXTURE = Path(__file__).parent / "fixtures" / "minimal_837p.txt"`).
## Anti-patterns
- **Don't re-parse raw X12 strings inside validators.** Always parse first into the typed `ParseResult<EDI>` / `ClaimOutput`, then validate against that. Validators index into the model fields (or `claim.raw_segments` for spot-checks of specific segment presence) — they never call `tokenize` again. R034's `REF*G1` presence check (`validator.py:104-107`) is the only place that legitimately touches `raw_segments`, and it does so to confirm a single segment exists.
- **Don't bake payer-specific logic into the generic parser.** Payer variations live in `backend/src/cyclone/parsers/payer.py` (`PayerConfig`, `PayerConfig835`) and `backend/src/cyclone/payers.py` (the YAML loader from `config/payers.yaml`). Parsers accept the config as an argument; rules read it from the `cfg` parameter. A new payer never requires a new parser file — extend the config and add / adjust an R-code rule.
- **Don't add a validator rule without an R-code.** The `rule="R<n>_<name>"` string is the stable identifier the UI greys out, the API returns in `errors[].rule`, and tests assert against. Inventing a rule without an R-code (or reusing an R-code with new semantics) breaks the operator workflow. New SP-N increments reserve their R-code range up front (SP9 reserved R200R210, SP20 added R021) and document it in the spec.
## Related skills
- **`cyclone-store`** — load when the increment changes how a parsed `ClaimOutput` / `ParseResult<EDI>` is persisted (`store.py` write path, `<entity>_written` events).
- **`cyclone-api-router`** — load when the increment adds or changes an HTTP endpoint that surfaces a parsed result (e.g. `/api/parse-999`, `/api/parse-837`, `/api/parse-835`).
- **`cyclone-tests`** — every parser addition ships a fixture in `backend/tests/fixtures/` and a pytest case; load this skill for the fixture-drop-in and autouse-conftest rules.
- **`cyclone-cli`** — load when the increment adds a CLI subcommand. The `cyclone parse-837 <file>` and `cyclone parse-835 <file>` smoke commands at `backend/src/cyclone/cli.py:77,151` are the parser-level smoke tests; the `validate-npi` and `validate-tax-id` commands exercise the format helpers.
- **`cyclone-spec`** — load when the SP-N spec for the increment introduces a new R-code range or a new transaction type; the spec's `## Decisions` section is where the R-code reservation gets locked in.
@@ -1,40 +0,0 @@
# Cyclone EDI parsers — flat catalog
Every parser module under `backend/src/cyclone/parsers/` (one row per
file), its transaction type, its public entry signature, its result
model, its primary fixture, and any payer-specific variant. The
companion Pydantic model is in a co-located `models_<edi>.py`; the
segment walker uses `tokenize()` from `segments.py` and never parses
raw text inline.
| Module | EDI type | Public entry signature | Result model | Primary fixture(s) | Payer variant |
|---|---|---|---|---|---|
| `parse_837.py` | 837P (Professional Claim) | `parse(text, payer_config: PayerConfig, input_file="") -> ParseResult` | `cyclone.parsers.models.ParseResult` | `minimal_837p.txt`, `co_medicaid_837p.txt` | `PayerConfig` (CO Medicaid default) |
| `parse_835.py` | 835 (ERA / Remittance) | `parse(text, payer_config: PayerConfig835, input_file="") -> ParseResult835` | `cyclone.parsers.models_835.ParseResult835` | `minimal_835.txt`, `co_medicaid_835.txt`, `unbalanced_835.txt` | `PayerConfig835` |
| `parse_999.py` | 999 (Implementation ACK) | `parse_999_text(text, *, input_file="") -> ParseResult999` | `cyclone.parsers.models_999.ParseResult999` | `minimal_999.txt`, `minimal_999_rejected.txt` | none — single-shape ack |
| `parse_277ca.py` | 277CA (Claim ACK) | `parse_277ca_text(text, *, input_file="") -> ParseResult277CA` | `cyclone.parsers.models_277ca.ParseResult277CA` | `minimal_277ca.txt`, `minimal_277ca_rejected_only.txt`, `minimal_277ca_st277.txt` | `PayerConfig277CA` (config-driven) |
| `parse_270.py` | 270 (Eligibility Inquiry) | `parse(text, *, input_file="") -> ParseResult270` | `cyclone.parsers.models_270.ParseResult270` | `minimal_270.txt` | reuses `PayerConfig` shape |
| `parse_271.py` | 271 (Eligibility Response) | `parse(text, *, input_file="") -> ParseResult271` | `cyclone.parsers.models_271.ParseResult271` | `minimal_271.txt` | reuses `PayerConfig` shape |
| `parse_ta1.py` | TA1 (Interchange ACK) | `parse_ta1_text(text, *, input_file="") -> ParseResultTa1` | `cyclone.parsers.models_ta1.ParseResultTa1` | `minimal_ta1.txt` | none — single-shape ack |
Companion modules (not parsers, but shipped alongside):
| Module | Role |
|---|---|
| `segments.py` | `Delimiters`, `_detect_delimiters`, `tokenize(text) -> list[list[str]]` |
| `models.py` | Pydantic models for 837P (`ParseResult`, `ClaimOutput`, `Envelope`, `BatchSummary`, `ValidationIssue`, `ValidationReport`, …) |
| `models_835.py` / `models_270.py` / `models_271.py` / `models_277ca.py` / `models_999.py` / `models_ta1.py` | Pydantic models for each transaction type |
| `payer.py` | `PayerConfig` + `PayerConfig835` factories |
| `exceptions.py` | `CycloneParseError`, `CycloneValidationError` |
| `cas_codes.py` | CARC lookup: `reason_label(group, reason)`, `all_known_codes()`, `LAST_UPDATED` |
| `validator.py` | 837P rules: R010R100, R200R210; `validate(claim, config) -> ValidationReport` |
| `validator_835.py` | 835 rules: `R835_*` (e.g. `R835_BPR01_handling_code_allowed`); `validate(result, cfg) -> ValidationReport` |
| `serialize_270.py` / `serialize_837.py` / `serialize_999.py` | Outbound (Cyclone → payer) serializers — mirror of `parse_*` |
| `writer.py` / `writer_835.py` | Output writers (one JSON per claim) |
| `batch_ack_builder.py` | `build_ack_for_batch` — produces a 999 for a parsed 837 batch |
| `__init__.py` | Lazy PEP 562 re-exports (`parse`, `parse_835`, `parse_999`, `parse_270`, `parse_271`, `parse_277ca`, plus all models) |
Fixture rule: every parser ships at least one flat fixture in
`backend/tests/fixtures/<edi>-sample.txt`. Prodfiles sources live in
`docs/prodfiles/{837p-from-axiscare,835fromco,FromHPE,claims}/` — copy
from there, never reach in from a test.
@@ -1,138 +0,0 @@
---
name: cyclone-frontend-page
description: "Cyclone React page conventions (TanStack Query, use<X> hook, drawer, URL state, .test.tsx sibling, Layout / PageHeader / Sidebar). Use when: adding a new page, refactoring an existing one, or wiring a drawer into a page."
---
# cyclone-frontend-page
Cyclone pages live at `src/pages/<Name>.tsx`: a `use<X>` hook in `src/hooks/use<X>.ts` does the fetching (and optionally the live-tail subscription), `<Layout>` + `<PageHeader>` + `<Sidebar>` (`src/components/`) provide the app shell, and any right-side detail (claim, remittance) lives in `src/components/<DrawerName>/` whose open/close state is mirrored to the URL via `useDrawerUrlState`. This skill codifies the conventions so additions stay consistent with the twelve pages already shipped (Login added in SP23).
As of this writing: `**12 pages** under `src/pages/` (10 of 12 with a `*.test.tsx` sibling — Dashboard and BatchDiff are not yet covered; be the first when you refactor them; the Login page does not need a sibling), **~30 hooks** under `src/hooks/`, and **4 drawer modules** at `src/components/{ClaimDrawer,RemitDrawer,ProviderDrawer,AckDrawer}/`. The most recently shipped increment is **SP41** (in-window rebill pipeline); the next free increment after this SP42 doc-pass is **SP43**.
## When to use
- **Adding a new page.** Mounting a new screen in `src/pages/` — you need the Layout + PageHeader + table shape, the `use<X>` hook split, and the route registration point in `src/App.tsx`.
- **Refactoring an existing page.** Splitting a 600-line page, swapping a manual `fetch` for a hook, or moving in-component state into the URL — load this skill to confirm the destination shape.
- **Wiring a drawer.** Adding a new right-side detail drawer (e.g. `BatchDrawer`, `ActivityDrawer`) — you need `useDrawerUrlState` for URL-driven open/close, the `src/components/<DrawerName>/` folder layout, and the deep-link contract so `?claim=…` / `?remit=…` round-trips.
- **Sharing state via URL.** Persisting filter / page / drawer state across reloads — confirm the `useDrawerUrlState` / `useRemitDrawerUrlState` hook pair is the right tool before reaching for `useState` + history.
## Conventions
1. **Page shape.** Every page in `src/pages/<Name>.tsx` exports a `function <Name>()` (most pages use a named export — see `src/pages/Claims.tsx:51`, `Remittances.tsx:62`, `Dashboard.tsx:68`; `src/pages/Inbox.tsx:40` is the lone default export). The app shell is provided by the `<Layout>` route wrapper in `src/App.tsx:30`. Each page sets a `<PageHeader>` (`src/components/PageHeader.tsx:18`) and renders a table, list, or KPI grid. Sidebar nav is mounted once by `<Layout>` at `src/components/Sidebar.tsx`.
2. **Data hook.** Each page pairs with a `use<X>` hook in `src/hooks/use<X>.ts` (e.g. `Claims` ↔ `useClaims`, `Remittances` ↔ `useRemittances`, `Acks` ↔ `useAcks`). The hook returns `{ data, isLoading, isError, error, refetch }` from TanStack Query's `useQuery` (see `src/hooks/useClaims.ts:24-31`, `useRemittances.ts:21-25`). Pages never call `fetch` or `@/lib/api` directly — the hook is the boundary so the page is testable with `vi.mock("@/lib/api", ...)`.
3. **Live tail.** Pages with live data compose three hooks in order: the `use<X>` initial fetch, `useTailStream(resource)` (`src/hooks/useTailStream.ts:80` — opens the NDJSON stream, drives the backoff/stall state machine), and `useMergedTail(resource, baseItems, filterFn?)` (`src/hooks/useMergedTail.ts:25` — merges snapshot + tail, dedup'd by id). The full wiring lives at `src/pages/Claims.tsx:85-89` and `Remittances.tsx:81-83`. The streaming subscription belongs on the page, not in the data hook — hoisting it couples the lifecycle to whoever mounts `use<X>`.
4. **Drawer.** Right-side detail drawers live in `src/components/<DrawerName>/` (currently `ClaimDrawer/`, `RemitDrawer/`) with a barrel `index.ts` (`src/components/ClaimDrawer/index.ts:1-14`). The drawer is wired to URL state via `useDrawerUrlState()` for `?claim=…` (`src/hooks/useDrawerUrlState.ts`) or `useRemitDrawerUrlState()` for `?remit=…` (`src/hooks/useRemitDrawerUrlState.ts`). Open state is driven by the URL, not a `useState` flag, so deep-links round-trip.
5. **Tests.** Every page gets a `src/pages/<Name>.test.tsx` sibling (e.g. `Claims.test.tsx`, `Remittances.test.tsx`, `Batches.test.tsx`). Every hook gets a `src/hooks/use<X>.test.ts` sibling (e.g. `useClaims.test.ts`, `useRemittances.test.ts`). The shared setup — `// @vitest-environment happy-dom`, `IS_REACT_ACT_ENVIRONMENT = true`, `QueryClient` provider, `vi.mock("@/lib/api", ...)` — is documented in `cyclone-tests`; mirror `src/pages/Claims.test.tsx:1-30` for the canonical page-test shape.
6. **UI primitives.** Use Radix-backed components from `src/components/ui/` (`button.tsx`, `dialog.tsx`, `table.tsx`, `select.tsx`, `pagination.tsx`, `empty-state.tsx`, `error-state.tsx`, `filter-chips.tsx`, `skeleton.tsx`, `input.tsx`, `label.tsx`, `card.tsx`, `badge.tsx`, `skip-link.tsx`, `claim-state-badge.tsx`). Don't pull in a new UI library without discussion — every primitive here is already consumed by at least one shipped page.
7. **Routing.** Pages register their route in `src/App.tsx` as a `<Route path="<name>" element={<<Name>> />} />` inside the `<Layout>` element wrapper (`src/App.tsx:30-44`). Currently every page is a static import; switch to `React.lazy(() => import(...))` only if a page grows heavy (large parse/EDI libs, chart code) and the import cost shows up in the bundle report.
## Patterns
### `Claims.tsx`-style page (Layout + PageHeader + table + drawer)
Canonical page shape — composes the data hook, the tail triplet, and
`useDrawerUrlState` for the `?claim=…` deep-link. See
`src/pages/Claims.tsx:51-200` for the full file.
```tsx
import { useMemo, useState } from "react";
import { Table, TableBody, TableRow, /* … */ } from "@/components/ui/table";
import { PageHeader } from "@/components/PageHeader";
import { ClaimDrawer } from "@/components/ClaimDrawer";
import { useClaims } from "@/hooks/useClaims";
import { useDrawerUrlState } from "@/hooks/useDrawerUrlState";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
import { TailStatusPill } from "@/components/TailStatusPill";
export function Claims() {
const [status, setStatus] = useState<ClaimStatus | null>(null);
const params = useMemo(() => ({ status, limit: 25, offset: 0 }), [status]);
const { data } = useClaims(params);
const { status: tailStatus, lastEventAt, forceReconnect } = useTailStream("claims");
const items = useMergedTail("claims", data?.items ?? [], (c) => !status || c.status === status);
const { claimId, openClaim, closeClaim } = useDrawerUrlState();
return (
<>
<PageHeader
eyebrow="Inbox"
title="Claims"
status={<TailStatusPill status={tailStatus} lastEventAt={lastEventAt} onReconnect={forceReconnect} />}
/>
<Table>
<TableBody>
{items.map((c) => (
<TableRow key={c.id} onClick={() => openClaim(c.id)}>{/* …cells… */}</TableRow>
))}
</TableBody>
</Table>
<ClaimDrawer claimId={claimId} claims={items} onClose={closeClaim} onNavigate={openClaim} />
</>
);
}
```
### `use<X>` data hook (TanStack Query)
Pattern from `src/hooks/useClaims.ts:24-67` and `useRemittances.ts:21-65`.
Returns a stable shape so the page treats all data hooks uniformly.
The tail subscription lives on the page (see Convention 3), not here.
```ts
import { useQuery } from "@tanstack/react-query";
import { api, type ListClaimsParams, type PaginatedResponse } from "@/lib/api";
import type { Claim } from "@/types";
export function useClaims(params: ListClaimsParams) {
return useQuery<PaginatedResponse<Claim>>({
queryKey: ["claims", params],
queryFn: () => api.listClaims<Claim>(params),
enabled: api.isConfigured,
// …in-memory fallback when !api.isConfigured…
});
}
```
### Drawer component with `useDrawerUrlState`
Pattern from `src/components/ClaimDrawer/ClaimDrawer.tsx:1-50` +
`src/hooks/useDrawerUrlState.ts`. The drawer takes `claimId` as a
prop (driven by the URL), renders nothing when `null`, and uses
`useDrawerKeyboard` for j/k navigation + Escape to close. The page
that mounts it controls the URL — `openClaim("abc")` sets `?claim=abc`,
removing the param closes the drawer, and reload preserves the state.
```tsx
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { useClaimDetail } from "@/hooks/useClaimDetail";
import { useDrawerKeyboard } from "@/hooks/useDrawerKeyboard";
export function ClaimDrawer({ claimId, claims, onClose, onNavigate, onToggleHelp }) {
const open = claimId !== null;
const { data, isLoading, isError, error } = useClaimDetail(claimId);
useDrawerKeyboard({ open, claims, onClose, onNavigate, onToggleHelp });
if (!open) return null;
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent>{/* header + body panels from useClaimDetail… */}</DialogContent>
</Dialog>
);
}
```
## Anti-patterns
- **Don't `fetch` from inside a page component.** All API access goes through the `use<X>` hook in `src/hooks/use<X>.ts`. Pages that reach for `fetch(...)` directly can't be tested with `vi.mock("@/lib/api", ...)` and split the data lifecycle across files. The hook returns `{ data, isLoading, isError, error, refetch }` so the page is a pure renderer.
- **Don't open a drawer via local component state.** A `const [open, setOpen] = useState(false)` for a drawer breaks deep-links and reload-restore. Use `useDrawerUrlState()` (claim) or `useRemitDrawerUrlState()` (remit) so the URL is the single source of truth.
- **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/` (e.g. `src/lib/format.ts` for currency / date formatting). JSX is for layout; mixing in `items.filter(...).sort(...)` inline is hard to test and hides behavior from the hook.
- **Don't call `useTailStream` from inside a `use<X>` hook.** The streaming subscription belongs on the page (see `src/pages/Claims.tsx:85-89`). Hoisting it into `useClaims` couples the open/close lifecycle of the page to whoever mounts the hook, and breaks the one-resource-one-page ownership that the backoff/stall state machine assumes.
- **Don't import a new UI library to render a button, modal, or table.** Radix-backed primitives in `src/components/ui/` already cover every widget the shipped pages use. Reach for a new library only after the primitive gap is real and the proposal is in a spec / PR.
## Related skills
- **`cyclone-tail`** — load for any live-data page (Claims, Remittances, ActivityLog). Documents the `useTailStream` + `useMergedTail` triplet, the `<TailStatusPill>` wiring, and the 30s stall threshold (`STALL_TIMEOUT_MS = 30_000` at `useTailStream.ts:53`).
- **`cyclone-api-router`** — load when a page is calling a new HTTP endpoint. Documents `api_routers/<topic>.py` vs. inline `api.py` registration and the response / error-envelope shapes.
- **`cyclone-tests`** — load when adding the `*.test.tsx` sibling. Documents the `// @vitest-environment happy-dom` setup, `IS_REACT_ACT_ENVIRONMENT = true`, the `@testing-library/react` vs. `createRoot`+`Probe` rendering styles, and the `vi.mock("@/lib/api")` convention.
- **`cyclone-edi`** — load when a page renders parsed 837P / 835 / 999 / 270 / 271 / 277CA / TA1 content (ServiceLinesTable, CAS panels, ValidationPanel). Documents the parser modules, the R-coded validator rules, and the CAS / CARC / NPI / EIN helpers.
-159
View File
@@ -1,159 +0,0 @@
---
name: cyclone-spec
description: "Cyclone SP-N superpowers increment flow — spec → plan → implement → merge. Use when: starting a new numbered feature increment, naming a branch, opening a SP-N PR, or doing the merge dance into main."
---
# cyclone-spec
The Cyclone repo ships every new feature as a numbered **SP-N increment**:
a spec, a plan, an implementation branch, and a single atomic merge commit
into `main`. This skill encodes the conventions so every increment follows
the same shape and the commit history stays auditable.
As of this writing: SP numbers used through **SP41** (the in-window rebill pipeline); **SP42** is this doc-pass. **SP23** shipped the Ubuntu + Docker + RBAC + auth LAN-bind product fork; **SP24** is the reissue-claims + auth-docs alignment; SPs **2540** cover the resubmissions, `claim_acks`, rendering/service NPI, transaction-set control numbers, additional live-tail streams, dashboards, and rebill prerequisites shipped between SP24 and SP41. The next free increment after SP42 is **SP43**.
## Auth-aware spec template (post-SP23)
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 since SP23 landed (2026-06-23)** — every backend endpoint now requires login (`cyclone.auth.*`, `Depends(matrix_gate)` on every `APIRouter`), the React app ships a `Login` page, RBAC roles `admin / user / viewer` gate individual endpoints, and the host still binds `0.0.0.0:8000` (reachability controlled by the host firewall / compose port publishing). The dev/test escape hatch is `CYCLONE_AUTH_DISABLED=1`, which logs a WARNING at boot. 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 LAN-only threat model (SQLCipher at rest, macOS Keychain). SP23 expanded the threat model to a remote operator on the LAN." Reference: [`docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md`](../../../docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md).
## When to use
- **Starting a new feature increment.** You are about to add a numbered
feature, fix that crosses subsystem boundaries, or anything bigger than
a one-line change. Before you write code, reserve the next SP number and
write the spec.
- **Naming the spec / plan files or the branch.** You have a topic, a
date, and a number — and you need the exact path / branch shape so
existing scripts and reviewers can find the artifacts.
- **Opening the SP-N PR.** You are about to push the branch and need the
PR title format and the commit-prefix conventions so the merge commit
reads cleanly.
- **Doing the merge dance.** Review is approved and you're about to land
the branch into `main`. Use this skill to confirm the merge shape — no
squash, no rebase, one atomic merge commit.
## Conventions
1. **Numbering.** Reserve the next SP-N number — the next integer after
the highest `SP<n>` already used in `git log`. Never reuse a number,
even after deletion. Numbering is monotonic and lives in the merge
history.
2. **Branch.** `sp<N>-<short-kebab-topic>` — e.g. `sp22-line-reconciliation`,
`sp9-multi-payer-npi`. Kebab-case, lowercase, no spaces, no slashes.
The branch name is the canonical handle for the increment.
3. **Spec path.** `docs/superpowers/specs/YYYY-MM-DD-cyclone-<topic>-design.md`
with header `Status: Draft, pending user review`. One spec per
increment. Real examples: `2026-06-19-cyclone-db-reconciliation-design.md`
(SP3), `2026-06-20-cyclone-multi-payer-npi-sftp-design.md` (SP9).
4. **Plan path.** `docs/superpowers/plans/YYYY-MM-DD-cyclone-<topic>.md`.
Header per the upstream `superpowers:writing-plans` skill: a
`For agentic workers:` line that names
`superpowers:subagent-driven-development` or
`superpowers:executing-plans`, plus a `Goal / Architecture / Tech
Stack / Spec` metadata block, then numbered tasks with
`- [ ] Step N:` checkboxes.
5. **Commit prefix.** All commits on the branch follow these prefixes —
they make the SP-N merge commit readable and let `git log --grep`
filter cleanly:
- `feat(sp<N>): …` — implementation commits (e.g. `feat(sp20): NPI Luhn checksum + Tax ID format validation`).
- `docs(spec): …` — landing the spec (e.g. `docs(spec): design for CycloneStore split (Step 4)`).
- `docs(plan): …` — landing the plan.
- `merge: SP<N> <topic> into main` — the merge commit itself (e.g. `merge: SP14 5-lane Inbox UI + acknowledge action into main`).
6. **PR title.** `SP<N> <Topic>` — e.g. `SP22 Line reconciliation`.
Matches the merge-commit subject so GitHub's "merged PR" view and the
`git log` entry are identical strings.
7. **Merge shape.** A single atomic merge commit into `main` after
review. **No squash** — squash collapses the per-commit history and
breaks the SP-N audit trail. **No rebase** — rebase rewrites the SHAs
the PR review was performed against. The SP-N merge commit *is* the
record of the increment landing.
## Patterns
### Spec header (canonical SP-N shape, post-SP9)
This is the canonical header for new specs. Older specs (pre-SP9) deviate
slightly — different title style, no Branch or Aesthetic direction line —
and have not been retroactively normalized. **Use this template for any
new SP-N spec.**
```markdown
# Sub-project <N> — <Topic>: Design Spec
**Date:** YYYY-MM-DD
**Status:** Draft, awaiting user sign-off
**Branch:** `sp<N>-<short-kebab-topic>`
**Aesthetic direction:** <one line — e.g. "No new UI" or "Modern (geometric sans + bold borders + electric blue accent)">
## 1. Scope
<2-6 lines: what's in, what's out, with explicit out-of-scope list>
```
Worked example (matches this template): `docs/superpowers/specs/2026-06-20-cyclone-multi-payer-npi-sftp-design.md`.
### Plan header (every SP-N plan starts with this)
```markdown
# <Topic> 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:** <one sentence — the outcome>
**Architecture:** <one paragraph — how it's structured>
**Tech Stack:** <comma-separated list>
**Spec:** [`docs/superpowers/specs/YYYY-MM-DD-cyclone-<topic>-design.md`](../specs/...)
---
## File structure
<tree of new / modified files>
## Task 0: <setup>
## Task 1: <first user-visible step>
```
Real examples: `docs/superpowers/plans/2026-06-21-cyclone-skill-catalog.md`,
`docs/superpowers/plans/2026-06-21-cyclone-store-split.md`.
## Anti-patterns
- **Don't skip the spec ("it's a small fix").** Small fixes still get a
3-line spec when they introduce a new numbered increment. The spec is
the *what* and the audit trail; the plan is the *how*. Without a spec
the merge commit has no anchor.
- **Don't squash the merge commit.** The SP-N merge commit is the audit
trail — it tells future you exactly which feature landed and which
commits composed it. Squash collapses that into one opaque commit and
the per-commit history is lost.
- **Don't put code in the spec — the spec is the *what*, the plan is the
*how*.** Specs describe scope, goals, non-goals, and decisions. Code
snippets belong in the plan (with checkbox steps) or in the diff, not
in the spec. SP-N specs in this repo routinely have **zero** code
blocks.
## Related skills
- **`cyclone-tests`** — every spec lists test impact; load this when
drafting or reviewing the spec to confirm fixture / `.test.tsx`
implications.
- **`cyclone-edi`** — load when the SP-N increment touches an EDI parser,
validator rule, or CAS mapping.
- **`cyclone-tail`** — load when the increment changes the live-tail wire
format or adds a streaming page.
- **`cyclone-store`** — load when the increment adds a write-path,
touches `store.py`, or wires a new `<entity>_written` event.
- **`cyclone-api-router`** — load when the increment adds or changes an
HTTP endpoint in `api_routers/`.
- **`cyclone-frontend-page`** — load when the increment adds or
refactors a page in `src/pages/`.
- **`cyclone-cli`** — load when the increment adds a CLI subcommand or
changes exit codes.
- **`superpowers:brainstorming`** (global) — run before the spec to lock
the scope / decisions in the spec's `## Decisions (locked during
brainstorming)` section.
- **`superpowers:writing-plans`** (global) — produces the plan header
format every SP-N plan follows.
-198
View File
@@ -1,198 +0,0 @@
---
name: cyclone-store
description: "Cyclone store write-paths, the pubsub event contract (claim_written / remittance_written / activity_recorded), and the SP21 store-split boundary map. Use when: touching store.py, adding a new entity, wiring a new write event, or splitting a store module."
---
# cyclone-store
Cyclone persists every parsed X12 batch through one facade,
`CycloneStore` (now exposed via `backend/src/cyclone/store/__init__.py` after the SP21 split landed; the public import surface `from cyclone.store import …` is unchanged). Every write inserts the row AND publishes a pubsub event on the in-process `EventBus` (`backend/src/cyclone/pubsub.py:20`) so live-tail pages see new rows the moment they land. The event contract is the seam between persistence and streaming — a wrong event name silently goes stale.
As of this writing: the store is a **16-module subpackage** under `backend/src/cyclone/store/` (SP21 split landed). Event kinds in use today: `claim_written`, `remittance_written`, `activity_recorded`, plus the SP-acks-tail additions `ack_written` and `ta1_ack_written` for the `/api/acks/stream` and `/api/ta1-acks/stream` endpoints. Next: **SP43** (after the SP42 doc-pass merges).
## When to use
- **Adding a new entity.** You need a new ORM model + write method +
event kind + snapshot serializer and want to know where each piece
lives (current monolith vs. post-SP21 module).
- **Wiring a new write event.** You're adding a `<entity>_written`
event and need both the publish call in the store AND the
subscribe call in the live-tail endpoint to stay in sync.
- **Debugging a write-path issue.** A page isn't reflecting new
rows, the DB has the row but the stream is silent, or the publish
raises and rolls back the transaction.
- **Splitting a store module.** You're moving a domain out of
`store.py` into its own module under `backend/src/cyclone/store/`
and need the SP21 module list + facade re-export rules.
## Conventions
1. **All writes go through `CycloneStore`.** Route handlers and
parsers must not write directly to the ORM session. The facade
opens a short-lived session via `db.SessionLocal()()`. Direct ORM
access is the #1 way the live-tail contract gets bypassed (no
event published → page silently goes stale). Read paths are
similar — prefer the facade's `iter_*` / `get_*` methods over raw
`s.execute(select(...))`.
2. **Every write publishes an event.** The event name matches the
entity: `claim_written`, `remittance_written`,
`activity_recorded` (the trailing `_recorded` signals a
non-canonical row — activity events are derived, not first-class),
plus `ack_written` and `ta1_ack_written` for the SP-acks-tail
streams. New entities get `<entity>_written`; activity-style side
rows get `<entity>_recorded`. Publish is **best-effort**
failures are logged but never roll back the persisted batch.
3. **Snapshot shape.** Each entity has a `to_ui_<entity>` serializer
(plain Python function returning a dict) — currently
`to_ui_claim`, `to_ui_remittance`, `to_ui_claim_from_orm`,
`to_ui_remittance_from_orm`, `to_ui_provider`. Post-SP21 these
move to `backend/src/cyclone/store/ui.py`. The serializer is the
single source of truth for what the frontend sees — every event
payload MUST match what the matching list endpoint returns for
that row (`store.py:1052-1054`).
4. **SP21 boundaries.** Post-split, each domain lives in its own
module under `backend/src/cyclone/store/`: `__init__.py` (facade
+ `CycloneStore` class), `acks.py`, `backfill.py`, `backups.py`,
`batches.py`, `claim_acks.py`, `claim_detail.py`, `exceptions.py`,
`inbox.py`, `kpis.py`, `orm_builders.py`, `providers.py`,
`records.py`, `resubmissions.py`, `submission_dedup.py`, `ui.py`,
`write.py` — 16 modules total. Cross-module writes go through
`CycloneStore` facade methods, not direct module access. The
facade re-exports every name callers currently import from
`cyclone.store`. Full split plan:
`docs/superpowers/plans/2026-06-21-cyclone-store-split.md`.
5. **No business logic in route handlers.** A route handler validates
input, calls `store.<method>(...)`, passes the `event_bus`,
returns the serialized result. Reconciliation, idempotency
checks, CAS adjustment persistence — all live in the store.
## Patterns
### A `CycloneStore.add` write — publishes events from inserted rows
Taken from `backend/src/cyclone/store/write.py` (post-SP21). The method opens
a session, inserts rows, then runs a sync `_publish_events_sync`
after commit so subscribers can immediately re-fetch consistent data.
```python
def add(
self,
record: BatchRecord,
*,
event_bus: "EventBus | None" = None,
) -> None:
inserted_claim_ids: list[str] = []
with db.SessionLocal()() as s:
s.add(Batch(id=record.id, kind=record.kind, ...))
if isinstance(record, BatchRecord837):
for claim in record.result.claims:
if s.get(Claim, claim.claim_id) is not None:
continue # idempotency: skip dupes
s.add(_claim_837_row(claim, record.id))
s.add(ActivityEvent(kind="claim_submitted", ...))
inserted_claim_ids.append(claim.claim_id)
# ... 835 branch + flush + cas adjustments ...
s.commit()
if event_bus is not None and inserted_claim_ids:
self._publish_events_sync(event_bus, record, inserted_claim_ids)
def _publish_events_sync(self, event_bus, record, claim_ids):
with db.SessionLocal()() as s:
for cid in claim_ids:
ui = to_ui_claim_from_orm(s.get(Claim, cid), ...)
self._sync_publish(event_bus, "claim_written", ui)
# ... remittance + activity loops ...
```
`EventBus.publish` is async but the body is pure sync `put_nowait`,
so the store calls `_sync_publish` directly to avoid forcing sync
FastAPI handlers to await.
### Backend `/api/<resource>/stream` endpoint — subscribes to the event
Streaming endpoints live in `backend/src/cyclone/api_routers/<topic>.py`
post-SP36 (e.g. `api_routers/claims.py`). Two phases — eager
snapshot, then live subscription — wrapped in `StreamingResponse`
with `media_type="application/x-ndjson"`.
```python
@router.get("/api/claims/stream")
async def claims_stream(request: Request, ...) -> StreamingResponse:
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.iter_claims(status=status, ...) # 1. Snapshot
for row in rows:
yield _ndjson_line({"type": "item", "data": row})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in _tail_events(request, bus, ["claim_written"]): # 2. Live
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
```
The same shape is repeated in `api_routers/remittances.py`,
`api_routers/activity.py`, `api_routers/acks.py`, and
`api_routers/ta1_acks.py` (five streaming endpoints total).
`_ndjson_line` and `_tail_events` live in
`backend/src/cyclone/api_helpers.py`. The `["remittance_written"]`
and `["activity_recorded"]` subscriptions are at `api.py:1891,2002`.
### Backend test — asserts both the row AND the event landed
The autouse `conftest.py` (`backend/tests/conftest.py:20`) wires a
fresh `EventBus` onto `app.state` per test.
```python
def test_publishes_claim_written_event(client: TestClient) -> None:
bus = app.state.event_bus
resp = client.post("/api/parse-837",
files={"file": ("x.837", MINIMAL_837, "text/plain")})
assert resp.status_code == 200
assert list(store.iter_claims(limit=10)) # row landed
queues = bus._subscribers.get("claim_written", []) # event published
assert queues
evt = queues[0].get_nowait()
assert evt["_kind"] == "claim_written"
```
## Anti-patterns
- **Don't read directly from the ORM in a route handler — go through
the snapshot serializer.** A handler that does
`with db.SessionLocal()() as s: row = s.get(Claim, cid); return row`
bypasses `to_ui_claim_from_orm` and silently drifts from the event
payload shape. Always call `store.get_claim_detail(cid)` (or the
equivalent `get_*` facade method).
- **Don't introduce a new event name without updating the subscriber
list.** Today every `<entity>_written` event has exactly one
consumer — the matching `/api/<resource>/stream` endpoint, now
split across `api_routers/{claims,remittances,activity,acks,ta1_acks}.py`
(post-SP36 split). Any new event must wire its subscription in
the matching router and add the resource name to the hook
triplet in the corresponding page.
- **Don't merge a write method with its event publication into
separate places.** `_publish_events_sync` lives next to `add` in
`store.py:1042-1107` so reviewers see both halves of the contract
in one diff. Post-SP21 the same rule applies.
- **Don't make the publish call blocking on commit failures.**
Publish errors are caught and logged at `store.py:1097-1098`; a
failing subscriber MUST NOT roll back the persisted batch. The
batch is the source of truth; the event is the cache-invalidation
hint.
## Related skills
- **`cyclone-edi`** — the parsed `ClaimOutput` / `ParseResult<EDI>`
lands in the store via `CycloneStore.add`.
- **`cyclone-api-router`** — the route that calls `store.<method>(...)`
and (for stream endpoints) subscribes to the matching
`<entity>_written` event.
- **`cyclone-tail`** — the consumer side of the event contract;
load when changing the wire format or adding a streaming hook.
- **`cyclone-tests`** — write-path tests live under `backend/tests/`
and assert both the DB row and the event payload.
-201
View File
@@ -1,201 +0,0 @@
---
name: cyclone-tail
description: "Cyclone live-tail streaming wire format and the useTailStream / useMergedTail hook triplet. Use when: adding a new streaming list page, changing the wire format, debugging stalled/reconnecting state, or modifying the StatusPill behavior."
---
# cyclone-tail
Cyclone keeps the Claims, Remittances, Activity, Acks, and Ta1Acks pages live without
polling: every store write publishes an internal EventBus event, the
page opens a `GET /api/<resource>/stream` HTTP/1.1 chunked-NDJSON
connection, and new rows land in the table the moment they hit the
database. This skill codifies the wire format, the hook triplet, and
the backoff/stall machinery so additions stay consistent with the
five streaming pages already shipped (`Claims`, `Remittances`,
`ActivityLog`, `Acks`, `Ta1Acks`).
## When to use
- **Adding a new streaming page.** Mounting `useTailStream(resource)`
on a page — you need the hook triplet shape (initial fetch +
`useTailStream` + `useMergedTail`), the `<TailStatusPill>` wiring,
and the dedup rules in `useMergedTail`.
- **Changing the wire format.** Adding a new event type — update
`TailEvent` in `src/lib/tail-stream.ts:22-44`, the dispatch switch
in `src/hooks/useTailStream.ts:173-195`, the emitter in
`backend/src/cyclone/api_helpers.py:tail_events()`, and
`references/wire-format.md`.
- **Debugging stalled/reconnecting state.** Confirm whether the
backend is heartbeating (`CYCLONE_TAIL_HEARTBEAT_S`, default `15s`)
or the stall timer fired (`STALL_TIMEOUT_MS = 30_000` at
`src/hooks/useTailStream.ts:53`).
- **Tuning heartbeat/stall timing.** Changing the 30s stall threshold
or the 15s heartbeat interval — README's "Status pill" + "Knobs"
tables need to stay in sync (`README.md:109-129`).
## Conventions
1. **Wire format.** Newline-delimited JSON. Every line is
`{"type": ..., "data": ...}`. Known `type` values: `item`
(per-row envelope), `snapshot_end` (`{"count": N}` marker after the
snapshot), `heartbeat` (`{"ts": "<iso-8601>"}` keep-alive),
`item_dropped` (`{"id": "..."}` queue-overflow notice), `error`
(`{"message": "..."}` promoted to a thrown error by the hook).
Defined at `src/lib/tail-stream.ts:22-44`; emitted by
the streaming routers in `backend/src/cyclone/api_routers/{claims,remittances,activity,acks,ta1_acks}.py`
(post-SP36 split). See `references/wire-format.md`.
2. **Hook triplet.** Streaming pages compose three pieces:
`useTailStream(resource)` (`src/hooks/useTailStream.ts:80` — opens
the stream, drives the backoff/stall state machine, dispatches
`item` events into `useTailStore`),
`useMergedTail(resource, baseItems, filterFn?)`
(`src/hooks/useMergedTail.ts:25` — returns
`baseItems + tailSlice` dedup'd against `baseItems`), and a
per-resource initial-fetch hook (`useClaims`, `useRemittances`,
`useActivity`, `useAcks`, `useTa1Acks`). The page wires all three —
see `src/pages/Claims.tsx:87-89`. The five resources share the
same triplet; only the resource string differs.
3. **Stall threshold.** 30 seconds of total silence — heartbeats
included — flips status to `stalled` and surfaces the
`↻ Reconnect` button on `<TailStatusPill>`. Constant:
`STALL_TIMEOUT_MS = 30_000` at `src/hooks/useTailStream.ts:53`.
Re-armed on every event including `heartbeat` and `item_dropped`
(`useTailStream.ts:124-140`). Don't change without updating
`README.md:109-123`.
4. **Snapshot first.** Every stream emits the snapshot before any
live `item` events, then closes with exactly one `snapshot_end`
carrying `{"count": N}`. The hook uses `snapshot_end` to flip
`<TailStatusPill>` from `connecting` to `live` and to reset the
reconnect backoff counter (`useTailStream.ts:174-180`).
5. **Content-Type.** Stream endpoints respond with
`media_type="application/x-ndjson"` — set in each
`api_routers/<resource>.py` stream handler. Frontend sets
`Accept: application/x-ndjson` at `src/lib/tail-stream.ts:67-70`.
Never `application/json` for a stream endpoint.
6. **Backoff.** Transient errors retry with `1s → 2s → 4s → 8s → 16s
→ 30s` capped — `BACKOFF_STEPS_MS` at
`src/hooks/useTailStream.ts:48-50`. Counter resets on every
`snapshot_end`.
7. **Heartbeat knob.** Idle heartbeat interval is configurable via
`CYCLONE_TAIL_HEARTBEAT_S` (default `15`, parsed at call time in
`backend/src/cyclone/api_helpers.py:185-198`). Tests override to
a small value to keep runtime bounded.
## Patterns
### Page-hook skeleton — `Claims.tsx`
Pattern from `src/pages/Claims.tsx:85-90`. Three hooks in order:
`useClaims(params)` (initial TanStack Query fetch),
`useTailStream("claims")` (opens the stream, owns status state), and
`useMergedTail("claims", data?.items ?? [], tailFilterFn)` (combines
initial snapshot with live tail, dedup'd by id, filtered by the page's
predicate applied AFTER dedup at `useMergedTail.ts:72-74`).
```ts
import { useClaims } from "@/hooks/useClaims";
import { useTailStream } from "@/hooks/useTailStream";
import { useMergedTail } from "@/hooks/useMergedTail";
export function ClaimsPage() {
const { data } = useClaims({ status: "submitted" });
const { status, lastEventAt, forceReconnect } = useTailStream("claims");
const tailFilterFn = (c: Claim) => c.status === "submitted";
const items = useMergedTail("claims", data?.items ?? [], tailFilterFn);
// <TailStatusPill status={status} lastEventAt={lastEventAt}
// onReconnect={forceReconnect} /> + <Table items={items} />
}
```
### Backend `/api/foo/stream` endpoint
Pattern from `backend/src/cyclone/api_routers/claims.py`. Register BEFORE
`/api/foo/{foo_id}` so the literal `stream` segment doesn't match as
an id. Two phases — eager snapshot, then live subscription — wrapped
in `StreamingResponse` with `media_type="application/x-ndjson"`.
```python
@app.get("/api/foo/stream")
async def foo_stream(
request: Request,
status: str | None = Query(None),
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
# 1. Snapshot.
rows = store.iter_foos(status=status, limit=limit)
for row in rows:
yield _ndjson_line({"type": "item", "data": row})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
# 2. Live subscription + heartbeats.
async for chunk in _tail_events(request, bus, ["foo_written"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
```
`_ndjson_line` and `_tail_events` live in
`backend/src/cyclone/api_helpers.py`; the latter forwards EventBus
events as `item` lines and emits `heartbeat` lines on the cadence
from `heartbeat_seconds()`.
### `<TailStatusPill>` wiring
Pattern from `src/components/TailStatusPill.tsx:22-83`. The pill
takes `status` + `lastEventAt` from `useTailStream` plus
`forceReconnect` so the `↻ Reconnect` button wires to the hook's
`reconnectNonce` bump (aborts and reopens the stream —
`useTailStream.ts:99-101`). The button only renders when
`status === "stalled" || status === "error"` (`TailStatusPill.tsx:52`).
## Anti-patterns
- **Don't hand-roll `fetch` + `ReadableStream` parsing in a page.**
All stream consumers go through `streamTail(resource, opts?)` at
`src/lib/tail-stream.ts:58`. The parser handles `TextDecoderStream`,
newline splitting, malformed-line tolerance (`console.warn` + skip),
the `KNOWN_TYPES` allowlist, and abort-on-signal semantics
(`tail-stream.ts:75,98,107`). Duplicating it loses all of those.
- **Don't change the wire format on one endpoint without updating
the others.** The parser (`src/lib/tail-stream.ts`) and the hook
dispatch switch (`useTailStream.ts:173-195`) are shared across all
five live streams. Adding a new event type means updating
`TailEvent`, `KNOWN_TYPES`, the dispatch `case`, the `armStall`
re-arm list, and the backend emitter — in that order.
- **Don't emit `item` events before `snapshot_end`.** The hook uses
`snapshot_end` as the marker to flip `<TailStatusPill>` from
`connecting` to `live` and to reset the reconnect backoff counter.
Emitting `item`s first lands rows in `useTailStore` but the UI
still reads "Connecting" — operators see a flash of stale state.
Emit snapshot, then `snapshot_end`, then live.
- **Don't change the 30s stall threshold without updating the README
"Status pill" table.** The constant
(`STALL_TIMEOUT_MS = 30_000` at `useTailStream.ts:53`) is the
contract the pill is documented against — a bump needs the matching
edit at `README.md:118`.
- **Don't add `useTailStream` calls from `useFoo.ts` hooks.**
Streaming connections belong on the page (`src/pages/Claims.tsx`,
`Remittances.tsx`, `ActivityLog.tsx`, `Acks.tsx`, `Ta1Acks.tsx`). Hoisting into `useFoo`
couples the lifecycle to whoever mounts it and breaks
one-resource-one-page ownership.
## Related skills
- **`cyclone-frontend-page`** — page components live in `src/pages/`;
load when adding or refactoring a streaming page to confirm the
route + `PageHeader` + table conventions.
- **`cyclone-api-router`** — endpoint conventions (`api_routers/`
hosts the streaming endpoints post-SP36, alongside the other
resource-group routers); load when adding or changing an HTTP
endpoint that surfaces streamed or paginated data.
- **`cyclone-store`** — write-path conventions in `store.py` and the
pubsub event contract (`claim_written`, `remittance_written`,
`activity_recorded`); load when adding a new entity whose writes
should fan out to a stream endpoint.
- **`cyclone-tests`** — frontend `*.test.tsx` siblings cover
`useTailStream`, `useMergedTail`, `TailStatusPill`; backend
`test_api_stream_live.py` covers the five live-tail endpoints
(claims, remittances, activity, acks, ta1-acks); load when the
increment changes wire-format behavior or adds a streaming hook.
@@ -1,76 +0,0 @@
# Live-tail wire format — field reference
The Cyclone live-tail NDJSON contract is owned by the frontend parser
(`src/lib/tail-stream.ts:22-44`) and the backend emitter
(`backend/src/cyclone/api_helpers.py:tail_events()` + the three
endpoints in `backend/src/cyclone/api.py:1357-2006`). This file is the
quick reference; the canonical prose lives in the README and the
source-of-truth type definitions.
## Source
Wire format excerpt copied verbatim from `README.md:76-94` (the
"Live updates → Wire format" section). The README is the user-facing
exposition; this reference adds the per-line field semantics and the
parser-tolerance rules from the code.
> Each stream endpoint emits newline-delimited JSON. The first batch
> is the **snapshot** of currently-known rows; after that comes
> **`snapshot_end`** with the count, then the **live** events.
>
> ```json
> {"type":"item","data":{"id":"CLM-1", "...":"..."}}
> {"type":"item","data":{"id":"CLM-2", "...":"..."}}
> {"type":"snapshot_end","data":{"count":2}}
> {"type":"item","data":{"id":"CLM-3", "...":"..."}} ← live
> {"type":"heartbeat","data":{"ts":"2026-06-20T23:17:09Z"}} ← idle keep-alive
> ```
>
> Lines are `{"type": ..., "data": ...}`; known types are `item`,
> `snapshot_end`, `heartbeat`, and (rare) `item_dropped` /
> `error`. Heartbeats keep the connection alive when nothing is
> happening — clients flip to `stalled` after 30s of total silence
> (heartbeat or otherwise) and surface a **↻ Reconnect** button.
## Per-line field reference
| `type` | `data` shape | Required? | Emitted by | Parser behavior |
| -------------- | ----------------------------------------- | --------- | ------------------------------------------- | -------------------------------------------- |
| `item` | resource-specific row (`Claim` / `Remittance` / `Activity`) | yes, in `data` (the per-row envelope) | snapshot loop + `_tail_events` forwarding `claim_written` / `remittance_written` / `activity_recorded` | `dispatch(resource, ev.data)``useTailStore.addClaim` / `addRemittance` / `addActivity` (first-write-wins dedup on the id-keyed slices — `tail-store.ts:104,120`). |
| `snapshot_end` | `{"count": N}` (integer ≥ 0) | yes, on every stream | `api.py:1395,1889,1999` (one per stream, after the snapshot loop) | Flips `<TailStatusPill>` from `connecting` to `live`, resets the reconnect backoff counter to 0 (`useTailStream.ts:174-180`). |
| `heartbeat` | `{"ts": "<iso-8601 UTC>"}` | yes, but only when idle | `_tail_events` in `api_helpers.py:241-245` (cadence from `heartbeat_seconds()`, default 15s) | Re-arms the stall timer (`useTailStream.ts:124-140`); no state change. |
| `item_dropped` | `{"id": "<string>"}` | optional (rare; queue overflow) | EventBus drop-oldest path (per the spec at `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md:299`) | Re-arms the stall timer; no state change. The `id` field is informational — the hook does not refetch on drop, the page does (see Spec §3.6). |
| `error` | `{"message": "<string>"}` | optional (server-side failure) | Reserved for future server-emitted errors (currently only thrown client-side) | Hook promotes to a thrown `Error(message)` so the catch block runs the reconnect machinery (`useTailStream.ts:190-194`). |
## Parser tolerance
The shared parser at `src/lib/tail-stream.ts` is intentionally
forgiving so a single bad frame doesn't kill the stream:
- **Trailing `\r`** is stripped per line (`tail-stream.ts:116`) so a
CRLF-terminated stream still parses.
- **Malformed JSON** (`JSON.parse` throws) → `console.warn` + skip the
line; the iterator continues (`tail-stream.ts:122-131`).
- **Unknown `type`** (not in `KNOWN_TYPES`) → `console.warn` + skip
(`tail-stream.ts:141-148`). Adding a new event type is
forward-compatible: old clients see warn lines, new clients see the
typed event.
- **Empty lines** (consecutive `\n`s) → silently skipped
(`tail-stream.ts:118`).
- **No trailing newline** → flushed as a final partial line on
stream close (`tail-stream.ts:154-178`).
- **Abort signal** → iterator exits cleanly without throwing
(`tail-stream.ts:75,98,107`).
## Endpoint inventory
| Method | Path | Subscribes to | Default sort | Defined at |
| ------ | ------------------------- | -------------------- | ------------------- | ----------------------------------- |
| GET | `/api/claims/stream` | `claim_written` | `-submission_date` | `backend/src/cyclone/api.py:1357` |
| GET | `/api/remittances/stream` | `remittance_written` | `-received_date` | `backend/src/cyclone/api.py:1858` |
| GET | `/api/activity/stream` | `activity_recorded` | `-timestamp` (limit 50) | `backend/src/cyclone/api.py:1971` |
All three accept the same query params as their non-streaming
counterparts (`status`, `payer`, `date_from`, …) so a frontend can
swap a one-shot fetch for a tail with no URL surgery. Responses are
`Content-Type: application/x-ndjson`.
-170
View File
@@ -1,170 +0,0 @@
---
name: cyclone-tests
description: "Cyclone pytest + vitest fixture patterns, prodfiles layout, backend/tests/fixtures/ conventions, .test.tsx sibling rule. Use when: adding a backend pytest case, adding a frontend vitest test, or wiring in a real-EDI prodfiles sample."
---
# cyclone-tests
The Cyclone test suite is split two ways: **backend pytest** (~173 test files under `backend/tests/`, ~21 flat fixtures in `backend/tests/fixtures/`, one autouse `conftest.py` that resets the DB per-test) and **frontend vitest** (85 `*.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: **~173 backend test files**, **~21 flat backend fixtures** (the original 13 plus additions for `reissue-claims`, `validate-837`, `submit-batch`, and SP41 rebill fixtures), **85 frontend `*.test.ts(x)` siblings**, and a slimmed-down `docs/prodfiles/references/` archive (the bulk of the prodfiles were moved out of the repo; remaining sample is `good-claim-com-2026-04-16.837`). Tests that previously reached into `docs/prodfiles/claims/` and `docs/prodfiles/FromHPE/` now skip when those dirs are absent (`tests/test_prodfiles_smoke.py`). The most recently shipped increment is **SP41** (in-window rebill pipeline); the next free increment is **SP43** after this SP42 doc-pass merges.
## 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
- **Adding a backend pytest case.** You're about to add a new `test_*.py` under `backend/tests/` and need the autouse DB-fixture rules, the fixture-path convention, and the right naming flavor (`test_api_*.py` vs `test_<module>_*.py`).
- **Adding a frontend vitest test.** You're about to add a `*.test.ts(x)` sibling and need the `// @vitest-environment happy-dom` setup, the act-environment flag, and the right rendering helper (`@testing-library/react` vs the `createRoot`+`Probe` shim).
- **Dropping in a prodfiles fixture.** You have a real EDI sample under `docs/prodfiles/<source>/` and need the copy step that makes it test-runnable without coupling the test to the prodfiles archive.
- **Debugging a flaky test.** A test passes locally but flakes in CI — load this skill to check the determinism + no-network rules before chasing the symptom.
## Conventions
1. **Frontend sibling rule.** Every new file in `src/` that contains testable logic gets a `*.test.ts(x)` next to it. `useFoo.ts``useFoo.test.ts`. `ClaimDrawer.tsx``ClaimDrawer.test.tsx`. The 59 existing siblings follow this; CI implicitly enforces it via the default `src/**/*.test.ts(x)` glob in `vitest.config.ts`.
2. **Backend test location.** Tests live under `backend/tests/test_*.py`. Two flavors, two naming patterns:
- **Integration tests** (FastAPI surface) → `test_api_<topic>_<verb>.py` — e.g. `test_api_parse_persists.py`, `test_api_999.py`.
- **Pure-unit tests** (parsers, validators, store internals) → `test_<module>_<behavior>.py` — e.g. `test_cas_codes.py`, `test_pubsub.py`.
3. **Prodfiles drop-in.** Real EDI samples live under `docs/prodfiles/<source>/<file>.txt` (sources seen so far: `837p-from-axiscare/`, `835fromco/`, `FromHPE/`, `claims/`). To use one in a test: copy the file to `backend/tests/fixtures/<descriptive-name>.txt` (flat — no per-test subdirectories; the existing 13 fixtures all sit at the top level) and reference it from the test as a module-level `Path` constant. See `## Patterns` for the exact line.
4. **Determinism.** Time-sensitive tests must not depend on wall-clock time.
- **Frontend** uses `vi.useFakeTimers()` + `vi.setSystemTime(new Date("YYYY-MM-DDTHH:MM:SSZ"))` (see `src/components/TailStatusPill.test.tsx:54-58`) and `vi.advanceTimersByTime(ms)` to drive interval / backoff code deterministically.
- **Backend** dates are passed explicitly as fixture values — e.g. `datetime.now(timezone.utc)` is fine for "now-ish" anchors, but for fixed dates pass `datetime(2026, 6, 20, tzinfo=timezone.utc)`. The project does **not** currently use `freezegun` or `mock.patch(datetime)`; if you need deterministic date mocking, propose adding `freezegun` to `backend/pyproject.toml` rather than rolling your own.
5. **No network.** Tests must not hit the network.
- **Backend:** `fastapi.testclient.TestClient(app)` runs in-process; no uvicorn. The autouse `conftest.py` fixture (`backend/tests/conftest.py:20`) 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`.
- **Frontend:** `vi.stubGlobal("fetch", vi.fn().mockResolvedValue(...))` for hooks; `vitest.config.ts` sets `VITE_API_BASE_URL=http://test.local` so the `api` module doesn't throw `notConfiguredError` before the mock fires.
6. **pytest collection.** Run `cd backend && python -m pytest tests/<file>::<name> -v` for the fastest single-test feedback loop. Run `cd backend && python -m pytest tests/<file> -v` for one file. Run `cd backend && python -m pytest` for the full suite — this is the merge gate. Frontend: `npm test` (alias for `vitest run`) for the full suite; `npx vitest run src/hooks/useFoo.test.ts` for one file.
## Patterns
### Backend pytest using a fixture + per-test SQLite DB
Canonical shape — see `backend/tests/test_api_999.py:1-50` for the full file. The autouse `conftest.py` already provides the DB init + `EventBus` reset; most tests just add a `client` fixture.
```python
"""Tests for the FastAPI surface in cyclone.api for the 999 endpoint."""
from __future__ import annotations
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
# Fixture reference — flat, module-level Path constant. NEVER reach into
# docs/prodfiles/ from a test; the fixtures/ dir is the test-consumed surface.
ACCEPTED = Path(__file__).parent / "fixtures" / "minimal_999.txt"
REJECTED = Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt"
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def test_parse_999_endpoint_happy_path(client: TestClient):
text = ACCEPTED.read_text()
resp = client.post(
"/api/parse-999",
files={"file": ("minimal_999.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["ack"]["ack_code"] == "A"
```
Override the autouse DB fixture only when you need a custom env (e.g. a per-test backup directory) — see `backend/tests/test_999_rejected_state.py:20-25`:
```python
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db")
from cyclone import db
db._reset_for_tests()
db.init_db()
yield
```
### Frontend vitest `*.test.tsx` for a component using fake timers
Pattern taken from `src/components/TailStatusPill.test.tsx:1-62` — uses `vi.useFakeTimers()` + `vi.setSystemTime(...)` to drive interval-based code deterministically.
```ts
// @vitest-environment happy-dom
// React's act warnings need an act-aware environment — mirror the other
// hook tests in this repo.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MyComponent } from "./MyComponent";
describe("MyComponent", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-20T12:00:30Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("test_renders_after_interval_tick", () => {
vi.advanceTimersByTime(30_000); // drive the setInterval
// …assert…
});
});
```
### Frontend vitest `*.test.tsx` for a component using `@testing-library/react` + `happy-dom`
Pattern taken from `src/hooks/useInboxLanes.test.ts:1-80`. (A handful of older tests — `useClaimDetail.test.ts`, `useDrawerUrlState.test.ts`, `TailStatusPill.test.tsx` — roll a custom `createRoot`+`Probe` shim instead. Both styles are accepted; `@testing-library/react` is preferred when the hook has async dependencies because `waitFor` is built in.)
```ts
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { useMyHook } from "./useMyHook";
vi.mock("@/lib/api", () => ({
api: { fetchFoo: vi.fn() },
}));
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("useMyHook", () => {
it("loads data on mount", async () => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ items: [] }),
}));
const { result } = renderHook(() => useMyHook());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([]);
});
});
```
## Anti-patterns
- **Don't put frontend tests in `src/__tests__/`.** No such directory exists in the codebase — `__tests__` only appears in the SP-catalog plan itself. Use the sibling rule (`useFoo.ts``useFoo.test.ts`).
- **Don't reach into `docs/prodfiles/` directly from a test.** Always copy to `backend/tests/fixtures/<name>.txt` first. The prodfiles directory is the source-of-truth archive and may be reorganized; the fixtures directory is the test-consumed surface and is stable.
- **Don't use wall-clock sleeps for timing.** A handful of legacy tests use `await new Promise((r) => setTimeout(r, 200))` (see `src/components/SearchBar.test.tsx:281,323,373` and `src/hooks/useSearch.test.ts:174`) — these are known flaky in CI. Use `vi.useFakeTimers()` + `vi.advanceTimersByTime(ms)` instead, or `waitFor(...)` from `@testing-library/react`.
## Related skills
- **`cyclone-spec`** — every SP-N spec lists test impact; load this when drafting or reviewing the spec to confirm fixture / `.test.tsx` implications for the increment.
- **`cyclone-edi`** — most backend tests cover parser + validator behavior; load when the increment touches an EDI parser or adds a validator rule (R200/R210/NPI Luhn/EIN/CAS).
- **`cyclone-tail`** — most frontend tests cover hook behavior (`useTailStream`, `useMergedTail`); load when the increment changes the wire format or adds a streaming hook.
- **`cyclone-store`** — write-path tests live here; load when the increment touches `store.py`, adds a new entity, or wires a new `<entity>_written` event.
- **`cyclone-api-router`** — endpoint tests live in `backend/tests/test_api_*.py`; load when the increment adds or changes an HTTP endpoint.
- **`cyclone-frontend-page`** — page-component tests live next to pages in `src/pages/*.test.tsx`; load when the increment adds or refactors a page.
- **`cyclone-cli`** — CLI smoke tests live in `backend/tests/test_cli_*.py`; load when the increment adds a CLI subcommand.
- **`superpowers:test-driven-development`** (global) — the upstream TDD workflow. Load first when starting any new feature increment; this skill only codifies the Cyclone-specific test layout on top of TDD.
-211
View File
@@ -1,211 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Cyclone is a self-hosted X12 EDI claims-management suite for a single billing office (Colorado Medicaid currently). It parses 837P professional claims and 835 ERA remittances (X12 005010X222A1 / 005010X221A1) and also handles 999, TA1, 270, 271, and 277CA. Always binds to `0.0.0.0` — the host firewall / compose port publishing is what restricts reachability, not the bind address. Requires login (auth boundary is HTTP; bcrypt + HttpOnly session cookie; first admin bootstrapped from `CYCLONE_ADMIN_USERNAME` + `CYCLONE_ADMIN_PASSWORD` env vars; see SP24 spec for the full posture). LAN-only by design — don't expose the published ports to the WAN.
Stack: one Python process (FastAPI + uvicorn, port 8000) + one Node process in dev (Vite, port 5173). The authoritative state is a single SQLite file at `~/.local/share/cyclone/cyclone.db` (or SQLCipher at the same path when the macOS Keychain entry + `sqlcipher3` are both present).
For the day-1 architecture read, see `docs/ARCHITECTURE.md` (process topology, module map, store facade, parser pipeline, pubsub). For the what-it-does read, see `docs/REQUIREMENTS.md` (FRs + NFRs + DoD).
## Install
```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 0.0.0.0:8000
# CYCLONE_PORT=... overrides port; CYCLONE_RELOAD=1 enables uvicorn --reload
# Or: .venv/bin/uvicorn cyclone.api:app --reload --port 8000
# Terminal 2 — frontend
npm run dev # Vite on http://localhost:5173
```
Vite proxies `/api/*` to the backend at `http://127.0.0.1:${CYCLONE_PORT:-8000}` so relative-URL fetchers (the live-tail NDJSON streams in particular) resolve through the same origin. Override the backend port with `CYCLONE_PORT` in the frontend terminal too.
Create `.env.local` at the repo root with `VITE_API_BASE_URL=http://127.0.0.1:8000`. Without it, the UI runs against the in-memory zustand store and real EDI parsing is disabled.
## Test
```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`. SP numbers are used through **SP41** (in-window rebill pipeline, merged `309e6c0`); the next free increment is **SP42**. SP23 (Ubuntu + Docker + auth + RBAC + LAN-bind product fork) shipped via `merge: SP23 Ubuntu Docker Deployment into main` (`07a7ecb`). 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) |
| GET | `/api/acks/stream` | `ack_received` | `-received_date` |
| GET | `/api/ta1-acks/stream` | `ta1_ack_received` | `-received_date` |
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. After SP21 (store split, `4360ef7`) and SP36 (api-routers split, `f005494`), the largest files now live under `cyclone/api_routers/` (22 routers) and `cyclone/store/` (the SP21-split facade in 16 modules). `api.py` is a ~377-line shell that wires the FastAPI app, mounts the auth + admin + api_routers routers, and re-exports a few backward-compat shims for tests; `store.py` no longer exists. Subpackages: `api_routers/` (SP36 — 22 routers: acks, activity, admin, batches, claim_acks, claims, clearhouse, config, dashboard, eligibility, health, inbox, parse, payers, providers, rebill, reconciliation, remittances, submission, ta1_acks, plus `_shared.py`), `auth/` (SP24 — bcrypt + HttpOnly session cookies + RBAC `matrix_gate`; 10 modules), `clearhouse/` (Clearhouse + SftpClient), `edi/` (filenames), `handlers/` (SP27 — per-file-type inbound parse handlers), `parsers/` (X12 transaction parsers + models + validators + serializers), `rebill/` (SP41 — in-window rebill pipeline), `reissue/` (SP24 — offline 837P reissue workflow), `store/` (SP21 — split `CycloneStore` facade), `submission/` (SP37 — canonical `submit_file` helper shared by `cyclone submit-batch` CLI + `POST /api/submit-batch` HTTP endpoint; owns parse → DB-write → SFTP-upload → audit per file). The legacy `workflow/` directory is dead bytecode — see REQUIREMENTS.md R-27 (Open).
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`; 23 SQL migrations under `migrations/` (0001_initial through 0023_visits) are walked in order by `db_migrate.py`.
The parser pipeline is a 5-stage `tokenize → segmentize → model → validate → write_to_store` flow used for every inbound X12 type. Per-transaction parsers: `parse_837.py`, `parse_835.py`, `parse_999.py`, `parse_ta1.py`, `parse_270.py`, `parse_271.py`, `parse_277ca.py`. Each has a matching Pydantic model module (`models.py`, `models_835.py`, …) and a writer (`writer.py` / `writer_835.py`). The 837P serializer (`serialize_837.py`) is the byte-faithful outbound counterpart used by both single-claim download (`/api/claims/{id}/serialize-837`) and the bulk rejected-resubmit bundle (`/api/inbox/rejected/resubmit?download=true`).
The pubsub is `cyclone.pubsub.EventBus` — an in-process async fan-out broker. Publishers call `publish(kind, payload)`; subscribers receive via an async iterator. If a subscriber's per-kind queue is full, the oldest event is dropped so a slow consumer can't stall the producer. Bus is single-event-loop only (matches FastAPI/uvicorn).
Config: `config/payers.yaml` is the on-disk source for providers / payers / clearhouse, schema-validated at boot against a Pydantic model. Reload with `POST /api/admin/reload-config`. Original in-code `PAYER_FACTORIES` dict in `cli.py` is kept as a fallback for ad-hoc testing.
Secrets live in the macOS Keychain (via `keyring` + `cyclone.secrets`): SQLCipher key (service `cyclone`, account `cyclone.db.key`), SFTP password, backup passphrase. No secrets on disk in plaintext.
## Production SFTP posture (this box: manual mode)
This host runs in **manual SFTP mode** against Gainwell's MOVEit Transfer MFT at `mft.gainwelltechnologies.com`. The seeded `dzinesco` clearhouse keeps `sftp_block.stub: true`; the operator moves files to/from Gainwell with their SFTP client and drops inbound files into `ingest/` for cyclone to ingest. Do NOT flip `stub` to `false` from this host — see "Auth caveat" below.
### Env var convention
The operator's shell exports the Gainwell creds as `GAINWELL_SFTP_USER`, `GAINWELL_SFTP_PASS`, `GAINWELL_SFTP_HOST`, `GAINWELL_REMOTE_DIR`. Cyclone's `secrets.get_secret()` looks up `CYCLONE_SFTP_PASSWORD` (or `CYCLONE_SFTP_PASSWORD_FILE`) per `secrets._ENV_NAME_FOR`. The two are NOT the same name — bridge them per-call or in your shell rc:
```bash
export CYCLONE_SFTP_PASSWORD="$GAINWELL_SFTP_PASS"
```
### Inbound drop zone
`/home/tyler/dev/cyclone/ingest/` is the operator-maintained staging dir for inbound MFT files (999 acks, TA1, 835 remittances, 277CA claim acks). Files here are NOT auto-watched; processing happens via the procedure in `docs/RUNBOOK.md` § "Manual SFTP mode". At time of writing this dir holds ~1500 unprocessed 999 acks + 1 TA1 + 1 835 from early July 2026 — the local ingest flow clears them.
### Auth caveat (do not retry)
Paramiko reaches `MOVEit Transfer SFTP` cleanly (SSH kex completes, MOVEit offers password auth) but `AuthenticationException: Authentication failed` is returned despite the operator confirming the credentials are known-good from another host. Most likely cause: MOVEit Transfer's IP-based access control — this host's public IP `103.14.26.95` is not whitelisted. Repeated auth attempts risk account lockout. Do NOT keep guessing. Resolve by either: (a) whitelisting this IP with Gainwell's MFT admin, or (b) staying in manual mode and moving files via the operator's SFTP client.
### Inbound ingestion paths
The canonical way to write 999/TA1/277CA/835 rows into the DB is `Scheduler.process_inbound_files`, exposed as the CLI `cyclone pull-inbound --date YYYYMMDD` and the HTTP `POST /api/admin/scheduler/pull-inbound`. **There is no `parse-999` / `parse-ta1` / `parse-277ca` CLI command** (CLAUDE.md's "CLI" section above is aspirational on those three). The `parse-837` and `parse-835` CLIs exist but only emit JSON files to `--output-dir`; they do NOT write to the DB. For DB writes, use `pull-inbound` (which dedupes via `processed_inbound_files`).
### Daemon hot-reload
`python -m cyclone serve` runs as root (started by `tini`) and keeps the SFTP block in memory. Flipping `stub` directly in the DB (`store.update_clearhouse(...)`) does NOT auto-reload the daemon's view — either restart the daemon or use `PATCH /api/clearhouse` (which calls `scheduler.reconfigure_scheduler` to hot-reload).
## Frontend at a glance
`src/` is React 18 + TypeScript + Vite. Routes register in `src/App.tsx` (12 pages — Dashboard, Claims, Remittances, Providers, ActivityLog, Upload, Reconciliation, Inbox, Acks, Batches, BatchDiff, Login — all under a `<Layout>` route wrapper, with `Login` outside the auth gate). 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/`, `ProviderDrawer/`, `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
# 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.
- **Always bind to 0.0.0.0.** The bind address does not control reachability — the host firewall / compose port publishing does. Requires login (bcrypt + HttpOnly session cookie; see SP24 spec), and the threat model is still a stolen/imaged drive — SQLCipher at rest and the macOS Keychain handle that. The auth boundary is the HTTP layer; the file-system posture is unchanged. Don't expose the published ports to the public internet (LAN-only or VPN-fronted). Don't disable auth without an explicit `CYCLONE_AUTH_DISABLED=1` env var (the escape hatch logs a WARNING at boot).
</content>
</invoke>
-43
View File
@@ -1,43 +0,0 @@
# 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
+185 -462
View File
@@ -2,17 +2,12 @@
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 FastAPI backend and a React UI for browsing,
filtering, and inspecting the parsed data.
and 005010X221A1), with a local-only FastAPI backend and a React UI for
browsing, filtering, and inspecting the parsed data.
LAN-only by design: always binds to `0.0.0.0:8000` and requires login
(bcrypt + HttpOnly session cookie; first admin bootstrapped from
`CYCLONE_ADMIN_USERNAME` + `CYCLONE_ADMIN_PASSWORD`). Reachability is
controlled by the host firewall / compose port publishing, not by the
bind address. Built for one operator, one machine, one trading partner
(Colorado Medicaid, currently). Do not expose the published ports to
the public internet. See [SP23](docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md)
for the LAN-bind / Docker / RBAC product fork that shipped 2026-06-23.
Local-only on purpose: binds to `127.0.0.1`, no auth, no internet exposure.
Built for one operator, one machine, one trading partner (Colorado
Medicaid, currently).
## Install
@@ -35,7 +30,7 @@ Two terminals:
# Terminal 1 — backend
cd backend
.venv/bin/python -m cyclone serve
# (defaults to 0.0.0.0:8000; override with CYCLONE_HOST / CYCLONE_PORT / CYCLONE_RELOAD=1)
# (defaults to 127.0.0.1:8000; override with CYCLONE_PORT=...; reload with CYCLONE_RELOAD=1)
# Terminal 2 — frontend
npm run dev
@@ -56,52 +51,6 @@ VITE_API_BASE_URL=http://127.0.0.1:8000
Without that, the UI falls back to its in-memory sample store via the
existing `data` adapter (parses are disabled).
## Pipeline automation agent
For unattended round-trips (an agent / scheduler drops an 837P file
into the pipeline and waits for the 999 back from Gainwell), see the
`cyclone-pipeline` sibling project at `/Users/openclaw/dev/cyclone-pipeline/`.
It drives the full 7-phase state machine — preflight → browser upload →
parse verification → SFTP submit → TA1 wait → 999 wait → scan +
report — with structured JSON logs, crash-safe resume, and a
self-contained per-run folder under `./runs/`.
```bash
# Single file
cyclone-pipeline run /path/to/axiscare-837p.txt
# Resume a crashed run
cyclone-pipeline resume 2026-06-21-1430-001
# On/after the following Monday, verify the 835 arrived
cyclone-pipeline check-835 2026-06-21-1430-001
```
The 835 is **not** waited for inline (it lands the following Monday on
the CO Medicaid payment cycle). See
[`cyclone-pipeline/README.md`](../cyclone-pipeline/README.md) for
install, embed-in-agent example, exit codes, and the report format.
## Skills
Cyclone ships 8 project-scoped AI-assistant skills under
[`.superpowers/skills/`](.superpowers/skills/). Each one codifies the
conventions for a major subsystem so the next contributor (human or
AI) gets the lay of the land automatically.
| Skill | Owns |
|-------|------|
| [`cyclone-spec`](.superpowers/skills/cyclone-spec/SKILL.md) | The SP-N spec → plan → implement → merge flow. |
| [`cyclone-tests`](.superpowers/skills/cyclone-tests/SKILL.md) | pytest + vitest fixture patterns, prodfiles drop-in. |
| [`cyclone-edi`](.superpowers/skills/cyclone-edi/SKILL.md) | EDI parser/validator conventions (837P/835/999/270/271/277CA/TA1). |
| [`cyclone-tail`](.superpowers/skills/cyclone-tail/SKILL.md) | Live-tail streaming wire format and the hook triplet. |
| [`cyclone-store`](.superpowers/skills/cyclone-store/SKILL.md) | Store write-paths, pubsub event contract, SP21 split map. |
| [`cyclone-api-router`](.superpowers/skills/cyclone-api-router/SKILL.md) | FastAPI router conventions (`api_routers/`, `api_helpers.py`). |
| [`cyclone-frontend-page`](.superpowers/skills/cyclone-frontend-page/SKILL.md) | React page conventions (TanStack Query, drawer, URL state). |
| [`cyclone-cli`](.superpowers/skills/cyclone-cli/SKILL.md) | CLI subcommand conventions (`cli.py`, exit codes, smoke tests). |
Skills auto-load by description match — no slash command needed.
## Test
```bash
@@ -116,49 +65,6 @@ npm run build
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
The Claims, Remittances, and Activity pages stay current without
@@ -189,15 +95,13 @@ happening — clients flip to `stalled` after 30s of total silence
### Endpoints
| 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) |
| GET | `/api/acks/stream` | `ack_received` | `-received_date` |
| GET | `/api/ta1-acks/stream` | `ta1_ack_received` | `-received_date` |
| 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) |
All five accept the same query params as their non-streaming
All three accept the same query params as their non-streaming
counterparts (`status`, `payer`, `date_from`, …) so a frontend can
swap a one-shot fetch for a tail with no URL surgery. Responses
are `Content-Type: application/x-ndjson`.
@@ -251,6 +155,7 @@ parses and rejects claims, the inbox reflects the new
| POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. `409` if the claim state moved out from under us. |
| POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. |
| POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. `200` with `conflicts` for non-rejected. |
| POST | `/api/inbox/payer-rejected/acknowledge` | `{claim_ids: [...], actor: "..."}`. Bulk-acknowledge payer rejections. Idempotent. `200` with `transitioned` / `already_acked` / `not_found` / `not_rejected` counts. Writes an audit event per transition; never overwrites the underlying 277CA rejection. |
| GET | `/api/inbox/export.csv?lane=<lane>` | Streams CSV of the lane's rows. |
## Outbound 837 Serializer
@@ -417,8 +322,9 @@ both be true for the same claim.
The `audit_log` table is the canonical record of every state transition
the system has ever observed — claim lifecycle, reconciliation
decisions, config reloads, SFTP submissions, 277CA rejects. SP11 made
it tamper-evident: every row carries a SHA-256 hash of
decisions, config reloads, SFTP submissions, 277CA rejects, Payer-Rejected
acknowledgements, SQLCipher key rotations. SP11 made it tamper-evident:
every row carries a SHA-256 hash of
`(prev_hash || row_payload)`, forming a chain back to a genesis row.
Any `INSERT`, `UPDATE`, or `DELETE` that breaks the chain is detectable
in a single walk.
@@ -431,8 +337,8 @@ in a single walk.
`verify_chain` is the integrity check that backs the audit promise —
it is intentionally cheap (one indexed walk) and intentionally
side-effect-free so a scheduler can run it on a cron and alert on any
non-`{ok: true}` result. Chain verification is gated by the standard
`matrix_gate` dependency (admin role for the `/verify` subpath); for a
non-`{ok: true}` result. Chain verification is **not** access-gated
beyond the same `127.0.0.1` bind the rest of the API uses; for a
hostile multi-operator deployment, wrap the route in your reverse proxy.
## Encryption at Rest
@@ -453,251 +359,46 @@ operator has created the Keychain entry on first run. See
for the one-time setup recipe and the HIPAA Security Rule §164.312(a)(2)(iv)
mapping.
### Key rotation (SP15)
### Key rotation
`POST /api/admin/db/rotate-key` re-encrypts the SQLite file in place
with a fresh SQLCipher key via `PRAGMA rekey`, then updates the
Keychain so subsequent connections open with the new key. The
rotation holds a module-level `threading.Lock` (so two concurrent
requests can't race), disposes + rebuilds the SQLAlchemy engine with
`NullPool` (so SQLCipher's thread affinity is honored), and writes a
tamper-evident `db.key_rotated` audit event with old + new
fingerprints and the post-rotation table count. The old key is
retained in the `cyclone.db.key.previous` Keychain account for a
grace period so a botched rotation can be rolled back by hand.
The DB encryption key is rotated in place via `POST /api/admin/db/rotate-key`.
The handler:
## NPI checksum + Tax ID format validation (SP20)
1. Generates a fresh 256-bit CSPRNG key (`db_crypto.generate_db_key()`).
2. Persists the new key to the Keychain under the same
`cyclone.db.key` account (overwriting the old one).
3. Disposes the SQLAlchemy engine so pooled connections release the
file (SQLCipher refuses to `PRAGMA rekey` while another connection
holds the DB).
4. Opens with the old key, issues `PRAGMA rekey`, and verifies the
schema survived (table-count sanity check).
5. Rebuilds the engine with the new key.
6. Writes a `db.key_rotated` audit event carrying the SHA-256
fingerprints of the old and new keys (first 8 hex chars) plus the
post-rotation `table_count`.
Two pure local validators — no NPPES round-trip, no IRS e-file
lookup. Catches the 99% case (a typo at the end of an NPI, a letter in
an EIN, an extra digit, the reserved `00`/`07`/`8X` EIN prefix).
When SQLCipher is enabled the engine uses SQLAlchemy's `NullPool`
instead of the default `QueuePool`. `QueuePool` returns connections to
a shared queue that any thread can pull from, which breaks SQLCipher's
thread affinity. `NullPool` trades connection reuse for thread safety
— the only correct behavior under FastAPI's per-request threadpool.
| Check | Algorithm | Surface |
|-------|-----------|---------|
| NPI | 10 digits where the last is a Luhn checksum over `80840 + body`. CMS-published example: body `123456789` → check `3` → valid NPI `1234567893`. | `cyclone.npi.is_valid_npi`, CLI `cyclone validate-npi <npi>`, API `GET /api/admin/validate-provider?npi=...`, validator rule `R021_npi_checksum` (warning) |
| Tax ID (EIN) | 9 digits, optional `XX-XXXXXXX` formatting. Rejects reserved prefixes `00`, `07`, `80``89` (IRS Pension Plan Branch). | `cyclone.npi.is_valid_tax_id`, CLI `cyclone validate-tax-id <ein>`, API `GET /api/admin/validate-provider?tax_id=...` |
The rotation endpoint is serialized with a module-level
`threading.Lock` (one rotation in flight at a time), returns `409` if
a rotation is already running, `400` if encryption is not enabled,
and `503` with a `reason` on `PRAGMA rekey` or Keychain failure so the
operator can take the next step without parsing the traceback.
### CLI
```bash
$ cyclone validate-npi 1234567893
OK: 10-digit NPI passes Luhn checksum
$ cyclone validate-npi 1234567890
INVALID: '1234567890' fails NPI Luhn checksum # exit 1
$ cyclone validate-tax-id 72-1587149
OK: 9-digit EIN (normalized=721587149)
$ cyclone validate-tax-id 00-1234567
INVALID: 9-digit EIN has reserved prefix (00); EIN is not assignable by IRS # exit 1
```
### API
```bash
curl 'http://localhost:8000/api/admin/validate-provider?npi=1234567893&tax_id=72-1587149'
# {
# "npi": {"valid": true, "skipped": false},
# "tax_id": {"valid": true, "skipped": false, "normalized": "721587149"}
# }
```
Both query params are optional; omitted fields return
`{"valid": null, "skipped": true}` so the caller can render "no check
performed" rather than treating absent input as a hard fail.
### Parser integration
The `R021_npi_checksum` rule runs alongside the existing `R020_npi_format`
in `cyclone.parsers.validator`. A billing-provider NPI that passes
R020 (right shape) but fails R021 (bad Luhn) is yielded as a
**warning**, not an error — operators sometimes ingest test fixtures
with placeholder NPIs (e.g. all-same-digit) and we don't want to block
that path. In strict mode (`--strict` / `?strict=true`) warnings are
promoted to errors.
### Files
* `cyclone/npi.py` — new module (~155 LOC).
* `cyclone.parsers.validator` — new `R021_npi_checksum` rule.
* `cyclone.api_routers.admin` — new `validate-provider` endpoint.
* `cyclone.cli``validate-npi` + `validate-tax-id` subcommands.
* Tests: `test_npi.py` (27), `test_api_validate_provider.py` (4),
`test_cli_validate.py` (8), `test_validator.py::test_r021_*` (4) —
**43 new tests**.
## Security hardening (SP19)
Three pure-ASGI middlewares sit in front of every FastAPI request.
They're sized for Cyclone's local-only posture — a misconfigured
Tailscale / ngrok bind, a buggy cron job uploading a 4 GB file, or a
port-scraper — not for hostile internet exposure.
| Middleware | Default | Override | Reject |
|------------|---------|----------|--------|
| `BodySizeLimitMiddleware` | 50 MB | `CYCLONE_MAX_BODY_BYTES` | `413 body_too_large` over Content-Length cap; chunked reads capped too |
| `RateLimitMiddleware` | 300 req/min/IP | `CYCLONE_RATE_LIMIT_PER_MIN` | `429 rate_limited` over the sliding window; `/api/health` exempt |
| `SecurityHeadersMiddleware` | always on | n/a | stamps `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, `Permissions-Policy`, `Content-Security-Policy: default-src 'none'; frame-ancestors 'none'` |
Every rejection (413 / 429) also writes a tamper-evident
`api.request_rejected` event into the SP11 audit chain so an
operator can correlate a misbehaving client with the SP18 JSON logs:
```json
{"event_type":"api.request_rejected","entity_id":"POST /api/parse-837","payload":{"status":413,"reason":"body_too_large","path":"/api/parse-837","method":"POST","ip":"127.0.0.1"}}
```
### Health probe
`GET /api/health` now returns a subsystem snapshot:
```json
{
"status": "ok",
"version": "0.1.0",
"db": {"ok": true},
"scheduler": {"running": true, "interval_s": 60, "sftp_block": "co_medicaid",
"backup_scheduler_running": false, "backup_interval_hours": 24.0},
"pubsub": {"parse_completed": 1, "batch_added": 1},
"batch": {"last_batch_id": 42, "last_batch_kind": "837P",
"last_batch_at": "2026-06-21T15:30:00.123Z",
"last_batch_filename": "TP11525703-837P-..."}
}
```
Returns `"status": "degraded"` if any subsystem reports an error —
the per-subsystem dict still surfaces so an operator can see which
one is unhappy. `/api/health` is rate-limit exempt so a load balancer
hammering the endpoint doesn't trip the limiter.
### Files
* `cyclone.security``BodySizeLimitMiddleware`,
`RateLimitMiddleware`, `SecurityHeadersMiddleware`, and
`get_health_snapshot()` (~330 LOC).
* `cyclone.api_routers.health` rewritten to use `get_health_snapshot()`.
* `cyclone.pubsub.EventBus.stats()` — new method that returns
per-kind subscriber counts.
* `tests/test_security.py` — 13 new tests.
## Structured logging (SP18)
Cyclone emits newline-delimited JSON to stderr by default — readable
by `jq`, Loki, Vector, ELK, or any log shipper. Every record carries
`ts` (ISO 8601 ms UTC), `level`, `logger`, `msg`, and an optional
`extra` dict for structured fields. Exceptions render as a
`traceback` string.
```
{"ts":"2026-06-21T15:30:00.123Z","level":"INFO","logger":"cyclone.scheduler","msg":"Processed inbound file","extra":{"input_filename":"ACK_999.x12","parser":"parse_999","claims":3}}
{"ts":"2026-06-21T15:30:01.456Z","level":"ERROR","logger":"cyclone.api","msg":"Backup create failed","extra":{"reason":"BackupError: passphrase mismatch"},"traceback":"Traceback ..."}
```
### PII scrubbing
A `PiiScrubber` filter is attached to the root logger and rewrites
obvious PHI patterns to `<redacted:npi>` / `<redacted:ssn>` /
`<redacted:dob>` / `<redacted:patient_name>` before any handler sees
the record:
| Pattern | Replacement |
|---------|-------------|
| `\b\d{10}\b` | `<redacted:npi>` |
| `\b\d{3}-\d{2}-\d{4}\b` or `\b\d{9}\b` at phrase boundary | `<redacted:ssn>` |
| `(dob\|date_of_birth)[:=]\s*\d{4}-\d{2}-\d{2}` | preserves the key, redacts the date |
| `patient_name=...` | full chunk redacted |
| Extras with key `dob`/`ssn`/`npi`/`patient_name`/… | value redacted regardless of shape |
The scrubber is conservative — bare ISO dates without a `dob=` prefix
are **not** scrubbed (they're too often timestamps or batch IDs), and
11+ digit numbers are left alone (they can't be NPIs). Disable for
forensic mode with `CYCLONE_LOG_NO_PII_SCRUB=1`.
### Knobs
| Env var | Default | Meaning |
|---------|---------|---------|
| `CYCLONE_LOG_LEVEL` | `INFO` | Root logger level. `DEBUG` for troubleshooting. |
| `CYCLONE_LOG_FILE` | (none) | Write to this path via `RotatingFileHandler` (10 MB × 5 backups). |
| `CYCLONE_LOG_JSON` | `true` | `false` uses the dev tabular formatter. |
| `CYCLONE_LOG_NO_PII_SCRUB` | (none) | `1` disables scrubbing. |
CLI:
```
cyclone --log-format=dev parse-837 sample.x12 --output-dir out/ # tabular for tail -f
cyclone --log-file=/var/log/cyclone.log backup create # JSON to rotating file
```
The `parse-837` / `parse-835` subcommands also accept `--log-level`
which re-runs `setup_logging()` so the per-invocation level overrides
the group default.
### Files
* `cyclone.logging_config``JsonFormatter`, `CycloneDevFormatter`,
`PiiScrubber`, `setup_logging()`.
* `tests/test_logging_formatter.py` (11), `test_logging_scrubber.py`
(13), `test_logging_setup.py` (10) — 34 new tests.
* `cyclone.api` lifespan calls `setup_logging()` first; the CLI
`main` group does the same.
## Encrypted Backups (SP17)
The BackupService takes an online consistent snapshot of the live
SQLite file via SQLite's `.backup()` API, encrypts the bytes with
AES-256-GCM, and writes a `.bin` + `.meta.json` pair into the backup
directory (default `~/.local/share/cyclone/backups/`). The encryption
key is derived from a separate passphrase in the macOS Keychain
(PBKDF2-HMAC-SHA256, 200,000 iterations, 16-byte salt persisted to
Keychain) — so a SQLCipher DB-key compromise does not unlock the
backups, and a backup-passphrase compromise does not unlock the live
DB. If neither is set, the service refuses (`BackupError`) rather than
silently writing plaintext.
| Method | Path | Purpose |
| ------ | ---- | ------- |
| POST | `/api/admin/backup/create` | Take an encrypted backup now. |
| GET | `/api/admin/backup/list` | List `db_backups` rows (newest first, filterable). |
| GET | `/api/admin/backup/status` | Counts, disk usage, last-run timestamp, scheduler snapshot. |
| POST | `/api/admin/backup/{id}/verify` | Decrypt + SHA-256 verify against the sidecar. |
| POST | `/api/admin/backup/{id}/restore/initiate` | First step: get `restore_token` + preview (fingerprints of backup vs live). |
| POST | `/api/admin/backup/{id}/restore/confirm` | Second step: dispose engine, copy decrypted DB, rebuild engine. |
| POST | `/api/admin/backup/prune` | Apply retention policy now. |
| POST | `/api/admin/backup/scheduler/{start,stop,tick}` | Operate the backup scheduler. |
Restore is two-step by design: an idle browser tab can't nuke the
live DB. The first call returns a one-shot 64-char hex
`restore_token` plus a side-by-side preview (`backup_db_fingerprint`,
`backup_table_count`, `current_db_fingerprint`, `current_table_count`).
The second call swaps the live engine only if the token matches
within a 5-minute TTL.
The scheduler (auto-start opt-in via `CYCLONE_BACKUP_AUTOSTART`)
ticks every `CYCLONE_BACKUP_INTERVAL_HOURS` (default 24), runs
`create_now` + `prune`, and writes audit events for each outcome
(`db.backup_created`, `db.backup_failed`, `db.backup_pruned`,
`db.backup_restored`). The CLI mirrors the API surface:
```bash
cyclone backup init-passphrase # one-time; interactive
cyclone backup create
cyclone backup list
cyclone backup verify <id>
cyclone backup restore <id> --yes
cyclone backup prune --yes
cyclone backup status
```
Retention defaults to 30 days (`CYCLONE_BACKUP_RETENTION_DAYS`). The
retention policy is best-effort: an operator who runs `cyclone
backup create` manually retains full control.
| Method | Path | Notes |
| ------ | --------------------------------- | -------------------------------------------------------------- |
| POST | `/api/admin/db/rotate-key` | Rotate the SQLCipher key in place. Audit-logged. |
## SFTP Wire-Up (paramiko)
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
generated 837 files to the dzinesco SFTP server
(`mft.gainwelltechnologies.com:22`, path
`/CO XIX/PROD/coxix_prod_11525703/ToHPE`). The SFTP credential is
`/CO XIX/PROD/coxix_prod_11525703/FromHPE`). The SFTP credential is
fetched from the macOS Keychain at call time — never read from YAML,
never logged, never written to disk. The wire-up honors the file-naming
template stored in the `clearhouse` config:
@@ -717,6 +418,58 @@ was a one-file change (`sftp_paramiko.py` replacing `sftp_stub.py`).
the `paramiko` extras aren't installed so the test suite stays green on
Linux dev boxes.
## Inbound MFT Scheduler (SP16)
The dzinesco SFTP server accepts inbound files (835 remits, 999/TA1
acks, 271 eligibility responses, 277/277CA claim acknowledgments) that
need to be pulled on a cadence, parsed, and persisted. SP16 ships the
**operator-facing control surface** for the polling loop, even though
the actual background scheduler is still in flight:
| Method | Path | Notes |
| ------ | --------------------------------------------- | ---------------------------------------------------------------- |
| POST | `/api/admin/scheduler/start` | Start the background MFT polling loop (idempotent). |
| POST | `/api/admin/scheduler/stop` | Stop the background loop. Waits for the current tick to finish. |
| GET | `/api/admin/scheduler/status` | Running state + last tick summary. |
| POST | `/api/admin/scheduler/tick` | Force a single poll cycle. Coalesces with an in-flight tick. |
| GET | `/api/admin/scheduler/processed-files` | Recently processed inbound files. `?status=ok|error|skipped|pending`, `?limit=` (1500, default 50). |
The endpoints read from a `cyclone.scheduler` module and a
`cyclone.db.ProcessedInboundFile` ORM model that are being landed
alongside SP16 — the admin router's import surface is in place, the
implementation is in progress. The control surface is forward-compatible
with the eventual scheduler: starting / stopping / ticking / listing
processed files will all work as soon as the loop lands.
The `paramiko` SFTP client from [SP13](#sftp-wire-up-paramiko) is the
underlying transport; the scheduler reuses it to list + download files
under the inbound SFTP path, then dispatches each downloaded file to
the matching parser based on the `<FileType>` token in the filename
(see [SP13 file-naming template](#sftp-wire-up-paramiko)).
## Batches, Reconciliation, and Activity
These read/write endpoints are the core operator surface for browsing
the parsed record and acting on matches. They predate the per-SP
endpoint reference sections in the **Roadmap** and are listed here in
one place so the route inventory stays discoverable.
| Method | Path | Notes |
| ------ | --------------------------------------------- | ---------------------------------------------------------------- |
| GET | `/api/batches` | All parsed batches, newest first. `?limit=` (11000, default 100). |
| GET | `/api/batches/{batch_id}` | One batch detail (envelope, claims / remits, validation summary). |
| GET | `/api/batch-diff?a=<id>&b=<id>` | Side-by-side diff of two batches: `added` / `removed` / `changed` claims + envelope metadata for each. Both query params required. |
| GET | `/api/reconciliation/unmatched` | `{"claims": [...], "remittances": [...]}` — every Claim with no paired Remittance and vice versa. The two lists are always present (empty list, never absent) so the UI can index unconditionally. |
| POST | `/api/reconciliation/match` | Body `{claim_id, remit_id}`. Manually pair a claim with a remit. `400` on missing ids, `404` on unknown id, `409` on already-matched or terminal-state claim. |
| POST | `/api/reconciliation/unmatch` | Body `{claim_id}`. Remove the current match and reset the claim to `submitted`. `404` on unknown id, `409` on no current match. |
| GET | `/api/providers` | Distinct providers across parsed claims. `?npi=`, `?state=`, `?limit=`, `?offset=`. Distinct from `/api/config/providers/{npi}` (SP9 config table) — this endpoint surfaces providers derived from the parsed claim stream. |
| GET | `/api/activity` | Recent activity events. `?kind=`, `?since=`, `?limit=` (1500, default 200). Powers the Activity page; the streaming counterpart `/api/activity/stream` is documented under **Live updates**. |
The per-SP endpoint blocks at the bottom of the **Roadmap** cover the
SP-specific routes (parse, 999/TA1/277CA, Inbox, claim drawer, line
reconciliation, outbound 837, multi-payer config, audit log, 277CA,
key rotation, payer-rejected acknowledge).
## Persistence
Parsed batches, claims, remittances, matches, 277CA rejections,
@@ -750,11 +503,35 @@ backup API).
.
├── backend/
│ ├── src/cyclone/
│ │ ├── api.py # FastAPI app shell (~377 LOC post-SP36; routes live in api_routers/)
│ │ ├── api.py # FastAPI app + middleware; mounts api_routers/* sub-apps
│ │ ├── api_helpers.py # NDJSON / content-negotiation / live-tail helpers
│ │ ├── api_routers/ # FastAPI APIRouters extracted from api.py
│ │ │ ├── health.py # GET /api/health
│ │ │ ├── acks.py # GET /api/acks, /api/acks/{id}
│ │ │ ├── ta1_acks.py # GET /api/ta1-acks, /api/ta1-acks/{id}
│ │ │ ├── activity.py # GET /api/activity, /api/activity/stream
│ │ │ ├── batches.py # GET /api/batches, /api/batches/{id}
│ │ │ ├── claims.py # GET /api/claims, /api/claims/stream, /api/claims/{id},
│ │ │ │ # /api/claims/{id}/serialize-837, /api/claims/{id}/line-reconciliation
│ │ │ ├── remittances.py # GET /api/remittances, /api/remittances/stream, /api/remittances/{id}
│ │ │ ├── providers.py # GET /api/providers
│ │ │ ├── inbox.py # GET /api/inbox/lanes, POST /api/inbox/* (match/dismiss/acknowledge/resubmit),
│ │ │ │ # GET /api/inbox/export.csv
│ │ │ ├── reconciliation.py # GET /api/reconciliation/unmatched, /api/batch-diff,
│ │ │ │ # POST /api/reconciliation/match, /api/reconciliation/unmatch
│ │ │ ├── eligibility.py # POST /api/eligibility/request, /api/eligibility/parse-271
│ │ │ ├── clearhouse.py # GET /api/clearhouse, POST /api/clearhouse/submit
│ │ │ ├── config.py # /api/config/providers[/...], /api/config/payers[/...], /api/admin/reload-config
│ │ │ ├── parse_835.py # POST /api/parse-835
│ │ │ ├── parse_837.py # POST /api/parse-837
│ │ │ ├── parse_999.py # POST /api/parse-999
│ │ │ ├── parse_ta1.py # POST /api/parse-ta1
│ │ │ └── admin.py # /api/admin/audit-log[/verify], /api/admin/db/rotate-key,
│ │ │ # /api/admin/scheduler/* (SP16)
│ │ ├── pubsub.py # in-process EventBus (drop-oldest, per-kind fan-out)
│ │ ├── store.py # CycloneStore, mappers, publish-on-write
│ │ ├── db.py # SQLAlchemy engine, session factory, ORM models
│ │ ├── db_migrate.py # PRAGMA user_version migration runner (23 migrations)
│ │ ├── db_migrate.py # PRAGMA user_version migration runner
│ │ ├── db_crypto.py # optional SQLCipher encryption at rest (SP12)
│ │ ├── audit_log.py # tamper-evident hash-chained audit_log (SP11)
│ │ ├── inbox_lanes.py # rejected / payer_rejected / candidates / unmatched / done_today
@@ -762,45 +539,37 @@ backup API).
│ │ ├── inbox_state_277ca.py # 277CA STC A4/A6/A7 → payer_rejected stamp (SP10)
│ │ ├── providers.py # multi-NPI provider lookups (SP9)
│ │ ├── payers.py # payer / payer_config lookups (SP9)
│ │ ├── secrets.py # macOS Keychain-backed secret fetcher (4-tier: _FILE > env > Keychain > None)
│ │ ├── reconcile.py # pure-function 835→claim match + line-level match (SP31 pcn-exact)
│ │ ├── scheduler.py # inbound MFT polling scheduler (SP16, SP27 per-op timeout)
│ │ ├── edifabric.py # Edifabric /v2/x12/{read,validate} HTTP client (SP40)
│ │ ── backup.py / backup_service.py / backup_scheduler.py # SP17 encrypted backups
│ ├── __main__.py # `python -m cyclone serve` (auth bootstrap then dispatch)
├── cli.py # click CLI (15+ subcommands; see cyclone-cli skill)
├── api_routers/ # SP36 — 22 FastAPI routers + _shared.py helpers
├── auth/ # SP24 — bcrypt + sessions + matrix_gate + admin users + rate_limit
├── clearhouse/ # SftpClient + InboundFile + SftpStat
├── edi/ # filename regex/builder helpers
├── handlers/ # SP27 — per-file-type inbound parse handlers
├── parsers/ # X12 tokenizer, models, validator, writers, 277CA, 999, TA1, 270, 271
├── rebill/ # SP41 — in-window rebill pipeline (13 modules)
├── reissue/ # SP24 — offline 837P reissue workflow
├── store/ # SP21 — split CycloneStore facade (16 modules)
── submission/ # SP37 — canonical submit_file helper + recover + bulk_ingest
── tests/ # 178 pytest files (post-SP41)
│ ├── fixtures/ # 22 entries (837P / 835 / 999 / TA1 / 277CA samples + visits CSV)
── test_api_*.py # FastAPI integration via TestClient
│ ├── test_*.py # pure-unit
│ └── ...
├── src/ # React + Vite + TypeScript UI (12 pages, 84 test files)
│ │ ├── secrets.py # macOS Keychain-backed secret fetcher
│ │ ├── reconcile.py # pure-function 835→claim match + line-level match
│ │ ├── __main__.py # `python -m cyclone serve`
│ │ ├── cli.py # click CLI
│ │ ── parsers/ # X12 tokenizer, models, validator, writers, 277CA, 999, TA1, 270, 271
└── tests/
├── fixtures/ # co_medicaid_*.txt, minimal_*.txt
├── test_api.py # parse-837/835 round-trip
├── test_api_gets.py # 6 GET endpoints
├── test_store.py # store + mappers + iterators
├── test_api_streaming.py
├── test_api_stream_live.py # 3 live-tail endpoints + disconnect cleanup
├── test_pubsub.py # EventBus + subscribe/unsubscribe
├── test_api_parse_persists.py
├── test_db.py / test_db_crypto.py / test_db_migrate.py
├── test_audit_log.py
── test_inbox_lanes.py / test_inbox_state.py
── test_apply_277ca_rejections.py
│ ├── test_sftp_stub.py / test_sftp_paramiko.py
── test_providers_seed.py / test_payer_config_loading.py
├── src/ # React + Vite + TypeScript UI
│ ├── components/
│ │ ├── ui/ # Skeleton, EmptyState, ErrorState, FilterChips, Pagination, …
│ │ ├── ClaimDrawer/ # 12 sibling tests
│ │ ├── RemitDrawer/ # 8 sibling tests
│ │ ├── ProviderDrawer/ # right-anchored side panel
│ │ ├── AckDrawer/ # right-anchored side panel (SP28 ack-claim auto-link)
│ │ ├── inbox/ # 5-lane triage surface (SP14)
│ │ ├── drill/ # DrillStackProvider + DrillableCell + PeekModal
│ │ └── TailStatusPill.tsx # live-tail status badge + reconnect button
│ ├── pages/ # Dashboard, Claims, Remittances, Providers, ActivityLog,
│ │ # Upload, Reconciliation, Inbox, Acks, Batches, BatchDiff, Login
│ ├── pages/ # Claims, Remittances, Providers, Acks, Activity, Upload, Inbox, …
│ ├── hooks/ # useBatches, useClaims, useRemittances, useProviders, useActivity, useParse
│ │ # + useTailStream, useMergedTail (live tail triplet)
│ ├── auth/ # AuthProvider, RoleGate, api client (SP24)
├── lib/ # api.ts, format.ts, utils.ts, tail-stream.ts (NDJSON parser), inbox-api.ts
│ ├── store/ # zustand useAppStore (sample data) + useTailStore (FIFO-capped live tail slices)
│ │ # + useTailStream, useMergedTail (live tail)
│ ├── lib/ # api.ts, format.ts, utils.ts
# + tail-stream.ts (NDJSON parser)
│ ├── store/ # zustand sample-data + parsed-batches store
│ │ # + tail-store.ts (FIFO-capped live tail slices)
│ └── types/ # shared TS types
├── config/
│ └── payers.yaml # YAML-driven payer + clearhouse config (SP9)
@@ -815,91 +584,17 @@ backup API).
## 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 41 are **shipped** (last merged: SP41 in-window
rebill pipeline, `merge: SP41 inwindow-rebill-pipeline into main` at
`309e6c0`). See the [completeness
Sub-projects 2 through 15 are **shipped**. See the [completeness
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
the honest gap analysis against the industry definition of a HIPAA
clearinghouse — the short version is that the LAN-only,
single-operator, single-payer design contract is honored (auth per
SP23, encryption at rest via SQLCipher per SP12, RBAC per SP23, audit
log per SP11), and the
clearinghouse — the short version is that the local-only,
single-operator, single-payer design contract is honored, and the
items that would be needed to expand that contract (AS2/AS4, SNIP 17,
HITRUST, 276/277 status, 278 referrals, COB) are intentionally out of
scope.
Shipped sub-projects (most recent first):
- **Sub-project 22 (shipped) — Pipeline automation agent.** A
sibling project at `/Users/openclaw/dev/cyclone-pipeline` that
drives 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, and a per-run report. Pure Python 3.11+ (httpx, Playwright,
Click, pydantic v2, structlog). The 835 is not waited for inline —
it lands the following Monday — and is verified by a separate
`check-835` subcommand. Embeddable as a library for OpenClaw / Nora
agent integration. See
[Pipeline automation agent](#pipeline-automation-agent) above.
- **Sub-project 19 (shipped) — Security hardening + health probe.**
Three pure-ASGI middlewares (`BodySizeLimitMiddleware`,
`RateLimitMiddleware`, `SecurityHeadersMiddleware`) close the
completeness-review gaps §3.1.4 (no body/rate limits) and §3.1.25
(no CSP / security headers). 413/429 rejections emit a
tamper-evident `api.request_rejected` audit event (SP11 chain).
`/api/health` is now a rich subsystem snapshot — DB connectivity,
MFT scheduler state, backup scheduler state, live pubsub
subscriber counts, last batch id + timestamp. See
[Security hardening (SP19)](#security-hardening-sp19) below.
- **Sub-project 20 (shipped) — NPI checksum + Tax ID format validation.**
Pure local validators (`cyclone.npi`) — no NPPES round-trip, no IRS
e-file lookup. Catches the 99% typo case at parse time. NPI uses
CMS-published Luhn over `80840 + body` (example: `1234567893` is
valid). EIN rejects reserved prefixes (`00`, `07`, `80``89`).
Surface: `cyclone validate-npi` / `validate-tax-id` CLI subcommands,
`GET /api/admin/validate-provider`, new `R021_npi_checksum`
validator rule (warning, not error — placeholder NPIs in test
fixtures shouldn't block ingest). See
[NPI checksum + Tax ID format validation (SP20)](#npi-checksum--tax-id-format-validation-sp20)
below.
- **Sub-project 18 (shipped) — Structured JSON logging.** All logs
emitted by the API, CLI, scheduler tick loop, and backup service
flow through a `JsonFormatter` (newline-delimited JSON, ISO-8601 ms
timestamps) by default. A `PiiScrubber` filter redacts obvious PHI
(NPIs, SSNs, DOBs, patient names) from message + extras — both via
inline patterns (`npi 1881068062`) and via PHI-keyed extras
(`extra={"dob": "1980-04-12"}`). Configurable via env vars
(`CYCLONE_LOG_LEVEL`, `CYCLONE_LOG_FILE`, `CYCLONE_LOG_JSON`,
`CYCLONE_LOG_NO_PII_SCRUB`) and CLI flags
(`--log-format=json|dev`, `--log-file=…`); a tabular `CycloneDevFormatter`
is the opt-out for `tail -f` in dev. See
[Structured logging](#structured-logging-sp18) below.
- **Sub-project 17 (shipped) — Encrypted DB backups.** Automated
encrypted backups via AES-256-GCM (PBKDF2-HMAC-SHA256, 200k iters).
The operator sets a separate passphrase in the macOS Keychain
(`cyclone backup init-passphrase`); if missing, the service falls
back to deriving from the SQLCipher DB key with a WARNING. Online
backups via SQLite `.backup()`, two-step restore (`initiate`
`confirm` with one-shot 64-char hex token), retention pruning with
a 30-day default, and a tamper-evident audit chain (`db.backup_created`,
`db.backup_failed`, `db.backup_pruned`, `db.backup_restored`,
`db.backup_passphrase_set`). Backup scheduler ticks every 24h
(configurable); auto-start opt-in via `CYCLONE_BACKUP_AUTOSTART`.
Seven admin endpoints + six CLI subcommands. See
[Encrypted Backups](#encrypted-backups) below.
- **Sub-project 16 (shipped) — Live MFT polling scheduler.** asyncio
background loop polls the Gainwell MFT inbound path, downloads
new files, and routes them through the right parser (999 / 835 /
277CA / TA1). Idempotent (re-ticks skip already-processed files
via the new `processed_inbound_files` table). Crash-safe (per-file
try/except so a bad file doesn't stop the loop). Five admin
endpoints (`/api/admin/scheduler/{status,start,stop,tick,processed-files}`).
- **Sub-project 15 (shipped) — SQLCipher key rotation.** In-place
rotation via `PRAGMA rekey`, serialized through a module-level
`threading.Lock` and a SQLAlchemy `NullPool` to keep SQLCipher
@@ -916,7 +611,7 @@ Shipped sub-projects (most recent first):
- **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
actually pushes to
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/ToHPE`.
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
SFTP credentials are read from the macOS Keychain at call time.
- **Sub-project 12 (shipped) — Encryption at rest.** Optional
SQLCipher AES-256 encryption of the SQLite file, with the key
@@ -1178,9 +873,37 @@ the one-time setup recipe.
- `POST /api/clearhouse/submit` — same endpoint as SP9; the
implementation is now a real `paramiko` `SftpClient.write` to
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/ToHPE`.
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
SFTP credentials are fetched from the macOS Keychain at call time.
### SP14 endpoints (5-lane Inbox UI + acknowledge)
- `GET /api/inbox/lanes` — same shape as SP6; the `payer_rejected`
lane payload now also includes
`payer_rejected_acknowledged_at` + `payer_rejected_acknowledged_actor`
per row so the UI can badge acknowledged claims (forward-compat for
a future "Recently acknowledged" view).
- `POST /api/inbox/payer-rejected/acknowledge` — bulk-acknowledge
Payer-Rejected claims. Body: `{claim_ids: [...], actor: "..."}`.
Idempotent. Response: `200` with
`{transitioned, already_acked, not_found, not_rejected}`. Writes an
`inbox.payer_rejected_acknowledged` audit event per transition.
Acknowledgement hides a claim from the lane but never overwrites
`payer_rejected_at` / `payer_rejected_reason` /
`payer_rejected_by_277ca_id` — the original 277CA evidence stays
intact in the audit log.
### SP15 endpoints (SQLCipher key rotation)
- `POST /api/admin/db/rotate-key` — rotate the SQLCipher DB key in
place. Generates a fresh 256-bit key, writes it to the Keychain
(overwriting `cyclone.db.key`), disposes the engine, issues
`PRAGMA rekey`, verifies the schema, rebuilds the engine. Writes
a `db.key_rotated` audit event with old + new key fingerprints
and `table_count`. Returns `409` when a rotation is already in
flight, `400` when encryption is not enabled, `503` with a
`reason` on `PRAGMA rekey` or Keychain failure.
## License
No license file yet; this is internal-use software. Add a `LICENSE` file
-65
View File
@@ -1,65 +0,0 @@
# 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
View File
@@ -1,233 +0,0 @@
// 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);
});
-12
View File
@@ -1,12 +0,0 @@
.venv/
venv/
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
.ruff_cache/
tests/
docs/prodfiles/
*.production.txt
.git/
.github/
-1
View File
@@ -7,4 +7,3 @@ __pycache__/
venv/
build/
dist/
var/
-83
View File
@@ -1,83 +0,0 @@
# 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/
# Install the sftp extra alongside sqlcipher so the real-mode SFTP
# client (paramiko) is available inside the container — required by
# SP25 + SP26 for live Gainwell MFT polling.
RUN pip wheel --no-cache-dir --wheel-dir /wheels '.[sqlcipher,sftp]'
# ---------- 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,sftp]' \
&& 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"]
-10
View File
@@ -16,15 +16,6 @@ dependencies = [
"sqlalchemy>=2.0,<3",
"pyyaml>=6.0,<7",
"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]
@@ -33,7 +24,6 @@ dev = [
"pytest-cov>=4.1",
"pytest-asyncio>=0.23,<1",
"httpx>=0.27,<1",
"pytest-randomly>=4.1",
]
sqlcipher = [
# SP12: encryption at rest. Optional — without it the DB is plain SQLite.
+2 -32
View File
@@ -1,11 +1,10 @@
"""Entry point for ``python -m cyclone``.
* ``python -m cyclone`` (no args) — Click CLI (``cli.main``)
* ``python -m cyclone serve`` — start the FastAPI app on 0.0.0.0:8000
* ``python -m cyclone serve`` — start the FastAPI app on 127.0.0.1:8000
Honors the env vars:
* ``CYCLONE_HOST`` (default ``0.0.0.0`` — always bind to all interfaces)
* ``CYCLONE_PORT`` (default ``8000``)
* ``CYCLONE_RELOAD`` (default ``0``; set to ``1`` to enable uvicorn reload)
"""
@@ -17,42 +16,13 @@ import sys
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":
port = os.environ.get("CYCLONE_PORT", "8000")
# Always bind to 0.0.0.0 so the API is reachable from the
# frontend container on the compose bridge network AND from
# the host (Vite dev proxy) AND from the LAN. Network isolation
# is provided by the host firewall / compose port publishing,
# not by binding to loopback. Override with CYCLONE_HOST if you
# have a reason to restrict.
host = os.environ.get("CYCLONE_HOST", "0.0.0.0")
reload = os.environ.get("CYCLONE_RELOAD", "0") == "1"
sys.argv = [
sys.argv[0],
"cyclone.api:app",
"--host", host,
"--host", "127.0.0.1",
"--port", port,
]
if reload:
+838 -267
View File
File diff suppressed because it is too large Load Diff
+6 -30
View File
@@ -93,40 +93,19 @@ def ndjson_stream_list(
}) + "\n"
def ndjson_stream_837(
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.
"""
def ndjson_stream_837(result: ParseResult) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → claims → summary."""
envelope_obj = (
result.envelope.model_dump() if result.envelope is not None else None
)
yield (json.dumps({"type": "envelope", "data": envelope_obj}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim", "data": json.loads(claim.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")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
def ndjson_stream_835(
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.
"""
def ndjson_stream_835(result: ParseResult835) -> Iterator[bytes]:
"""Yield one JSON object per line: envelope → financial → trace → payer → payee → claim_payments → summary."""
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": "trace", "data": json.loads(result.trace.model_dump_json())}) + "\n").encode("utf-8")
@@ -134,10 +113,7 @@ def ndjson_stream_835(
yield (json.dumps({"type": "payee", "data": json.loads(result.payee.model_dump_json())}) + "\n").encode("utf-8")
for claim in result.claims:
yield (json.dumps({"type": "claim_payment", "data": json.loads(claim.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")
yield (json.dumps({"type": "summary", "data": json.loads(result.summary.model_dump_json())}) + "\n").encode("utf-8")
def strict_rewrite_837(result: ParseResult) -> ParseResult:
+1 -59
View File
@@ -1,59 +1 @@
"""Per-resource FastAPI routers.
`api.py` does `for r in routers: app.include_router(r)`. New
routers register themselves here in alphabetical order. Helpers
shared by 2+ routers live in `_shared.py` (private to the package).
Every router except ``health`` declares its own
``APIRouter(dependencies=[Depends(matrix_gate)])`` — keep that
invariant here so adding a new router doesn't silently miss the gate.
"""
from fastapi import APIRouter
from cyclone.api_routers import (
acks,
activity,
admin,
batches,
claim_acks,
claims,
clearhouse,
config,
dashboard,
eligibility,
health,
inbox,
parse,
payers,
providers,
rebill,
reconciliation,
remittances,
submission,
ta1_acks,
)
routers: list[APIRouter] = [
acks.router, # gated
activity.router, # gated
admin.router, # gated
batches.router, # gated
claim_acks.router, # gated
claims.router, # gated
clearhouse.router, # gated
config.router, # gated
dashboard.router, # gated
eligibility.router, # gated
health.router, # public — health probes must work pre-auth
inbox.router, # gated
parse.router, # gated
payers.router, # gated
providers.router, # gated
rebill.router, # gated (SP41)
reconciliation.router, # gated
remittances.router, # gated
submission.router, # gated
ta1_acks.router, # gated
]
__all__ = ["routers"]
"""Resource-group routers. Imported and registered by ``cyclone.api``."""
-273
View File
@@ -1,273 +0,0 @@
"""Cross-router helpers for the api_routers package.
Private to the package (leading underscore). Only routers in this
package import from here. Helpers graduate here when at least two
routers need them — single-router helpers stay in the router that
uses them.
Helpers currently promoted here:
- :func:`_actor_user_id` — promoted early (during SP36 Task 11
/ clearhouse extraction) because the clearhouse router needs
it and the 2 remaining call-sites in ``api.py`` (parse-999 ack
block, parse-277ca ack block) are both inside the parse
surface that Task 16 will extract. Promoting now is cheaper
than leaving a cross-module ``from cyclone.api import _actor_user_id``
that would create an import-cycle at registry load time.
- :data:`PAYER_FACTORIES` / :data:`PAYER_FACTORIES_835` — SP36
Task 16: payer config dicts lifted from ``api.py`` alongside
the ``_resolve_payer`` / ``_resolve_payer_835`` helpers that
consume them. The two helpers each touch a single ``PAYER_FACTORIES*``
dict; keeping both halves of the pair in one module removes a
circular import (parse.py → _shared._resolve_payer → api.PAYER_FACTORIES).
- :func:`_resolve_payer` / :func:`_resolve_payer_835` — used by
``parse-837`` and ``parse-835`` endpoints respectively. Promoted
in SP36 Task 16 alongside the PAYER_FACTORIES dicts.
- :func:`_transaction_set_id_from_segments` — used by
``parse-837`` and ``parse-835`` envelope guards. Promoted in
SP36 Task 16.
- :func:`_build_and_persist_ack` — used by ``parse-837`` (when
``?ack=true``). Promoted in SP36 Task 16.
- :func:`_reconciliation_summary_for_batch` — used by
``parse-835``. Promoted in SP36 Task 16.
- :func:`_ta1_synthetic_source_batch_id` — used by ``parse-ta1``.
Promoted in SP36 Task 16.
- :func:`_serialize_ta1` — used by ``parse-ta1`` to build the
raw TA1 round-trip text. Promoted in SP36 Task 16 per the
plan's "8 helpers" specification; technically a single-router
helper per D4 but moved here for symmetry with the other parse
serializers.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import HTTPException, Request
from cyclone import db
from cyclone.parsers.batch_ack_builder import build_ack_for_batch
from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.store import store
log = logging.getLogger(__name__)
# --------------------------------------------------------------------------- #
# Actor user id (SP36 Task 11 — early-promoted)
# --------------------------------------------------------------------------- #
def _actor_user_id(request: Request) -> int | None:
"""Return the acting user's id from ``request.state.user``, or None.
``get_current_user``/``matrix_gate`` populate ``request.state.user``
for both the authenticated path and the AUTH_DISABLED escape hatch.
Returns None when the state hasn't been set (e.g. background jobs
or unit tests that bypass auth). Used to stamp ``user_id`` onto
audit events without crashing the request.
"""
user = getattr(request.state, "user", None)
if user is None:
return None
return getattr(user, "id", None)
# --------------------------------------------------------------------------- #
# Payer config dicts (SP36 Task 16)
# --------------------------------------------------------------------------- #
#
# Mirror cli._PAYER_FACTORIES. Kept here (not in the parse router) so
# the ``_resolve_payer`` / ``_resolve_payer_835`` helpers can import
# their backing dicts without a circular import.
PAYER_FACTORIES: dict[str, Any] = {
"co_medicaid": PayerConfig.co_medicaid,
"generic_837p": PayerConfig.generic_837p,
}
PAYER_FACTORIES_835: dict[str, Any] = {
"co_medicaid_835": PayerConfig835.co_medicaid_835,
"generic_835": PayerConfig835.generic_835,
}
# --------------------------------------------------------------------------- #
# Payer resolution (SP36 Task 16)
# --------------------------------------------------------------------------- #
def _resolve_payer(name: str) -> PayerConfig:
if name not in PAYER_FACTORIES:
raise HTTPException(
status_code=400,
detail={
"error": "Unknown payer",
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES)}",
},
)
return PAYER_FACTORIES[name]()
def _resolve_payer_835(name: str) -> PayerConfig835:
if name not in PAYER_FACTORIES_835:
raise HTTPException(
status_code=400,
detail={
"error": "Unknown payer",
"detail": f"Unknown payer {name!r}. Choose from: {', '.join(PAYER_FACTORIES_835)}",
},
)
return PAYER_FACTORIES_835[name]()
# --------------------------------------------------------------------------- #
# Envelope detection (SP36 Task 16)
# --------------------------------------------------------------------------- #
def _transaction_set_id_from_segments(segments: list[list[str]]) -> str | None:
"""Return the ST01 transaction-set id (``"837"``, ``"835"``, ``"999"``...).
SP35 helper: scans the first few tokenized segments for the ST
segment and returns its second element (ST01). Returns None when no
ST is present — e.g. a TA1 file, which uses the bare TA1 segment
and no ST envelope. The endpoint-level envelope guards treat
``None`` as "no ST found; let the parser decide" so TA1 files
routed through the wrong endpoint still surface a parse error
rather than a misleading "expected 837p, got ''" message.
"""
for seg in segments[:5]: # ST is always the second segment after ISA
if seg and seg[0] == "ST" and len(seg) > 1:
return seg[1]
return None
# --------------------------------------------------------------------------- #
# 999 ACK builder (SP36 Task 16)
# --------------------------------------------------------------------------- #
def _build_and_persist_ack(batch_id: str) -> dict | None:
"""Build a 999 ACK for ``batch_id`` and persist the row.
Returns the ack payload dict (matches the ``/api/parse-999``
response shape so the JSON and NDJSON clients can share the
schema) or None if the build failed. The build is fail-soft:
errors are logged but never abort the 837 ingest, because the
user-visible 837 result is still correct.
"""
try:
ack_result = build_ack_for_batch(batch_id)
except Exception:
log.exception("build_ack_for_batch failed for %s", batch_id)
return None
fg = ack_result.functional_group_acks[0] if ack_result.functional_group_acks else None
if fg is None:
return None
raw_text = serialize_999(ack_result, interchange_control_number=ack_result.envelope.control_number)
row = store.add_ack(
source_batch_id=batch_id,
accepted_count=fg.accepted_count,
rejected_count=fg.rejected_count,
received_count=fg.received_count,
ack_code=fg.ack_code,
raw_json=json.loads(ack_result.model_dump_json()),
)
return {
"id": row.id,
"accepted_count": fg.accepted_count,
"rejected_count": fg.rejected_count,
"received_count": fg.received_count,
"ack_code": fg.ack_code,
"source_batch_id": batch_id,
"raw_999_text": raw_text,
}
# --------------------------------------------------------------------------- #
# Reconciliation summary (SP36 Task 16)
# --------------------------------------------------------------------------- #
def _reconciliation_summary_for_batch(batch_id: str) -> dict:
"""Return ``{matched, unmatched_claims, unmatched_remittances, skipped}`` for a batch.
Reads from the DB after ``store.add()`` has already run reconciliation
synchronously (SP27 Task 10: ``reconcile.run(s, batch_id)`` inside the
ingest session, before commit). Counts are observed at this moment;
a subsequent manual match/unmatch will not be reflected until the
next request.
``skipped`` is reserved for future use — the orchestrator tracks
skipped claims internally but does not surface a queryable count.
"""
from sqlalchemy import func, select
from cyclone.db import Match, Remittance
with db.SessionLocal()() as s:
matched = s.execute(
select(func.count(Match.id)).where(
Match.remittance_id.in_(
select(Remittance.id).where(Remittance.batch_id == batch_id)
)
)
).scalar_one()
# Pull unmatched via the store (small result set; cheap).
unmatched = store.list_unmatched(kind="both")
return {
"matched": matched,
"unmatched_claims": len(unmatched["claims"]),
"unmatched_remittances": len(unmatched["remittances"]),
"skipped": 0, # reserved — T10 does not persist a skipped count
}
# --------------------------------------------------------------------------- #
# TA1 synthetic source batch id (SP36 Task 16)
# --------------------------------------------------------------------------- #
def _ta1_synthetic_source_batch_id(interchange_control_number: str) -> str:
"""Return a synthetic ``batches.id`` for a received TA1 with no source batch.
Mirrors :func:`_ack_synthetic_source_batch_id` (in
``cyclone.handlers._ack_id``). The ta1_acks.source_batch_id
FK requires a row in batches; for received TA1s we synthesize an
id of the form ``TA1-<ISA13>``. The row is NOT created in batches
(same FK-is-no-op convention as the 999 path).
"""
return f"TA1-{(interchange_control_number or '').strip() or '000000001'}"
# --------------------------------------------------------------------------- #
# TA1 serializer (SP36 Task 16)
# --------------------------------------------------------------------------- #
def _serialize_ta1(result) -> str:
"""Render a TA1 file from a ParseResultTa1 for the ``raw_ta1_text`` field.
Mirrors what the parser consumed: ISA envelope → TA1 → IEA. We
rebuild minimal ISA fields from the envelope plus the TA1 segment
verbatim. The serializer is intentionally tiny — TA1 has no GS/ST,
so there's no functional-group structure to round-trip.
"""
ta1 = result.ta1
parts = [
f"TA1*{ta1.control_number}*{ta1.interchange_date.strftime('%y%m%d') if ta1.interchange_date else ''}*"
f"{ta1.interchange_time or ''}*{ta1.ack_code}*{ta1.note_code or ''}*"
f"{ta1.ack_generated_date.strftime('%y%m%d') if ta1.ack_generated_date else ''}",
]
return "~".join(parts) + "~"
+35 -154
View File
@@ -1,86 +1,64 @@
"""``/api/acks`` and ``/api/277ca-acks`` — list, detail, and live-tail.
"""``/api/acks`` — list & detail endpoints for the 999 ACK inbox.
999 ACKs are the persisted acknowledgment rows produced by
These are the persisted acknowledgment rows produced by
``POST /api/parse-999``. The frontend ``useAcks`` hook re-shapes the
list payload to its ``Ack`` interface in ``src/types/index.ts``.
277CA ACKs are the persisted Claim Acknowledgment rows produced by
``POST /api/parse-277ca``. The frontend ``useAcks`` hook treats them
as a second source alongside 999 — the row shape is normalised in
``cyclone.store.ui.to_ui_two77ca_ack``.
The 999 detail endpoint returns the full ``raw_json`` payload plus
the regenerated ``raw_999_text`` so the UI can show "view source"
without a second round-trip.
SP25: ``/api/acks/stream`` joins the live-tail triplet — the Acks
page mounts ``useTailStream("acks")`` and ``useMergedTail("acks", …)``
to see new 999 acks the moment they land (whether from the SFTP
poller or a manual upload).
The detail endpoint returns the full ``raw_json`` payload plus the
regenerated ``raw_999_text`` so the UI can show "view source" without a
second round-trip.
"""
from __future__ import annotations
import logging
from typing import Any, AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from cyclone import db
from cyclone.api_helpers import (
ndjson_line,
ndjson_stream_list,
tail_events,
wants_ndjson,
)
from cyclone.auth.deps import matrix_gate
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.parsers.models_999 import ParseResult999
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.pubsub import EventBus
from cyclone.store import store, to_ui_ack, to_ui_two77ca_ack
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
log = logging.getLogger(__name__)
# SP25: ``_ack_to_ui`` moved to ``cyclone.store.ui.to_ui_ack`` so the
# live-tail event payload (``ack_received``) matches the list endpoint
# shape byte-for-byte.
def _ack_to_ui(row) -> dict:
"""Map an ``Ack`` ORM row to the UI shape used by ``/api/acks``.
Field names match the rest of the Cyclone API (snake_case). The
frontend ``useAcks`` hook re-shapes this to the camelCase ``Ack``
interface in ``src/types/index.ts``.
"""
return {
"id": row.id,
"source_batch_id": row.source_batch_id,
"accepted_count": row.accepted_count,
"rejected_count": row.rejected_count,
"received_count": row.received_count,
"ack_code": row.ack_code,
"parsed_at": (
row.parsed_at.isoformat().replace("+00:00", "Z")
if row.parsed_at is not None
else ""
),
}
@router.get("/api/acks")
def list_acks_endpoint(
request: Request,
limit: int = Query(100, ge=1, le=5000),
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
) -> Any:
"""Return the list of persisted 999 ACKs, newest first.
``limit`` caps the page size; ``offset`` lets the UI walk the
full set without holding it all in memory. ``aggregates`` is
summed over the *full* row set (not the page) so the KPI strip
on the Acks page reflects every persisted 999, not just the
visible 50. Without server-side aggregates the page would
silently under-report (silent-failure mode) once the row count
exceeds the page size.
"""
"""Return the list of persisted 999 ACKs, newest first."""
rows = store.list_acks()
items = [to_ui_ack(r) for r in rows[offset : offset + limit]]
items = [_ack_to_ui(r) for r in rows[:limit]]
total = len(rows)
returned = len(items)
has_more = offset + returned < total
aggregates = {
"accepted_count": sum(r.accepted_count or 0 for r in rows),
"rejected_count": sum(r.rejected_count or 0 for r in rows),
"received_count": sum(r.received_count or 0 for r in rows),
}
# SP28: batch-fetch linked_claim_ids per ack row in one query to
# avoid N+1 — see ``find_linked_claim_ids_for_acks`` below.
ack_ids = [r.id for r in rows]
linked_map = _find_linked_claim_ids_for_acks(ack_ids, kind="999")
for item, aid in zip(items, ack_ids[offset : offset + limit]):
item["linked_claim_ids"] = linked_map.get(aid, [])
has_more = total > returned
if wants_ndjson(request):
return StreamingResponse(
ndjson_stream_list(items, total, returned, has_more),
@@ -91,68 +69,9 @@ def list_acks_endpoint(
"total": total,
"returned": returned,
"has_more": has_more,
"aggregates": aggregates,
}
def _find_linked_claim_ids_for_acks(
ack_ids: list[int], *, kind: str
) -> dict[int, list[str]]:
"""Batch-fetch {ack_id: [claim_id, …]} for the listed ack rows.
One SELECT against ``claim_acks`` keyed on the page's ack_ids —
avoids an N+1 round-trip when the page renders the
"🔗 N claims" badge per row. Used by the 999 / TA1 / 277CA
list endpoints. Returns a ``{ack_id: [claim_id]}`` map; acks
with no links map to ``[]`` (default).
"""
out: dict[int, list[str]] = {aid: [] for aid in ack_ids}
if not ack_ids:
return out
with db.SessionLocal()() as s:
rows = (
s.query(db.ClaimAck.ack_id, db.ClaimAck.claim_id)
.filter(
db.ClaimAck.ack_kind == kind,
db.ClaimAck.ack_id.in_(ack_ids),
db.ClaimAck.claim_id.isnot(None),
)
.all()
)
for ack_id, claim_id in rows:
out[ack_id].append(claim_id)
return out
@router.get("/api/acks/stream")
async def acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream 999 ACKs as NDJSON: snapshot first, then live events.
SP25: this endpoint joins the live-tail triplet — subscribes to
``ack_received`` and emits one ``item`` per snapshot row plus a
single ``snapshot_end`` line, then forwards live events from
the bus. Matches the wire format used by ``/api/claims/stream``,
``/api/remittances/stream``, and ``/api/activity/stream``.
NOTE: registered BEFORE ``/api/acks/{ack_id}`` so the literal
``stream`` path segment doesn't get matched as an ack id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_acks()[:limit]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_ack(row)})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in tail_events(request, bus, ["ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/acks/{ack_id}")
def get_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted ACK row with its parsed detail.
@@ -167,7 +86,7 @@ def get_ack_endpoint(ack_id: int) -> dict:
status_code=404,
detail={"error": "Not found", "detail": f"Ack {ack_id} not found"},
)
body = to_ui_ack(row)
body = _ack_to_ui(row)
body["raw_json"] = row.raw_json
# Regenerate the X12 text from raw_json so the operator can download
# the actual 999 file. (SP3 P3 follow-up: list endpoint doesn't carry
@@ -183,41 +102,3 @@ def get_ack_endpoint(ack_id: int) -> dict:
else:
body["raw_999_text"] = None
return body
# -- 277CA ACKs -------------------------------------------------------------
# SP36 Task 2: these two endpoints moved here from api.py (lines 1278-1318).
# 277CA ACKs share the same shape-of-life contract as 999 ACKs: persisted by
# a parse endpoint, listed newest-first, detail returns raw_json so the UI
# can show "view source" without a round-trip.
@router.get("/api/277ca-acks")
def list_277ca_acks_endpoint(
limit: int = Query(100, ge=1, le=5000),
) -> Any:
"""Return the list of persisted 277CA ACKs, newest first.
SP28: each item gains ``linked_claim_ids`` (batch-fetched via
the shared ``_find_linked_claim_ids_for_acks`` helper — one
SELECT keyed on ``ack_kind``, no N+1) so the Acks page row
can render the "🔗 N claims" badge inline.
"""
rows = store.list_277ca_acks()
items = [to_ui_two77ca_ack(r) for r in rows[:limit]]
ack_ids = [r.id for r in rows]
linked_map = _find_linked_claim_ids_for_acks(ack_ids, kind="277ca")
for item, aid in zip(items, ack_ids[:limit]):
item["linked_claim_ids"] = linked_map.get(aid, [])
return {"total": len(rows), "items": items}
@router.get("/api/277ca-acks/{ack_id}")
def get_277ca_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted 277CA ACK row with its parsed detail."""
row = store.get_277ca_ack(ack_id)
if row is None:
raise HTTPException(status_code=404, detail=f"277CA ACK {ack_id} not found")
body = to_ui_two77ca_ack(row)
body["raw_json"] = row.raw_json
return body
+35 -40
View File
@@ -1,50 +1,49 @@
"""``/api/activity`` and ``/api/activity/stream`` — operator-facing event log.
"""``/api/activity`` — list & live-tail of recorded Activity events.
Two endpoints, both gated by ``matrix_gate``:
The Activity log is the cross-resource audit feed: every parser write,
inbox state change, match/dismiss/resubmit, clearhouse submission, etc.
records one row keyed by ``kind``. The list endpoint returns the most
recent ``limit`` events with optional ``kind`` / ``since`` filters; the
stream endpoint emits a snapshot followed by live updates from the
``EventBus``.
- ``GET /api/activity`` — paginated event list with ``kind`` /
``since`` filters, plus an NDJSON variant when the caller sends
``Accept: application/x-ndjson``.
- ``GET /api/activity/stream`` — NDJSON live-tail: snapshot of the
most recent N events, then ``activity_recorded`` events as they
hit the store. Default ``limit`` is 50 (smaller than the list
endpoint's 200) because activity is high-volume — callers usually
want the most recent handful, not a full replay.
The snapshot halves of ``/api/activity`` and ``/api/activity/stream``
share the same in-memory filter logic (``kind`` + ``since``) so the
two endpoints are interchangeable for the snapshot half.
SP36 Task 10: this block moved here from ``api.py:2606`` (the
``/api/activity*`` pair).
The default ``limit`` on the list endpoint is 200 (matches the prior
inline behavior); the stream endpoint defaults to 50 because activity is
high-volume — callers usually want the most recent handful, not a full
replay.
"""
from __future__ import annotations
from typing import Any, AsyncIterator
from fastapi import APIRouter, Depends, Query, Request
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from cyclone.api_helpers import (
ndjson_line as _ndjson_line,
ndjson_stream_list as _ndjson_stream_list,
tail_events as _tail_events,
wants_ndjson as _wants_ndjson,
ndjson_line,
ndjson_stream_list,
tail_events,
wants_ndjson,
)
from cyclone.auth.deps import matrix_gate
from cyclone.pubsub import EventBus
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/activity")
def list_activity(
def list_activity_endpoint(
request: Request,
kind: str | None = Query(None),
since: str | None = Query(None),
limit: int = Query(200, ge=1, le=5000),
limit: int = Query(200, ge=1, le=500),
) -> Any:
"""Return the recent Activity log, newest first.
Optional ``kind`` filters by ``event["kind"]`` (e.g. ``claim_submitted``).
Optional ``since`` is an inclusive ISO timestamp lower bound applied
to ``event["timestamp"]``.
"""
events = store.recent_activity(limit=limit)
if kind is not None:
events = [e for e in events if e["kind"] == kind]
@@ -52,9 +51,9 @@ def list_activity(
events = [e for e in events if e["timestamp"] >= since]
total = len(events)
has_more = False
if _wants_ndjson(request):
if wants_ndjson(request):
return StreamingResponse(
_ndjson_stream_list(events, total, total, has_more),
ndjson_stream_list(events, total, total, has_more),
media_type="application/x-ndjson",
)
return {
@@ -66,37 +65,33 @@ def list_activity(
@router.get("/api/activity/stream")
async def activity_stream(
async def activity_stream_endpoint(
request: Request,
kind: str | None = Query(None),
since: str | None = Query(None),
limit: int = Query(50, ge=1, le=5000),
limit: int = Query(50, ge=1, le=500),
) -> StreamingResponse:
"""Stream Activity events as NDJSON: snapshot first, then live events.
Subscribes to ``activity_recorded``. Default ``limit`` is 50
(smaller than the list endpoint's 200) because activity is
high-volume — callers usually want the most recent handful, not a
full replay.
Subscribes to ``activity_recorded``. The snapshot half reuses the
same in-memory filter as ``list_activity`` so the two endpoints are
interchangeable for the snapshot half.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
# Snapshot reuses the same in-memory filter as ``list_activity``
# so the two endpoints are interchangeable for the snapshot
# half.
events = store.recent_activity(limit=limit)
if kind is not None:
events = [e for e in events if e["kind"] == kind]
if since is not None:
events = [e for e in events if e["timestamp"] >= since]
for ev in events:
yield _ndjson_line({"type": "item", "data": ev})
yield _ndjson_line({
yield ndjson_line({"type": "item", "data": ev})
yield ndjson_line({
"type": "snapshot_end", "data": {"count": len(events)},
})
async for chunk in _tail_events(request, bus, ["activity_recorded"]):
async for chunk in tail_events(request, bus, ["activity_recorded"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
+109 -659
View File
@@ -1,95 +1,40 @@
"""``/api/admin/*`` — operator-only endpoints.
"""``/api/admin/*`` — operator-facing admin surface (SP11 + SP15).
The /api/admin namespace covers:
Four endpoints:
* **audit-log** (SP11): list + chain-verify the tamper-evident log
* **db/rotate-key**: SQLCipher key rotation (SP12)
* **backup**: create / list / status / verify / restore / prune / scheduler (SP15)
* **scheduler**: backup & main scheduler control + processed-files log
* **reload-config**: hot-reload ``config/payers.yaml`` after edits
* **validate-provider**: NPI + Tax ID liveness probe (SP20)
All routes on this router are gated by ``matrix_gate`` — declared
once on the ``APIRouter`` constructor. ``/api/admin/validate-provider``
lives here too because it's an admin-shaped read-only probe.
SP36 Task 3: the 20 admin endpoints formerly in ``cyclone.api``
(audit-log ×2, db/rotate-key ×1, backup ×10, scheduler ×6,
reload-config ×1) moved here from ``api.py:3321-4052`` and
``api.py:4269-4276``. The non-admin ``/api/config/*`` and
``/api/payers/{id}/summary`` routes that bracketed those blocks
stay in ``api.py`` for now — they're extracted in Tasks 5 & 6.
* ``GET /api/admin/audit-log`` — list audit-log rows with optional
``entity_type`` / ``entity_id`` / ``event_type`` filters (SP11).
* ``GET /api/admin/audit-log/verify`` — walk the audit-log hash chain
and verify every row (SP11).
* ``POST /api/admin/db/rotate-key`` — SP15 SQLCipher key rotation.
Re-encrypts the DB in place with a fresh key, updates the Keychain.
Protected by a module-level ``threading.Lock`` so two concurrent
rotations can't race (a single process restart resets the lock —
intentional, the next start-up opens with whatever key is in the
Keychain).
* ``POST /api/admin/reload-config`` — re-read ``config/payers.yaml``
and revalidate; returns the number of configs loaded.
"""
from __future__ import annotations
import json
import logging
import threading
import time
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import JSONResponse
from fastapi import APIRouter, HTTPException, Query
from cyclone import db, edifabric
from cyclone.audit_log import verify_chain
from cyclone.auth.deps import matrix_gate
from cyclone.clearhouse import InboundFile
from cyclone.npi import is_valid_npi, is_valid_tax_id, normalize_tax_id
router = APIRouter(dependencies=[Depends(matrix_gate)])
from cyclone import db, db_crypto, secrets
from cyclone.audit_log import AuditEvent, append_event, verify_chain
log = logging.getLogger(__name__)
router = APIRouter()
@router.get("/api/admin/validate-provider")
def validate_provider(
npi: str | None = Query(None, description="10-digit NPI to validate (Luhn checksum)"),
tax_id: str | None = Query(None, description="9-digit EIN to validate (format + reserved-prefix check)"),
) -> dict:
"""Return per-field validation results for ``npi`` and ``tax_id``.
Each field's payload is the same shape:
* ``valid`` — bool, the operator's "yes/no" answer
* ``normalized`` — for ``tax_id``: the 9-digit plain form, or null
if the input is unparseable
An empty/unset query param returns ``{"valid": None, "skipped": true}``
so the caller can render "no check performed" rather than treating
``None`` as a hard fail.
"""
result: dict = {}
if npi is None or npi == "":
result["npi"] = {"valid": None, "skipped": True}
else:
result["npi"] = {
"valid": is_valid_npi(npi),
"skipped": False,
}
if tax_id is None or tax_id == "":
result["tax_id"] = {"valid": None, "skipped": True, "normalized": None}
else:
normalized = normalize_tax_id(tax_id)
result["tax_id"] = {
"valid": is_valid_tax_id(tax_id),
"skipped": False,
"normalized": normalized,
}
return result
# --------------------------------------------------------------------------- #
# SP36 Task 3: the 20 admin endpoints below were moved here from api.py. #
# --------------------------------------------------------------------------- #
# --------------------------------------------------------------------------- #
# SP11: tamper-evident audit log (admin)
# --------------------------------------------------------------------------- #
# Module-level lock: protects against concurrent key rotations within
# the same process. A process restart resets it (intentional — the
# operator's next start-up opens with whatever key is in the Keychain).
_db_rotate_lock = threading.Lock()
@router.get("/api/admin/audit-log")
@@ -153,26 +98,6 @@ def verify_audit_log_endpoint() -> Any:
}
# ---------------------------------------------------------------------------
# SP15: SQLCipher key rotation
#
# Re-encrypts the DB in place with a fresh key, then updates the
# Keychain so subsequent connections open with the new key. This is
# a 1-time operation per rotation; for routine read/write the rest
# of the API is unchanged.
#
# Concurrency: the rotation holds a module-level lock so two
# concurrent requests can't race and end up with mismatched Keychain
# + DB. The lock is a simple threading.Lock; a process restart
# resets it (intentional — the operator's next start-up opens with
# whatever key is in the Keychain).
# ---------------------------------------------------------------------------
from cyclone import db_crypto as _db_crypto
from cyclone import secrets as _secrets
_db_rotate_lock = threading.Lock()
@router.post("/api/admin/db/rotate-key")
def rotate_db_key_endpoint(body: dict | None = None) -> Any:
"""Generate a fresh DB key, re-encrypt the DB, update the Keychain.
@@ -199,7 +124,7 @@ def rotate_db_key_endpoint(body: dict | None = None) -> Any:
actor = body.get("actor") or "operator"
reason = body.get("reason") or ""
if not _db_crypto.is_encryption_enabled():
if not db_crypto.is_encryption_enabled():
raise HTTPException(
status_code=400,
detail="encryption not enabled (sqlcipher3 missing or no Keychain key)",
@@ -214,15 +139,15 @@ def rotate_db_key_endpoint(body: dict | None = None) -> Any:
)
try:
url = db._resolve_url()
old_key = _db_crypto.get_db_key()
old_key = db_crypto.get_db_key()
if not old_key:
raise HTTPException(
status_code=400,
detail="no DB key in Keychain; cannot rotate",
)
new_key = _db_crypto.generate_db_key()
result = _db_crypto.rotate_db_key(
new_key = db_crypto.generate_db_key()
result = db_crypto.rotate_db_key(
url=url, old_key=old_key, new_key=new_key,
)
if not result.ok:
@@ -244,7 +169,7 @@ def rotate_db_key_endpoint(body: dict | None = None) -> Any:
# Rekey succeeded. Now update the Keychain. If this fails
# the DB is locked behind the new key — operator must
# restore the old key manually.
if not _secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT, new_key):
if not secrets.set_secret(db_crypto.KEYCHAIN_ACCOUNT, new_key):
log.error("Keychain update failed after successful rekey!")
raise HTTPException(
status_code=503,
@@ -265,7 +190,7 @@ def rotate_db_key_endpoint(body: dict | None = None) -> Any:
# Store the old key in the "previous" account for a grace
# period so the operator can roll back if they discover the
# new key is broken (e.g. the Keychain entry got truncated).
_secrets.set_secret(_db_crypto.KEYCHAIN_ACCOUNT_PREVIOUS, old_key)
secrets.set_secret(db_crypto.KEYCHAIN_ACCOUNT_PREVIOUS, old_key)
# Rebuild the engine so subsequent connections use the new
# key. dispose_engine() closes every pooled connection that
@@ -277,7 +202,6 @@ def rotate_db_key_endpoint(body: dict | None = None) -> Any:
# rebuilt so the audit event is written with the new key —
# proving that the new key works for new writes.
try:
from cyclone.audit_log import append_event, AuditEvent
with db.SessionLocal()() as s:
append_event(s, AuditEvent(
event_type="db.key_rotated",
@@ -307,517 +231,6 @@ def rotate_db_key_endpoint(body: dict | None = None) -> Any:
_db_rotate_lock.release()
# ---------------------------------------------------------------------------
# SP17: encrypted DB backups (admin)
#
# The actual encryption + lifecycle lives in :mod:`cyclone.backup` and
# :mod:`cyclone.backup_service`. The scheduler (separate from the
# MFT scheduler) lives in :mod:`cyclone.backup_scheduler`. These
# endpoints expose the operator's manual controls plus a tick for
# "take a backup right now."
#
# Restore is intentionally two-step: an idle browser tab can't nuke
# the live DB. The first call returns a ``restore_token`` (a one-shot
# 64-char hex) and a preview of the backup's fingerprint + table
# count plus the live DB's. The second call with the token performs
# the actual swap.
# ---------------------------------------------------------------------------
from cyclone import backup_service as _backup_svc_mod
from cyclone import backup_scheduler as _backup_sched_mod
def _backup_or_503():
try:
return _backup_svc_mod.get_backup_service()
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
@router.post("/api/admin/backup/create")
def backup_create() -> Any:
"""Take an encrypted backup right now. Returns the new backup metadata."""
from cyclone import audit_log as _audit
svc = _backup_or_503()
try:
result = svc.create_now()
except Exception as exc: # noqa: BLE001
# Surface a 503 with the reason so the operator sees what
# went wrong without grepping server logs.
raise HTTPException(
status_code=503,
detail=f"backup failed: {type(exc).__name__}: {exc}",
)
# Audit the create. Best-effort; failure here doesn't roll back
# the backup (already on disk).
try:
with db.SessionLocal()() as s:
_audit.append_event(s, _audit.AuditEvent(
event_type="db.backup_created",
entity_type="database",
entity_id="cyclone.db",
actor="operator",
payload={
"backup_id": result.backup.id,
"db_fingerprint": result.backup.db_fingerprint,
"table_count": result.backup.table_count,
"triggered_by": "api",
},
))
s.commit()
except Exception as exc: # noqa: BLE001
log.warning("could not write backup_created audit event: %s", exc)
return {
"ok": True,
"backup": {
"id": result.backup.id,
"filename": result.backup.filename,
"size_bytes": result.backup.size_bytes,
"db_fingerprint": result.backup.db_fingerprint,
"table_count": result.backup.table_count,
"created_at": result.backup.created_at.isoformat(),
"key_fingerprint": result.backup.key_fingerprint,
},
"sidecar": {
"format_version": result.sidecar.format_version,
"kdf": result.sidecar.kdf,
"kdf_iterations": result.sidecar.kdf_iterations,
"cipher": result.sidecar.cipher,
},
}
@router.get("/api/admin/backup/list")
def backup_list(
limit: int = Query(default=100, ge=1, le=1000),
status: str | None = Query(default=None),
) -> Any:
"""List ``db_backups`` rows, newest first. Filters by status."""
svc = _backup_or_503()
rows = svc.list_backups(limit=limit, status=status)
return {
"count": len(rows),
"files": [
{
"id": r.id,
"filename": r.filename,
"backup_dir": r.backup_dir,
"size_bytes": r.size_bytes,
"db_fingerprint": r.db_fingerprint,
"table_count": r.table_count,
"created_at": r.created_at.isoformat() if r.created_at else None,
"completed_at": r.completed_at.isoformat() if r.completed_at else None,
"status": r.status,
"error_message": r.error_message,
"key_fingerprint": r.key_fingerprint,
}
for r in rows
],
}
@router.get("/api/admin/backup/status")
def backup_status() -> Any:
"""Snapshot of the backup subsystem (counts, disk usage, last run)."""
svc = _backup_or_503()
snap = svc.status()
# Also include the BackupScheduler's snapshot if configured.
try:
sched = _backup_sched_mod.get_backup_scheduler()
snap["scheduler"] = sched.status().as_dict()
except RuntimeError:
snap["scheduler"] = None
return snap
@router.post("/api/admin/backup/{backup_id}/verify")
def backup_verify(backup_id: int) -> Any:
"""Decrypt + checksum-verify a backup against its sidecar."""
svc = _backup_or_503()
try:
v = svc.verify(backup_id)
except _backup_svc_mod.BackupError as exc:
raise HTTPException(status_code=404, detail=str(exc))
return {
"backup_id": v.backup_id,
"filename": v.filename,
"ok": v.ok,
"expected_fingerprint": v.expected_fingerprint,
"actual_fingerprint": v.actual_fingerprint,
"table_count": v.table_count,
"reason": v.reason,
}
@router.post("/api/admin/backup/{backup_id}/restore/initiate")
def backup_restore_initiate(backup_id: int) -> Any:
"""First step of the two-step restore. Returns a ``restore_token``."""
svc = _backup_or_503()
try:
init = svc.restore_initiate(backup_id)
except _backup_svc_mod.BackupError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return {
"backup_id": init.backup_id,
"filename": init.filename,
"size_bytes": init.size_bytes,
"restore_token": init.restore_token,
"expires_at": init.expires_at.isoformat(),
"preview": {
"backup_db_fingerprint": init.db_fingerprint,
"backup_table_count": init.table_count,
"current_db_fingerprint": init.current_db_fingerprint,
"current_table_count": init.current_table_count,
},
"warning": (
"Confirming will dispose the live engine and replace the DB "
"file with the backup. In-flight requests will error. "
"Re-issue the call with the restore_token within 5 minutes."
),
}
@router.post("/api/admin/backup/{backup_id}/restore/confirm")
def backup_restore_confirm(
backup_id: int,
body: dict | None = None,
) -> Any:
"""Second step of the two-step restore. Performs the swap."""
body = body or {}
token = body.get("restore_token")
if not token or not isinstance(token, str):
raise HTTPException(
status_code=400,
detail="missing or invalid restore_token in request body",
)
actor = body.get("actor") or "operator"
svc = _backup_or_503()
try:
result = svc.restore_confirm(backup_id, token, actor=actor)
except _backup_svc_mod.BackupError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# Audit the restore. Best-effort.
try:
from cyclone import audit_log as _audit
with db.SessionLocal()() as s:
_audit.append_event(s, _audit.AuditEvent(
event_type="db.backup_restored",
entity_type="database",
entity_id="cyclone.db",
actor=actor,
payload={
"backup_id": result.backup_id,
"filename": result.filename,
"restored_from_fingerprint": result.restored_from_fingerprint,
"new_db_fingerprint": result.new_db_fingerprint,
"restored_at": result.restored_at.isoformat(),
},
))
s.commit()
except Exception as exc: # noqa: BLE001
log.warning("could not write backup_restored audit event: %s", exc)
return {
"ok": True,
"backup_id": result.backup_id,
"filename": result.filename,
"restored_from_fingerprint": result.restored_from_fingerprint,
"restored_at": result.restored_at.isoformat(),
"new_db_fingerprint": result.new_db_fingerprint,
}
@router.post("/api/admin/backup/prune")
def backup_prune() -> Any:
"""Apply the retention policy now. Returns the deleted paths."""
from cyclone import audit_log as _audit
svc = _backup_or_503()
deleted = svc.prune()
actor = "operator"
if deleted:
try:
with db.SessionLocal()() as s:
_audit.append_event(s, _audit.AuditEvent(
event_type="db.backup_pruned",
entity_type="database",
entity_id="cyclone.db",
actor=actor,
payload={"deleted_paths": deleted},
))
s.commit()
except Exception as exc: # noqa: BLE001
log.warning("could not write backup_pruned audit event: %s", exc)
return {"ok": True, "deleted_count": len(deleted), "deleted_paths": deleted}
# ---------------------------------------------------------------------------
# SP17: backup scheduler (admin)
# ---------------------------------------------------------------------------
@router.post("/api/admin/backup/scheduler/start")
async def backup_scheduler_start() -> Any:
"""Begin the backup scheduler loop."""
try:
sched = _backup_sched_mod.get_backup_scheduler()
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
await sched.start()
return {"status": sched.status().as_dict()}
@router.post("/api/admin/backup/scheduler/stop")
async def backup_scheduler_stop() -> Any:
"""Stop the backup scheduler loop."""
try:
sched = _backup_sched_mod.get_backup_scheduler()
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
await sched.stop()
return {"status": sched.status().as_dict()}
@router.post("/api/admin/backup/scheduler/tick")
async def backup_scheduler_tick() -> Any:
"""Run one backup tick now (create + prune + audit)."""
try:
sched = _backup_sched_mod.get_backup_scheduler()
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
result = await sched.tick()
return {"ok": result.ok, "tick": result.as_dict()}
# ---------------------------------------------------------------------------
# SP16: live MFT polling scheduler (admin)
#
# The scheduler lives in :mod:`cyclone.scheduler` and is configured by
# the lifespan handler. The endpoints below expose start / stop /
# one-shot tick / status / history so an operator (or a cron job)
# can drive the scheduler without touching the DB.
#
# Note: the scheduler is OFF by default. Auto-start is opt-in via
# ``CYCLONE_SCHEDULER_AUTOSTART=true`` at launch. These endpoints
# are the operator's manual controls.
# ---------------------------------------------------------------------------
from cyclone import scheduler as _scheduler_mod
def _scheduler_or_503():
"""Return the configured scheduler or raise 503."""
try:
return _scheduler_mod.get_scheduler()
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc))
@router.post("/api/admin/scheduler/start")
async def scheduler_start() -> Any:
"""Begin polling the MFT inbound path every poll_interval_seconds."""
sched = _scheduler_or_503()
await sched.start()
return {"status": sched.status().as_dict()}
@router.post("/api/admin/scheduler/stop")
async def scheduler_stop() -> Any:
"""Stop polling. Waits up to 30s for the current tick to finish."""
sched = _scheduler_or_503()
await sched.stop()
return {"status": sched.status().as_dict()}
@router.post("/api/admin/scheduler/tick")
async def scheduler_tick() -> Any:
"""Run a single poll cycle synchronously and return the result.
Useful for: forcing a poll without waiting for the next interval;
verifying SFTP connectivity; running a one-shot import from the
CLI (``curl -X POST .../api/admin/scheduler/tick``).
"""
sched = _scheduler_or_503()
result = await sched.tick()
return {"ok": True, "tick": result.as_dict()}
@router.post("/api/admin/scheduler/pull-inbound")
async def scheduler_pull_inbound(
date: str = Query(
..., pattern=r"^\d{8}$",
description="Date filter as YYYYMMDD; only filenames whose 8-digit "
"timestamp (the 9th positional group in the inbound "
"filename) matches are downloaded and processed.",
),
file_types: str | None = Query(
default=None,
description="Optional comma-separated whitelist of file_types "
"(999, TA1, 277, 277CA, 835). Defaults to 999+TA1.",
),
limit: int = Query(default=2000, ge=1, le=10000),
) -> Any:
"""Targeted pull: list, filter to a date, download, and process.
Bypasses the alphabetical full-listing pass. Workflow:
1. ``SftpClient.list_inbound_names()`` — sub-second metadata-only
listing of the inbound MFT dir (skips ``*_warn.txt``).
2. Client-side filter: keep files whose 8-digit timestamp
substring equals ``date`` and whose ``file_type`` is in the
allowlist.
3. ``SftpClient.download_inbound(f)`` for each — fetches bytes
into the local cache.
4. ``Scheduler.process_inbound_files(files)`` — runs the same
per-file pipeline as a regular tick (already-processed files
are deduped via ``processed_inbound_files``).
Use this for the daily "process today's 999s" workflow without
paying the cost of downloading the full inbound set.
Returns ``{"ok": True, "summary": {...}}`` with
``listed / matched / downloaded / processed / skipped / errored``
counters and the date / file_type filters applied.
"""
from cyclone.clearhouse import SftpClient
from cyclone.edi.filenames import (
ALLOWED_FILE_TYPES,
parse_inbound_filename,
)
from cyclone.providers import SftpBlock
sched = _scheduler_or_503()
block: SftpBlock = sched._sftp_block # noqa: SLF001 — internal but stable
client = SftpClient(block)
if file_types:
wanted = {t.strip().upper() for t in file_types.split(",") if t.strip()}
unknown = wanted - ALLOWED_FILE_TYPES
if unknown:
raise HTTPException(
status_code=400,
detail=f"file_types {sorted(unknown)!r} not in "
f"{sorted(ALLOWED_FILE_TYPES)}",
)
else:
wanted = {"999", "TA1"} # daily default — what the operator needs
started = time.monotonic()
try:
# Single SFTP listdir — fast, no download.
all_files = await asyncio.to_thread(client.list_inbound_names)
except Exception as exc:
log.exception("SFTP list_inbound_names failed")
raise HTTPException(
status_code=502,
detail=f"SFTP list failed: {type(exc).__name__}: {exc}",
) from exc
listed = len(all_files)
matched: list[InboundFile] = []
for f in all_files:
if f.name.find(date) == -1:
continue
try:
parsed = parse_inbound_filename(f.name)
except ValueError:
continue
if parsed.file_type not in wanted:
continue
matched.append(f)
if len(matched) >= limit:
break
# Download in parallel-ish via to_thread (SftpClient serializes per
# connection; the overhead is dominated by the SFTP round trip).
downloaded = 0
download_errors: list[str] = []
for f in matched:
try:
await asyncio.to_thread(client.download_inbound, f)
downloaded += 1
except Exception as exc: # noqa: BLE001
log.warning("Failed to download %s: %s", f.name, exc)
download_errors.append(f"{f.name}: {type(exc).__name__}: {exc}")
# Hand off to the scheduler pipeline (idempotent; dedupes via
# processed_inbound_files).
tick = await sched.process_inbound_files(matched)
duration = round(time.monotonic() - started, 3)
return {
"ok": True,
"summary": {
"date": date,
"file_types": sorted(wanted),
"limit": limit,
"listed": listed,
"matched": len(matched),
"downloaded": downloaded,
"download_errors": download_errors,
"processed": tick.files_processed,
"skipped": tick.files_skipped,
"errored": tick.files_errored,
"duration_s": duration,
},
"tick": tick.as_dict(),
}
@router.get("/api/admin/scheduler/status")
def scheduler_status() -> Any:
"""Return the scheduler's runtime snapshot (running, counters, last tick)."""
sched = _scheduler_or_503()
return sched.status().as_dict()
@router.get("/api/admin/scheduler/processed-files")
def scheduler_processed_files(
limit: int = Query(default=100, ge=1, le=1000),
status: str | None = Query(default=None),
) -> Any:
"""List rows from ``processed_inbound_files``, newest first.
The operator's "what did the scheduler do?" view. Filters by
``status`` (``ok`` / ``error`` / ``skipped`` / ``pending``).
Returns ``{"count": N, "files": [...]}`` where ``files[i]``
matches the ORM row as a JSON dict.
"""
from cyclone.db import ProcessedInboundFile
from cyclone.scheduler import STATUS_OK, STATUS_ERROR, STATUS_SKIPPED, STATUS_PENDING
valid_statuses = {STATUS_OK, STATUS_ERROR, STATUS_SKIPPED, STATUS_PENDING}
if status is not None and status not in valid_statuses:
raise HTTPException(
status_code=400,
detail=f"status must be one of {sorted(valid_statuses)}",
)
with db.SessionLocal()() as s:
q = s.query(db.ProcessedInboundFile)
if status is not None:
q = q.filter(db.ProcessedInboundFile.status == status)
rows = q.order_by(db.ProcessedInboundFile.id.desc()).limit(limit).all()
return {
"count": len(rows),
"files": [
{
"id": r.id,
"sftp_block_name": r.sftp_block_name,
"name": r.name,
"size": r.size,
"modified_at": r.modified_at.isoformat() if r.modified_at else None,
"file_type": r.file_type,
"processed_at": r.processed_at.isoformat() if r.processed_at else None,
"parser_used": r.parser_used,
"claim_count": r.claim_count,
"status": r.status,
"error_message": r.error_message,
}
for r in rows
],
}
@router.post("/api/admin/reload-config")
def reload_config():
"""Re-read ``config/payers.yaml`` and revalidate. Returns counts."""
@@ -830,53 +243,90 @@ def reload_config():
# ---------------------------------------------------------------------------
# SP40: POST /api/admin/validate-837
#
# Admin-only Edifabric validation probe: upload an 837P file via
# multipart, hit /v2/x12/read → /v2/x12/validate, return the raw
# OperationResult JSON (Status, Details, LastIndex). Mirrors the
# `cyclone validate-837 <file>` CLI behavior — the wire shape comes
# straight through so callers don't have to translate between the two.
#
# HTTP status codes:
# 200 — OperationResult returned (Status may be success / warning /
# error; the caller decides whether the file is acceptable).
# 400 — uploaded file is empty / undecodable (defense-in-depth, same
# shape as /api/parse-837).
# 502 — Edifabric upstream 4xx/5xx — caller can surface the body.
# SP16: inbound MFT scheduler
# ---------------------------------------------------------------------------
@router.post("/api/admin/validate-837")
async def validate_837_endpoint(
file: UploadFile = File(...),
) -> Any:
"""Validate an uploaded 837P file via Edifabric /v2/x12/validate.
@router.post("/api/admin/scheduler/start")
async def scheduler_start():
"""Start the background MFT polling loop (idempotent)."""
from cyclone import scheduler as sched_mod
sched = sched_mod.get_scheduler()
await sched.start()
return {"ok": True, "status": sched.status().as_dict()}
Multipart upload (``file=...``); the file is read into bytes and
passed to :func:`cyclone.edifabric.validate_edi`. The API key is
resolved server-side from ``cyclone.secrets.get_secret('edifabric.api_key')``
so the key never leaves the backend.
@router.post("/api/admin/scheduler/stop")
async def scheduler_stop():
"""Stop the background MFT polling loop. Waits for the current tick."""
from cyclone import scheduler as sched_mod
sched = sched_mod.get_scheduler()
await sched.stop()
return {"ok": True, "status": sched.status().as_dict()}
@router.get("/api/admin/scheduler/status")
def scheduler_status():
"""Return the scheduler's running state + last tick summary."""
from cyclone import scheduler as sched_mod
sched = sched_mod.get_scheduler()
return sched.status().as_dict()
@router.post("/api/admin/scheduler/tick")
async def scheduler_tick():
"""Force a single poll cycle. Returns the TickResult.
Useful for the operator's "run now" button or for testing. If a
scheduled tick is already in flight, this call coalesces onto it
(waits for it to finish, returns its result).
"""
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
from cyclone import scheduler as sched_mod
sched = sched_mod.get_scheduler()
result = await sched.tick()
return {"ok": True, "tick": result.as_dict()}
@router.get("/api/admin/scheduler/processed-files")
def scheduler_processed_files(
limit: int = 50,
status: str | None = None,
):
"""List recently-processed inbound files (newest first).
The operator uses this to confirm the scheduler is keeping up
with MFT traffic. ``status`` filters to one of: ok, error,
skipped, pending.
"""
from sqlalchemy import desc
from cyclone import db
from cyclone.db import ProcessedInboundFile
with db.SessionLocal()() as session:
q = session.query(ProcessedInboundFile).order_by(
desc(ProcessedInboundFile.processed_at),
)
try:
result = edifabric.validate_edi(raw)
except edifabric.EdifabricError as exc:
# status_code=0 is a client-side config problem (missing API
# key); 4xx/5xx upstream become 502. Surface the Edifabric body
# verbatim so the operator can see what went wrong.
http_status = 502 if exc.status_code else 503
return JSONResponse(
status_code=http_status,
content={
"error": "Edifabric validation failed",
"upstream_status": exc.status_code,
"upstream_body": exc.body,
},
)
return JSONResponse(content=result)
if status:
q = q.filter(ProcessedInboundFile.status == status)
q = q.limit(min(limit, 500))
rows = q.all()
return {
"count": len(rows),
"files": [
{
"id": r.id,
"name": r.name,
"size": r.size,
"file_type": r.file_type,
"processed_at": r.processed_at.isoformat() if r.processed_at else None,
"parser_used": r.parser_used,
"claim_count": r.claim_count,
"status": r.status,
"error_message": (
r.error_message[:200] + "..."
if r.error_message and len(r.error_message) > 200
else r.error_message
),
}
for r in rows
],
}
+15 -455
View File
@@ -1,273 +1,23 @@
"""``/api/batches*`` — read views + ZIP export over the parsed-batch population.
"""``/api/batches`` — list & detail endpoints for parsed batches.
Three endpoints, all gated by ``matrix_gate``:
- ``POST /api/batches/{batch_id}/export-837`` — download a ZIP of
regenerated X12 837 files for the requested claim ids. Per-claim
payer config + clearhouse identity drive the submitter/receiver
blocks; per-claim millisecond offset on the filename keeps every
file in the bundle unique (HCPF requires this). Read-only — does
NOT mutate Claim state (compare with
``/api/inbox/rejected/resubmit?download=true`` which DOES flip
``REJECTED → SUBMITTED``).
- ``GET /api/batches`` — summary list,
newest-first, capped at ``limit``. Each row includes ``claimIds``
(837P only, so the Upload page can render a one-click Re-export
button per row without a round-trip to ``/api/batches/{id}``).
SP30: also returns billing-outcome fields
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so the
Dashboard "Recent batches" widget can render one row per batch
without an N+1 fetch.
- ``GET /api/batches/{batch_id}`` — full batch record
(parsed envelope + claims). 404 when unknown.
Three single-router helpers stay in this file (per spec D4):
- :func:`_batch_summary_claim_count` — claim count (837P or 835).
- :func:`_batch_summary_claim_ids` — per-claim ids (837P only).
- :func:`_batch_summary_billing_outcomes` — per-batch GROUP BY state
aggregate plus the most-recent rejection reason.
Inline imports inside handlers (preserved verbatim per spec D5):
``zipfile``, ``datetime``, ``ZoneInfo``, ``build_outbound_filename``,
``PayerConfigORM``, ``func`` (sqlalchemy).
SP36 Task 14: this block moved here from ``api.py:1280`` (the 3
``/api/batches*`` routes + 3 single-router helpers + the SP30
state-bucket tuples and the ``# 277CA STC category`` note).
Each ``store.add(...)`` call (837P, 835, 999, TA1, 277CA) produces a
``BatchRecord`` kept in memory by :class:`CycloneStore`. The list
endpoint returns a lightweight summary (id, kind, filename, parsedAt,
claim count) for the recent batches; the detail endpoint returns the
full parsed payload for one batch.
"""
from __future__ import annotations
import io
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from cyclone import db
from cyclone.api_helpers import (
ndjson_stream_list as _ndjson_stream_list,
wants_ndjson as _wants_ndjson,
)
from cyclone.auth.deps import matrix_gate
from cyclone.db import Batch, Claim, ClaimState
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.store import BatchRecord, store
router = APIRouter(dependencies=[Depends(matrix_gate)])
@router.post("/api/batches/{batch_id}/export-837")
def export_batch_837(request: Request, batch_id: str, body: dict):
"""Download a ZIP of regenerated X12 837 files for the requested claim_ids.
Body shape: ``{"claim_ids": [str, ...]}``.
Each successfully serialized claim becomes an entry in the ZIP named
per the HCPF X12 File Naming Standards:
``tp{tpid}-837P-{yyyymmddhhmmssSSS}-1of1.x12`` (with a per-claim
millisecond offset so every file in the bundle has a unique name).
The ``serialize_837_for_resubmit`` serializer is used so every file
gets a unique interchange / group control number — back-to-back
exports of the same set must produce different envelopes (required
by X12).
The submitter block (Loop 1000A — NM1*41 + PER) is populated from
the clearhouse singleton (dzinesco's identity in the seeded config)
and the receiver block (NM1*40) is populated from the per-payer
config. Without this wiring, the serializer falls back to
``CYCLONE`` / ``RECEIVER`` placeholders and HCPF rejects the file.
No DB state is mutated by this endpoint — it is read-only. Compare
with ``/api/inbox/rejected/resubmit?download=true`` which ALSO flips
``ClaimState.REJECTED → SUBMITTED``; the two endpoints are
intentionally separate.
Responses:
200 — ``application/zip`` with the .x12 entries. Per-claim failures
are surfaced via the ``X-Cyclone-Serialize-Errors`` header
(JSON-encoded array of ``{claim_id, reason}``).
400 — ``claim_ids`` missing or empty.
404 — ``batch_id`` unknown.
422 — every claim failed to serialize; body is JSON listing all
failures (``{"detail": {"serialize_errors": [...]}}``).
"""
import zipfile
from datetime import datetime
from zoneinfo import ZoneInfo
from cyclone.edi.filenames import build_outbound_filename
ids = body.get("claim_ids") or []
if not ids:
raise HTTPException(400, "claim_ids required")
with db.SessionLocal()() as s:
batch = s.get(Batch, batch_id)
if batch is None:
raise HTTPException(404, f"unknown batch: {batch_id}")
serialize_errors: list[dict] = []
ordered_rows: list[tuple[str, "Claim"]] = []
for cid in ids:
c = s.get(Claim, cid)
if c is None:
serialize_errors.append({"claim_id": cid, "reason": "unknown claim_id"})
continue
ordered_rows.append((cid, c))
# Pull clearhouse identity (submitter). If unseeded, the serializer
# falls back to placeholder defaults — degraded but not a hard error.
ch = store.get_clearhouse()
submitter_kwargs: dict = {}
if ch is not None:
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
# Submitter phone is not in the clearhouse config today, but if
# it ever is, wire it here. Email is the canonical contact
# channel for HCPF submissions per the SP9 spec.
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Resolve per-claim payer config so each file's receiver (NM1*40)
# and SBR09 are correct. Cache so we don't re-query the same payer.
from cyclone.db import PayerConfigORM as _PayerConfigORM
_payer_cache: dict[str, dict | None] = {}
def _resolve_payer_cfg(claim_obj: ClaimOutput) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
cache_key = pid or pname
if cache_key in _payer_cache:
return _payer_cache[cache_key]
cfg: dict | None = None
with db.SessionLocal()() as ss:
# 1. Exact match on (payer_id, "837P")
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
cfg = dict(row.config_json)
# 2. Fallback: any row whose payer_id matches the parsed payer.name
# (HCPF files emit "SKCO0" in NM109 but the canonical
# payer_id in the DB is "CO_TXIX" — name-matching is the
# pragmatic lookup for that case).
if cfg is None and pname:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in row:
cj = dict(r.config_json)
if cj.get("submitter_name") and pname.lower() in str(cj).lower():
cfg = cj
break
if (r.payer_id or "").upper() == pname.upper():
cfg = cj
break
# 3. Last resort: first 837P row in the table.
if cfg is None:
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
if row is not None:
cfg = dict(row.config_json)
_payer_cache[cache_key] = cfg
return cfg
# Build per-claim kwargs (receiver + SBR09) lazily. Receiver
# defaults to the parsed payer name/ID if no config row matches.
def _serialize_kwargs(claim_obj: ClaimOutput) -> dict:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = (
payer_cfg.get("receiver_id")
or (claim_obj.payer.id if claim_obj.payer else None)
or "RECEIVER"
)
receiver_name = (
payer_cfg.get("receiver_name")
or (claim_obj.payer.name if claim_obj.payer else None)
or receiver_id
)
sbr09 = payer_cfg.get("sbr09_default") or "MC"
return {
"receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09,
}
# Base MT timestamp for HCPF filenames. We add a per-claim
# millisecond offset so each file in the ZIP has a unique 17-digit
# ts (HCPF requires that; the spec also enforces "1of1" for the
# sequence element).
base_ts = datetime.now(ZoneInfo("America/Denver"))
def _per_claim_filename(idx: int, cid: str) -> str:
if ch is None:
# No clearhouse — fall back to a per-claim friendly name.
return f"claim-{cid}.x12"
# Millisecond offset, with second/minute rollover.
offset_ms = (idx - 1) * 1 # 1 ms per claim is enough within an export
ts_mt = base_ts.fromtimestamp(
base_ts.timestamp() + offset_ms / 1000.0, tz=ZoneInfo("America/Denver")
)
return build_outbound_filename(ch.tpid, "837P", now_mt=ts_mt)
buf = io.BytesIO()
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
for idx, (cid, c) in enumerate(ordered_rows, start=1):
if not c.raw_json:
serialize_errors.append({"claim_id": cid, "reason": "no raw_json"})
continue
try:
claim_obj = ClaimOutput.model_validate(c.raw_json)
except Exception as exc:
serialize_errors.append(
{"claim_id": cid, "reason": f"raw_json invalid: {exc}"}
)
continue
try:
kwargs = {**submitter_kwargs, **_serialize_kwargs(claim_obj)}
text = serialize_837_for_resubmit(
claim_obj, interchange_index=idx, **kwargs
)
except SerializeError837 as exc:
serialize_errors.append({"claim_id": cid, "reason": str(exc)})
continue
zf.writestr(_per_claim_filename(idx, cid), text)
success_count = len(ids) - len(serialize_errors)
if serialize_errors and success_count == 0:
# Every claim failed — surface the failure list in the body so the
# UI can render a useful error toast (the response is not a ZIP).
raise HTTPException(
422,
detail={"serialize_errors": serialize_errors},
)
buf.seek(0)
headers = {
"Content-Disposition": (
f'attachment; filename="batch-{batch_id}-{success_count}-claims.zip"'
),
}
if serialize_errors:
headers["X-Cyclone-Serialize-Errors"] = json.dumps(serialize_errors)
return Response(
content=buf.getvalue(),
media_type="application/zip",
headers=headers,
)
router = APIRouter()
def _batch_summary_claim_count(rec: BatchRecord) -> int:
@@ -279,195 +29,13 @@ def _batch_summary_claim_count(rec: BatchRecord) -> int:
return 0
def _batch_summary_claim_ids(rec: BatchRecord) -> list[str]:
"""Return per-claim ids for an 837P batch, or ``[]`` otherwise.
The Upload page's History tab renders a one-click Re-export ZIP
button per row; that button calls
``POST /api/batches/{id}/export-837`` with the row's claim ids.
Carrying them in the list response avoids an extra round-trip
to ``/api/batches/{id}`` for every row. 835 has no re-export
endpoint, so the list is empty for those — the UI uses the
empty list as the signal to hide the button.
"""
if rec.kind != "837p":
return []
return [
c.claim_id
for c in rec.result.claims # type: ignore[attr-defined]
if getattr(c, "claim_id", None)
]
# SP30: state buckets the Dashboard widget (and any future "how the
# last batch billed" surface) reads at a glance. Keep these in sync
# with ClaimState — adding a new state here is a deliberate decision
# the operator needs to see, not a coincidence.
_BATCH_SUMMARY_ACCEPTED_STATES: tuple[ClaimState, ...] = (
ClaimState.PAID,
ClaimState.RECEIVED,
ClaimState.RECONCILED,
ClaimState.PARTIAL,
)
_BATCH_SUMMARY_REJECTED_STATES: tuple[ClaimState, ...] = (
ClaimState.REJECTED,
ClaimState.DENIED,
ClaimState.REVERSED,
)
_BATCH_SUMMARY_PENDING_STATES: tuple[ClaimState, ...] = (
ClaimState.SUBMITTED,
)
# 277CA STC category A4/A6/A7 — payer-side rejections that may not
# yet have flipped Claim.state (the operator hasn't acknowledged).
# The Dashboard widget treats these as problems too, mirroring the
# Inbox `rejected + payer_rejected` aggregation.
_BATCH_SUMMARY_PAYER_REJECT_CODES: tuple[str, ...] = ("A4", "A6", "A7")
def _batch_summary_billing_outcomes(
records: list[BatchRecord],
) -> dict[str, dict]:
"""Compute per-batch billing outcome for the Dashboard widget.
Returns ``{batch_id: {accepted, rejected, pending, billed,
top_rejection_reason, has_problem}}`` for every batch in
``records``. Empty input → empty dict.
Two SQL queries, both bounded by the supplied batch ids:
1. One GROUP BY ``(batch_id, state)`` aggregate that produces
the accepted/rejected/pending counts and the sum of
``charge_amount`` (the billed total). Single pass — no N+1.
2. One ordered scan over the rejected + payer-rejected subset
to pick the most recent rejection reason (truncated to 60
chars). Skipped when the first query found no rejections
and no payer-rejects, so the happy path stays at one query.
835 batches have no Claim rows — the GROUP BY returns no
rows for them, so the dict entry for an 835 batch is
``{accepted:0, rejected:0, pending:0, billed:0.0,
top_rejection_reason:None, has_problem:False}`` (filled by
the caller's ``.get(id, defaults)`` pattern).
"""
if not records:
return {}
from sqlalchemy import func # local import to keep top-of-file light
batch_ids = [r.id for r in records]
outcome: dict[str, dict] = {
bid: {
"accepted": 0,
"rejected": 0,
"pending": 0,
"billed": 0.0,
"top_rejection_reason": None,
"has_problem": False,
}
for bid in batch_ids
}
with db.SessionLocal()() as s:
# ---- 1. GROUP BY (batch_id, state) for counts + billed total ----
rows = (
s.query(
Claim.batch_id,
Claim.state,
func.count(Claim.id),
func.coalesce(func.sum(Claim.charge_amount), 0),
)
.filter(Claim.batch_id.in_(batch_ids))
.group_by(Claim.batch_id, Claim.state)
.all()
)
any_rejection_or_payer = False
for batch_id, state, count, billed in rows:
slot = outcome.get(batch_id)
if slot is None:
continue # batch has no row in our pre-allocated dict
count = int(count or 0)
billed_f = float(billed or 0)
slot["billed"] += billed_f
if state in _BATCH_SUMMARY_ACCEPTED_STATES:
slot["accepted"] += count
elif state in _BATCH_SUMMARY_REJECTED_STATES:
slot["rejected"] += count
any_rejection_or_payer = True
elif state in _BATCH_SUMMARY_PENDING_STATES:
slot["pending"] += count
# everything else (DRAFT, etc.) is excluded from the widget.
# ---- 2. Most-recent rejection reason + payer-reject probe ----
# Only run when we know there IS at least one rejection OR a
# payer-reject claim somewhere in the batch set; otherwise
# the first query alone is enough.
if any_rejection_or_payer:
rej_rows = (
s.query(
Claim.batch_id,
Claim.rejection_reason,
Claim.payer_rejected_status_code,
)
.filter(
Claim.batch_id.in_(batch_ids),
Claim.state.in_(_BATCH_SUMMARY_REJECTED_STATES)
| Claim.payer_rejected_status_code.in_(
_BATCH_SUMMARY_PAYER_REJECT_CODES
),
)
.order_by(Claim.rejected_at.desc().nullslast())
.all()
)
seen_reason: set[str] = set()
for batch_id, reason, payer_code in rej_rows:
slot = outcome.get(batch_id)
if slot is None:
continue
if payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES:
slot["has_problem"] = True
# Capture the first non-null reason for this batch
# (rej_rows is ordered newest-first, so the first
# non-null wins). Truncate to 60 chars + ellipsis.
if (
slot["top_rejection_reason"] is None
and reason
and batch_id not in seen_reason
):
r = reason.strip()
if len(r) > 60:
r = r[:60] + ""
slot["top_rejection_reason"] = r
seen_reason.add(batch_id)
if (
slot["rejected"] > 0
or payer_code in _BATCH_SUMMARY_PAYER_REJECT_CODES
):
slot["has_problem"] = True
return outcome
@router.get("/api/batches")
def list_batches(
def list_batches_endpoint(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> Any:
"""Summary of all parsed batches, newest first.
Each item includes ``claimIds`` (837P only) so the History tab
on the Upload page can render a one-click re-export button per
row without an extra round-trip to ``/api/batches/{id}``. The
list is still capped at ``limit`` claims; see the full result
via the by-id endpoint when more is needed.
SP30: also returns billing-outcome fields
(``acceptedCount`` / ``rejectedCount`` / ``pendingCount`` /
``billedTotal`` / ``topRejectionReason`` / ``hasProblem``) so
the Dashboard "Recent batches" widget can render one row per
batch without an N+1 fetch. See
:func:`_batch_summary_billing_outcomes`.
"""
"""Summary of all parsed batches, newest first."""
records = store.list(limit=limit)
outcomes = _batch_summary_billing_outcomes(records)
items = [
{
"id": r.id,
@@ -475,15 +43,6 @@ def list_batches(
"inputFilename": r.input_filename,
"parsedAt": r.parsed_at.isoformat().replace("+00:00", "Z"),
"claimCount": _batch_summary_claim_count(r),
"claimIds": _batch_summary_claim_ids(r),
"acceptedCount": outcomes.get(r.id, {}).get("accepted", 0),
"rejectedCount": outcomes.get(r.id, {}).get("rejected", 0),
"pendingCount": outcomes.get(r.id, {}).get("pending", 0),
"billedTotal": round(outcomes.get(r.id, {}).get("billed", 0.0), 2),
"topRejectionReason": outcomes.get(r.id, {}).get(
"top_rejection_reason"
),
"hasProblem": outcomes.get(r.id, {}).get("has_problem", False),
}
for r in records
]
@@ -491,9 +50,9 @@ def list_batches(
total = len(all_records)
returned = len(items)
has_more = total > returned
if _wants_ndjson(request):
if wants_ndjson(request):
return StreamingResponse(
_ndjson_stream_list(items, total, returned, has_more),
ndjson_stream_list(items, total, returned, has_more),
media_type="application/x-ndjson",
)
return {
@@ -505,7 +64,8 @@ def list_batches(
@router.get("/api/batches/{batch_id}")
def get_batch(batch_id: str) -> Any:
def get_batch_endpoint(batch_id: str) -> Any:
"""Return the full parsed payload for one batch."""
rec = store.get(batch_id)
if rec is None:
raise HTTPException(
@@ -1,307 +0,0 @@
"""``/api/claim-acks`` (and per-claim/per-ack surfaces) — SP28.
Seven endpoints that surface the ``claim_acks`` join table to the
frontend + manual-match fallback for orphans. Mounted by
``cyclone.api`` alongside the existing ``/api/acks`` and
``/api/ta1-acks`` routers.
The live-tail endpoints (``/api/claims/{id}/acks/stream`` and
``/api/acks/{kind}/{id}/claims/stream``) subscribe to the
``claim_ack_written`` bus event so the ClaimDrawer Acknowledgments
panel and the per-ack claims list refresh in real time.
Manual match is any-logged-in user (D5) — this endpoint mutates
metadata only (``claim_acks`` row + live-tail event), no
``Claim.state`` mutation, no payment data. Idempotent: re-calling
with the same ``claim_id`` returns 200 with the existing row.
Rejects (409) when the claim is in a terminal state (``REVERSED``).
"""
from __future__ import annotations
import logging
from typing import Any, AsyncIterator, Literal
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from cyclone import db
from cyclone.api_helpers import ndjson_line, tail_events
from cyclone.auth.deps import matrix_gate
from cyclone.pubsub import EventBus
from cyclone.store import store, to_ui_claim_ack
router = APIRouter(dependencies=[Depends(matrix_gate)])
log = logging.getLogger(__name__)
AckKind = Literal["999", "277ca", "ta1"]
class MatchClaimBody(BaseModel):
"""Body for ``POST /api/acks/{kind}/{ack_id}/match-claim``."""
claim_id: str = Field(..., min_length=1)
set_control_number: str | None = None
set_accept_reject_code: str | None = None
ak2_index: int | None = None
# ---------------------------------------------------------------------------
# Per-claim surface
# ---------------------------------------------------------------------------
@router.get("/api/claims/{claim_id}/acks/stream")
async def claim_acks_stream(
request: Request,
claim_id: str,
) -> StreamingResponse:
"""Stream ClaimAck rows for one claim as NDJSON.
Subscribes to ``claim_ack_written`` and filters for rows where
``claim_id`` matches the path param. Each matching event is
emitted as ``{"type": "item", "data": to_ui_claim_ack(...)}``;
the client-side ``useMergedTail`` hook dedupes by id.
Registered BEFORE ``/api/claims/{claim_id}/acks`` so the literal
``stream`` path segment doesn't get matched as a suffix.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = [
r for r in store.list_acks_for_claim(claim_id)
if r.claim_id is not None # filter out TA1 batch-level rows
]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
yield ndjson_line({
"type": "snapshot_end",
"data": {"count": len(rows)},
})
async for chunk in tail_events(request, bus, ["claim_ack_written"]):
# tail_events yields full NDJSON lines; the client filter
# picks claim_id matches. We forward every event so the
# wire format mirrors /api/claims/stream.
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/claims/{claim_id}/acks")
def list_acks_for_claim_endpoint(claim_id: str) -> Any:
"""Return every ClaimAck row for one claim (per-claim only).
TA1 batch-level rows (where ``claim_id IS NULL``) are filtered
out — those don't belong to a specific claim, they're a
envelope-level acknowledgement that hangs off the originating
837 batch. The ClaimDrawer Acknowledgments panel is
per-claim only.
"""
rows = store.list_acks_for_claim(claim_id)
items = [to_ui_claim_ack(r) for r in rows if r.claim_id is not None]
return {"total": len(items), "items": items}
# ---------------------------------------------------------------------------
# Per-ack surface
# ---------------------------------------------------------------------------
@router.get("/api/acks/{kind}/{ack_id}/claims/stream")
async def ack_claims_stream(
request: Request,
kind: AckKind,
ack_id: int,
) -> StreamingResponse:
"""Stream ClaimAck rows for one ack as NDJSON.
Subscribes to ``claim_ack_written`` and forwards events the
store knows about. Clients filter by ack_id + ack_kind on the
client side.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_claims_for_ack(kind, ack_id)
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_claim_ack(row)})
yield ndjson_line({
"type": "snapshot_end",
"data": {"count": len(rows)},
})
async for chunk in tail_events(request, bus, ["claim_ack_written"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/acks/{kind}/{ack_id}/claims")
def list_claims_for_ack_endpoint(kind: AckKind, ack_id: int) -> Any:
"""Return every ClaimAck row for one ack (any kind).
For 999 / 277CA: returns 0..N rows (one per AK2 / ClaimStatus).
For TA1: returns 0..1 row (envelope-level, populated batch_id).
"""
rows = store.list_claims_for_ack(kind, ack_id)
items = [to_ui_claim_ack(r) for r in rows]
return {"total": len(items), "items": items}
# ---------------------------------------------------------------------------
# Manual match / unmatch (D5, D9)
# ---------------------------------------------------------------------------
@router.post("/api/acks/{kind}/{ack_id}/match-claim")
def manual_match_claim_endpoint(
request: Request,
kind: AckKind,
ack_id: int,
body: MatchClaimBody,
) -> Any:
"""Manual link fallback (D5/D9). Any-logged-in-user posture.
Inserts a ``claim_acks`` row with ``linked_by="manual"`` and
publishes ``claim_ack_written`` so the drawers refresh. The
endpoint is idempotent: if a row already exists for this dedup
key, the existing row is returned (200). 409 when the claim is
in a terminal state (``REVERSED``). 404 when the claim doesn't
exist or the ack doesn't exist.
"""
# Verify the claim exists and is in a non-terminal state.
from cyclone.db import ClaimState as _CS
with db.SessionLocal()() as s:
claim = s.get(db.Claim, body.claim_id)
if claim is None:
raise HTTPException(
status_code=404,
detail={
"error": "Not found",
"detail": f"Claim {body.claim_id} not found",
},
)
if claim.state == _CS.REVERSED:
return JSONResponse(
status_code=409,
content={
"error": "Conflict",
"detail": (
f"Claim {body.claim_id} is in terminal state "
f"{claim.state.value} and cannot be linked."
),
},
)
# Verify the ack exists (any kind).
ack_table = {
"999": db.Ack,
"277ca": db.Two77caAck,
"ta1": db.Ta1Ack,
}[kind]
if s.get(ack_table, ack_id) is None:
raise HTTPException(
status_code=404,
detail={
"error": "Not found",
"detail": f"{kind} ACK {ack_id} not found",
},
)
# Idempotency: if a manual or auto link already exists, return it.
existing = (
s.query(db.ClaimAck)
.filter(
db.ClaimAck.ack_kind == kind,
db.ClaimAck.ack_id == ack_id,
db.ClaimAck.claim_id == body.claim_id,
)
.first()
)
if existing is not None:
return {
"link": to_ui_claim_ack(existing),
"created": False,
}
# Insert via the store so the publish-from-store contract fires.
row = store.add_claim_ack(
claim_id=body.claim_id,
batch_id=None,
ack_id=ack_id,
ack_kind=kind,
ak2_index=body.ak2_index,
set_control_number=body.set_control_number,
set_accept_reject_code=body.set_accept_reject_code,
linked_by="manual",
event_bus=request.app.state.event_bus,
)
return {"link": to_ui_claim_ack(row), "created": True}
@router.delete("/api/acks/{kind}/{ack_id}/match-claim/{claim_id}")
def manual_unmatch_claim_endpoint(
request: Request,
kind: AckKind,
ack_id: int,
claim_id: str,
) -> Any:
"""Unlink (preserves ``Claim.state`` mutation; only removes the row).
404 when no link row exists for the dedup key. Publishes
``claim_ack_dropped`` so live-tail subscribers remove the link.
"""
from cyclone import db as _db
with _db.SessionLocal()() as s:
link = (
s.query(_db.ClaimAck)
.filter(
_db.ClaimAck.ack_kind == kind,
_db.ClaimAck.ack_id == ack_id,
_db.ClaimAck.claim_id == claim_id,
)
.first()
)
if link is None:
raise HTTPException(
status_code=404,
detail={
"error": "Not found",
"detail": (
f"No link for {kind} ack {ack_id}"
f"claim {claim_id}"
),
},
)
link_id = link.id
removed = store.remove_claim_ack(link_id, event_bus=request.app.state.event_bus)
return {"removed": removed, "link_id": link_id}
# ---------------------------------------------------------------------------
# Inbox ack-orphans lane (spec §D7)
# ---------------------------------------------------------------------------
@router.get("/api/inbox/ack-orphans")
def list_ack_orphans_endpoint(
kind: AckKind | None = Query(None, description="Filter by ack kind"),
) -> Any:
"""List acks with no resolvable Claim row of their own kind.
Used by the Inbox "Ack orphans" lane for the operator's manual
reconciliation flow. Filters by kind: ``999``, ``277ca``, ``ta1``.
"""
if kind is not None:
items = store.find_ack_orphans(kind)
return {"total": len(items), "items": items}
items_999 = store.find_ack_orphans("999")
items_277ca = store.find_ack_orphans("277ca")
items_ta1 = store.find_ack_orphans("ta1")
all_items = items_999 + items_277ca + items_ta1
return {"total": len(all_items), "items": all_items}
__all__ = ["router"]
+48 -176
View File
@@ -1,71 +1,53 @@
"""``/api/claims*`` — Claims list / detail / streaming / serialize / line-reconciliation.
"""``/api/claims`` — list, live-tail, detail, X12 regenerate, line-reconciliation.
Five endpoints, all gated by ``matrix_gate``:
This is the largest single-resource router. Five endpoints:
- ``GET /api/claims`` — paginated list
with filter+sort, plus an NDJSON variant when the caller sends
``Accept: application/x-ndjson``. SP27: counts the full filtered
population, not a page-limited sample.
- ``GET /api/claims/stream`` — NDJSON live-tail
on ``claim_written``. Snapshot first (eager
``store.iter_claims``), then ``tail_events`` subscribes + emits
heartbeats. Registered before ``/api/claims/{claim_id}`` so the
literal ``stream`` segment isn't captured as a claim id.
- ``GET /api/claims/{claim_id}`` — full drawer
context (SP4) with the SP28 ``ack_links`` block pre-attached.
404 on missing id — never 500.
- ``GET /api/claims/{claim_id}/serialize-837`` — regenerate X12
837P from the stored ``raw_json`` payload. 404 unknown claim, 422
no-``raw_json`` / unparseable / serializer failure.
- ``GET /api/claims/{claim_id}/line-reconciliation`` — per-line 837
vs 835 side-by-side with CAS adjustments and a summary block.
* ``GET /api/claims`` — paginated list with filters
(status / batch_id / provider_npi / payer / date range / sort).
* ``GET /api/claims/stream`` — NDJSON snapshot + live tail via the
``claim_written`` EventBus kind.
* ``GET /api/claims/{claim_id}`` — drawer detail
(header / state / service lines / diagnoses / parties / validation /
raw segments / stateHistory / matchedRemittance).
* ``GET /api/claims/{claim_id}/serialize-837`` — regenerate X12 for
download (SP8).
* ``GET /api/claims/{claim_id}/line-reconciliation`` — per-line
reconciliation view for the ClaimDrawer tab (spec §5.1).
Three single-router helpers stay in this file (per spec D4):
**Route ordering.** ``/stream`` is declared **before** ``/{claim_id}`` so
FastAPI's first-match routing doesn't swallow the literal ``stream``
segment as a claim id. Same order as the prior inline code.
- :func:`_compact_ack_links_for_claim` — slim form
``{ack_id, ack_kind, set_accept_reject_code, …}`` for the drawer
Acknowledgments panel.
- :func:`_claim_line_dict` — project an 837 service-line
dict from ``Claim.raw_json`` to wire shape.
- :func:`_svc_to_dict` — project an ORM
``ServiceLinePayment`` to wire shape.
Inline imports inside handlers (preserved verbatim per spec D5):
``select`` (sqlalchemy), ``LineReconciliation``/``ServiceLinePayment``/
``CasAdjustment`` (cyclone.db), ``json as _json``, ``Decimal``.
SP36 Task 15: this block moved here from ``api.py:1278`` (the 5
``/api/claims*`` routes + 3 single-router helpers).
The two projection helpers ``_claim_line_dict`` / ``_svc_to_dict`` are
private to this router; both only used by the line-reconciliation
endpoint.
"""
from __future__ import annotations
import json
from decimal import Decimal
from typing import Any, AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse
from cyclone import db
from cyclone.api_helpers import (
ndjson_line as _ndjson_line,
ndjson_stream_list as _ndjson_stream_list,
tail_events as _tail_events,
wants_ndjson as _wants_ndjson,
ndjson_line,
ndjson_stream_list,
tail_events,
wants_ndjson,
)
from cyclone.auth.deps import matrix_gate
from cyclone.db import Claim
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837
from cyclone.parsers.serialize_837 import serialize_837
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837, serialize_837
from cyclone.pubsub import EventBus
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/claims")
def list_claims(
def list_claims_endpoint(
request: Request,
batch_id: str | None = Query(None),
status: str | None = Query(None),
@@ -78,6 +60,11 @@ def list_claims(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
) -> Any:
"""Paginated claims list with filters; supports NDJSON streaming.
``has_more`` is computed as ``total > offset + returned`` so paginating
forward keeps returning true until the last page is reached.
"""
common = dict(
batch_id=batch_id,
status=status,
@@ -89,15 +76,12 @@ def list_claims(
items = list(store.iter_claims(
sort=sort, order=order, limit=limit, offset=offset, **common,
))
# SP27 Task 13b: count the full population, not a 100-row sample.
# `iter_claims` defaults to limit=100; counting its output silently
# capped the reported total at 100 even when the DB held 60k rows.
total = store.count_claims(**common)
total = len(list(store.iter_claims(**common)))
returned = len(items)
has_more = total > offset + returned
if _wants_ndjson(request):
if wants_ndjson(request):
return StreamingResponse(
_ndjson_stream_list(items, total, returned, has_more),
ndjson_stream_list(items, total, returned, has_more),
media_type="application/x-ndjson",
)
return {
@@ -108,13 +92,8 @@ def list_claims(
}
# --------------------------------------------------------------------------- #
# Live-tail NDJSON streaming endpoints (Phase 3 — SP5)
# --------------------------------------------------------------------------- #
@router.get("/api/claims/stream")
async def claims_stream(
async def claims_stream_endpoint(
request: Request,
status: str | None = Query(None),
provider_npi: str | None = Query(None),
@@ -134,8 +113,8 @@ async def claims_stream(
* ``{"type":"heartbeat","data":{"ts":<iso>}}`` every
``CYCLONE_TAIL_HEARTBEAT_S`` seconds when idle
Query params mirror :func:`list_claims` so a frontend can swap a
one-shot fetch for a tail with no URL surgery.
Query params mirror :func:`list_claims_endpoint` so a frontend can
swap a one-shot fetch for a tail with no URL surgery.
NOTE: registered before ``/api/claims/{claim_id}`` so the literal
``stream`` path segment doesn't get matched as a claim id.
@@ -150,11 +129,11 @@ async def claims_stream(
sort=sort or "-submission_date", order=order, limit=limit,
)
for row in rows:
yield _ndjson_line({"type": "item", "data": row})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
yield ndjson_line({"type": "item", "data": row})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
# 2. Subscribe + heartbeats.
async for chunk in _tail_events(request, bus, ["claim_written"]):
async for chunk in tail_events(request, bus, ["claim_written"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@@ -169,15 +148,8 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
raw segments, ``stateHistory`` (most-recent-first, capped at 50),
and a populated ``matchedRemittance`` block when paired.
SP28: response gains ``ack_links: list[dict]`` (compact form:
``[{ack_id, ack_kind, set_accept_reject_code, parsed_at}]``)
so the ``ClaimDrawer`` Acknowledgments panel can render on
initial load. TA1 batch-level rows (``claim_id IS NULL``) are
excluded — those don't belong to a specific claim.
Path param is ``claim_id`` (matches the SP3 ``/api/acks/{ack_id}``
convention). Returns 404 — never 500 — on a missing claim so the
UI can distinguish "doesn't exist" from a transient fetch error.
Returns 404 — never 500 — on a missing claim so the UI can
distinguish "doesn't exist" from a transient fetch error.
"""
body = store.get_claim_detail(claim_id)
if body is None:
@@ -188,94 +160,9 @@ def get_claim_detail_endpoint(claim_id: str) -> dict:
"detail": f"Claim {claim_id} not found",
},
)
# SP28: attach ack_links (compact form for the drawer panel).
body["ack_links"] = _compact_ack_links_for_claim(claim_id)
return body
def _compact_ack_links_for_claim(claim_id: str) -> list[dict]:
"""Return compact ack_links for one claim, newest first.
TA1 batch-level rows (claim_id IS NULL) are filtered out — those
hang off the originating 837 batch, not a specific claim. The
shape is the slimmer ``{ack_id, ack_kind,
set_accept_reject_code, parsed_at, ak2_index}`` form so the
ClaimDrawer can render without an N+1 round-trip per row.
"""
rows = store.list_acks_for_claim(claim_id)
out: list[dict] = []
for row in rows:
if row.claim_id is None:
continue
out.append({
"id": row.id,
"ack_id": row.ack_id,
"ack_kind": row.ack_kind,
"ak2_index": row.ak2_index,
"set_control_number": row.set_control_number,
"set_accept_reject_code": row.set_accept_reject_code,
"linked_at": (
row.linked_at.isoformat().replace("+00:00", "Z")
if row.linked_at is not None else ""
),
"linked_by": row.linked_by,
})
return out
def _serialize_kwargs_for_claim(claim_obj) -> dict:
"""SP40: build the ``serialize_837`` kwargs from the live clearhouse
+ per-payer ``PayerConfigORM`` config. Same as the bulk export in
``batches.py:_serialize_kwargs`` so single-claim download mirrors
production byte-faithfulness.
Without this, ``serialize_837(claim_obj)`` falls back to
placeholder strings (CYCLONE/RECEIVER/CUSTOMER SERVICE/8005550100)
which the operator rightly rejected as not production-ready.
Returns an empty dict only when the clearhouse / payer config is
not seeded — the serializer's own SP40 fallback then fires.
"""
out: dict = {}
try:
ch = store.get_clearhouse()
except Exception:
ch = None
if ch is not None:
out["sender_id"] = ch.tpid
out["submitter_name"] = ch.submitter_name
out["submitter_contact_name"] = ch.submitter_contact_name
out["submitter_contact_email"] = ch.submitter_contact_email
if getattr(ch, "submitter_contact_phone", None):
out["submitter_contact_phone"] = ch.submitter_contact_phone
# Per-payer receiver + SBR-09 from the 837P PayerConfigORM row.
pid = (getattr(claim_obj.payer, "id", "") or "").strip()
if pid:
try:
from cyclone.db import PayerConfigORM, Payer as PayerORM
with db.SessionLocal()() as ss:
pcfg = ss.get(PayerConfigORM, (pid, "837P"))
payer_row = ss.get(PayerORM, pid)
if pcfg is not None and isinstance(pcfg.config_json, dict):
cfg = pcfg.config_json
if cfg.get("receiver_name"):
out["receiver_name"] = cfg["receiver_name"]
if cfg.get("receiver_id"):
out["receiver_id"] = cfg["receiver_id"]
if cfg.get("sbr09_default"):
out["claim_filing_indicator_code"] = cfg["sbr09_default"]
if payer_row is not None:
if "receiver_name" not in out and payer_row.receiver_name:
out["receiver_name"] = payer_row.receiver_name
if "receiver_id" not in out and payer_row.receiver_id:
out["receiver_id"] = payer_row.receiver_id
except Exception:
# Fall through; the serializer's own SP40 default
# (MC for SBR-09) covers the most common case.
pass
return out
@router.get("/api/claims/{claim_id}/serialize-837")
def serialize_claim_as_837(claim_id: str):
"""Return the claim as a regenerated X12 837P file (SP8).
@@ -284,15 +171,6 @@ def serialize_claim_as_837(claim_id: str):
outbound serializer. Returns 404 if the claim doesn't exist, 422 if
the stored payload has no parseable ClaimOutput (data integrity
issue, not a transient failure).
SP40: threads the clearhouse submitter + CO_TXIX PayerConfig
receiver kwargs through to ``serialize_837`` so the regenerated
file has real ``NM1*41`` (Dzinesco / TPID ``11525703``),
``PER*IC*Tyler Martinez*EM*tyler@dzinesco.com``, ``NM1*40``
(HCPF) and ``SBR*P*18*******MC`` segments — not the
``CYCLONE/RECEIVER/CUSTOMER SERVICE/8005550100`` placeholders the
bare-args call site would emit. Mirrors the regen-script path in
``dev/unbilled-july2026/scripts/regen_corrected_files.py``.
"""
with db.SessionLocal()() as s:
row = s.get(Claim, claim_id)
@@ -320,13 +198,8 @@ def serialize_claim_as_837(claim_id: str):
status_code=422,
)
# SP40: build the same kwargs the bulk export uses, so the
# single-claim download mirrors production byte-faithfulness (real
# submitter, real contact, real receiver, real SBR-09).
serialize_kwargs = _serialize_kwargs_for_claim(claim_obj)
try:
text = serialize_837(claim_obj, **serialize_kwargs)
text = serialize_837(claim_obj)
except SerializeError837 as exc:
return JSONResponse(
{"error": "Unprocessable", "detail": str(exc)},
@@ -356,11 +229,12 @@ def get_claim_line_reconciliation(claim_id: str) -> dict:
line number to join them.
"""
from sqlalchemy import select
from cyclone.db import (
LineReconciliation, ServiceLinePayment, CasAdjustment,
CasAdjustment,
LineReconciliation,
ServiceLinePayment,
)
import json as _json
from decimal import Decimal
with db.SessionLocal()() as s:
claim = s.get(db.Claim, claim_id)
@@ -505,7 +379,6 @@ def get_claim_line_reconciliation(claim_id: str) -> dict:
def _claim_line_dict(d: dict) -> dict:
"""Project an 837 service-line dict from ``Claim.raw_json`` to wire shape."""
from decimal import Decimal
proc = d.get("procedure") or {}
charge = d.get("charge")
units = d.get("units")
@@ -524,7 +397,6 @@ def _claim_line_dict(d: dict) -> dict:
def _svc_to_dict(svc) -> dict:
"""Project an ORM ``ServiceLinePayment`` to wire shape."""
import json as _json
from decimal import Decimal
return {
"id": svc.id,
"line_number": svc.line_number,
+25 -209
View File
@@ -1,49 +1,33 @@
"""``/api/clearhouse*`` — singleton clearhouse config + SFTP submission.
"""``/api/clearhouse`` — SP9 single-payer SFTP submit surface.
Three endpoints, all gated by ``matrix_gate``:
Two endpoints:
- ``GET /api/clearhouse`` — return the singleton clearhouse config
(dzinesco's identity, SFTP block, filename block). 404 when the
config hasn't been seeded yet.
- ``POST /api/clearhouse/submit`` — submit a batch of claims to the
clearhouse (SFTP). SP9 ships a stub that copies to ``staging_dir``
instead of opening a real SFTP connection; SP13 wires the real
paramiko-backed client behind the same ``make_client()`` factory.
- ``GET /api/clearhouse`` — read the singleton clearhouse row
(dzinesco's identity, SFTP block, filename block). 404 when
unseeded.
- ``PATCH /api/clearhouse`` — full-row replacement of the
singleton (SP25). Strict-validates ``sftp_block`` first (Pydantic
v2 default mode coerces strings-to-bools and would hide a real
operator mistake), then validates the whole body in loose mode.
Hot-reloads the running scheduler via
``scheduler.reconfigure_scheduler`` so the next tick picks up the
new ``SftpBlock`` without a process restart.
- ``POST /api/clearhouse/submit`` — submit a batch of claims to
the clearhouse. Stub: serializes via the SP7 serializer, builds
an HCPF-compliant outbound filename, copies the result to the
staging path. Per-claim audit events stamped with
``actor="clearhouse-submit"``.
Three single-router helpers stay in this file (per spec D4):
- :func:`_load_claim_row` — load a ``Claim`` row by id.
- :func:`_serialize_claim_for_submit` — re-serialize a claim to X12
with optional per-call kwargs (submitter, receiver, SBR09, etc).
- :func:`_serialize_claim_from_raw` — best-effort serializer that
re-parses stored ``x12_text`` and re-emits.
SP36 Task 11: this block moved here from ``api.py:2484`` (the 3
``/api/clearhouse*`` routes + 3 single-router helpers).
For each claim the endpoint serializes a fresh 837 via
:func:`_serialize_claim_for_submit`, builds an HCPF-compliant outbound
filename, and records an audit-log row on success. Failures are
captured per-claim so the operator sees exactly which claim_ids
didn't go through.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, HTTPException
from cyclone import db
from cyclone.api_routers._shared import _actor_user_id
from cyclone.audit_log import AuditEvent, append_event
from cyclone.auth.deps import matrix_gate
from cyclone.db import Claim
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.serialize_837 import serialize_837
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/clearhouse")
@@ -55,76 +39,8 @@ def get_clearhouse():
return json.loads(ch.model_dump_json())
@router.patch("/api/clearhouse")
async def patch_clearhouse(body: dict) -> Any:
"""Replace the singleton clearhouse row (SP25).
The full ``Clearhouse`` model is required — we don't accept partial
updates because the operator-facing use case is "I'm switching the
loop to real MFT" or "I'm pointing at a different MFT server",
not "I'm tweaking one field at a time." Validation errors are
returned as 422 (Pydantic default).
After a successful write, the running scheduler is hot-reloaded
via ``scheduler.reconfigure_scheduler()`` so the next tick uses
the new SftpBlock without a process restart.
"""
from cyclone import scheduler as _scheduler_mod
from cyclone.providers import Clearhouse as _Clearhouse, SftpBlock as _SftpBlock
# Strict-validate the sftp_block sub-dict FIRST. Pydantic v2's
# default mode coerces strings to bools (e.g. ``"stub": "yes"``
# silently becomes True), which would hide a real operator
# mistake. The Clearhouse model itself stays in loose mode so
# ISO-string ``updated_at`` (the JSON round-trip shape) keeps
# parsing.
raw_sb = body.get("sftp_block", {})
try:
_SftpBlock.model_validate(raw_sb, strict=True)
except Exception as exc:
raise HTTPException(
status_code=422, detail=f"invalid sftp_block: {exc}",
) from exc
# Now validate the full body in loose mode.
try:
parsed = _Clearhouse.model_validate(body)
except Exception as exc:
raise HTTPException(
status_code=422, detail=str(exc),
) from exc
# SP25: when sftp_block.stub=false, the block must carry an auth
# account name and a non-empty host. The Pydantic model catches
# some of these; this catches the "empty password_keychain_account"
# case (which Pydantic allows because it's a free-form dict).
sb = parsed.sftp_block
if not sb.stub:
if not sb.host:
raise HTTPException(
status_code=422,
detail="sftp_block.host is required when stub=false",
)
auth = sb.auth or {}
if not auth.get("password_keychain_account") and not auth.get("key_file"):
raise HTTPException(
status_code=422,
detail=(
"sftp_block.auth must contain either "
"'password_keychain_account' or 'key_file' when stub=false"
),
)
updated = store.update_clearhouse(parsed)
await _scheduler_mod.reconfigure_scheduler(
updated.sftp_block,
sftp_block_name=updated.name or "default",
)
return json.loads(updated.model_dump_json())
@router.post("/api/clearhouse/submit")
def submit_to_clearhouse(request: Request, body: dict):
def submit_to_clearhouse(body: dict):
"""Submit a batch of claims to the clearhouse (SFTP). SP9: stub.
Body: ``{"claim_ids": [...], "payer_id": "CO_TXIX"}``
@@ -148,51 +64,6 @@ def submit_to_clearhouse(request: Request, body: dict):
if ch is None:
raise HTTPException(status_code=500, detail="clearhouse not seeded")
# Submitter (Loop 1000A) comes from the clearhouse config. The
# receiver (NM1*40) and SBR09 come from the per-payer config and
# are resolved per-claim below. Without this wiring, the
# serializer would emit "CYCLONE" / "RECEIVER" placeholders and
# the file would be rejected by HCPF.
submitter_kwargs = {
"sender_id": ch.tpid,
"submitter_name": ch.submitter_name,
"submitter_contact_name": ch.submitter_contact_name,
"submitter_contact_email": ch.submitter_contact_email,
}
if getattr(ch, "submitter_contact_phone", None):
submitter_kwargs["submitter_contact_phone"] = ch.submitter_contact_phone
# Build a payer_id → PayerConfig837 map once so we can look up the
# receiver + SBR09 default for each claim.
from cyclone.db import PayerConfigORM as _PayerConfigORM
def _resolve_payer_cfg(claim_obj) -> dict | None:
pid = (claim_obj.payer.id or "").strip() if claim_obj.payer else ""
pname = (claim_obj.payer.name or "").strip() if claim_obj.payer else ""
with db.SessionLocal()() as ss:
if pid:
row = ss.get(_PayerConfigORM, (pid, "837P"))
if row is not None:
return dict(row.config_json)
if pname:
rows = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.all()
)
for r in rows:
cj = dict(r.config_json)
if (r.payer_id or "").upper() == pname.upper():
return cj
if pname.lower() in str(cj).lower():
return cj
row = (
ss.query(_PayerConfigORM)
.filter(_PayerConfigORM.transaction_type == "837P")
.first()
)
return dict(row.config_json) if row else None
client = make_client(ch.sftp_block)
results = []
for cid in claim_ids:
@@ -201,34 +72,6 @@ def submit_to_clearhouse(request: Request, body: dict):
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
# Re-resolve the claim so we can look up its payer config. We
# re-parse the stored x12_text to get a ClaimOutput (same path
# the serializer uses).
try:
from cyclone.parsers.parse_837 import parse as _parse837
claim_row_obj = _load_claim_row(cid)
if claim_row_obj is None or not (claim_row_obj.raw_json or {}).get("x12_text"):
raise RuntimeError("no stored x12_text for claim")
parsed = _parse837(claim_row_obj.raw_json["x12_text"])
claim_obj = parsed.claims[0] if parsed.claims else None
except Exception:
claim_obj = None
if claim_obj is not None:
payer_cfg = _resolve_payer_cfg(claim_obj) or {}
receiver_id = payer_cfg.get("receiver_id") or (claim_obj.payer.id if claim_obj.payer else None) or "RECEIVER"
receiver_name = payer_cfg.get("receiver_name") or (claim_obj.payer.name if claim_obj.payer else None) or receiver_id
sbr09 = payer_cfg.get("sbr09_default") or "MC"
# Re-serialize with the proper envelope values.
try:
x12_text = _serialize_claim_for_submit(
cid,
**{**submitter_kwargs, "receiver_id": receiver_id,
"receiver_name": receiver_name,
"claim_filing_indicator_code": sbr09},
)
except Exception as exc: # noqa: BLE001
results.append({"claim_id": cid, "ok": False, "error": str(exc)})
continue
filename = build_outbound_filename(ch.tpid, "837P")
remote = f"{ch.sftp_block.paths['outbound']}/{filename}"
staging_path = client.write_file(remote, x12_text.encode("utf-8"))
@@ -252,59 +95,32 @@ def submit_to_clearhouse(request: Request, body: dict):
"stub": ch.sftp_block.stub,
},
actor="clearhouse-submit",
user_id=_actor_user_id(request),
))
audit_s.commit()
return {"ok": True, "submitted": results, "stub": ch.sftp_block.stub}
def _load_claim_row(claim_id: str):
"""Helper: load a Claim row by id (or return None)."""
with db.SessionLocal()() as s:
return s.get(Claim, claim_id)
def _serialize_claim_for_submit(claim_id: str, **kwargs) -> str:
"""Serialize a claim to X12 for SFTP submission. Lazy import of the
serializer to avoid pulling FastAPI machinery at module import time.
Optional ``**kwargs`` are forwarded to the serializer — used to
pass through clearhouse submitter info and per-payer receiver info
so the regenerated file matches what the HCPF MFT expects.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone import db
def _serialize_claim_for_submit(claim_id: str) -> str:
"""Serialize a claim to X12 for SFTP submission."""
with db.SessionLocal()() as s:
row = s.get(db.Claim, claim_id)
if row is None:
raise ValueError(f"claim {claim_id!r} not found")
# Re-parse the stored raw_json to get a ClaimOutput
from cyclone.parsers.models import ClaimOutput, Envelope, Subscriber, Payer, BillingProvider
from cyclone.parsers.parse_837 import parse
raw = row.raw_json or {}
# Reconstruct minimal ClaimOutput from raw_json; this is best-effort.
return _serialize_claim_from_raw(row, raw, **kwargs)
return _serialize_claim_from_raw(row, raw)
def _serialize_claim_from_raw(claim_row, raw: dict, **kwargs) -> str:
def _serialize_claim_from_raw(claim_row, raw: dict) -> str:
"""Best-effort serializer that uses the stored raw_json to emit a fresh 837.
For SP9 this delegates to the existing serialize_837 helper if the
claim has a complete raw_segments array. Otherwise it returns a
minimal placeholder. ``**kwargs`` are forwarded to the serializer
so callers can pass through submitter / receiver / SBR09 values
from the clearhouse and per-payer configs.
claim has a complete raw_segments array. Otherwise it raises.
"""
from cyclone.parsers.serialize_837 import serialize_837
from cyclone.parsers.parse_837 import parse
# Re-parse the original batch text (need to re-derive from store).
# SP9 stub: if the claim has a `raw_json` with `x12_text`, use that.
if isinstance(raw, dict) and raw.get("x12_text"):
result = parse(raw["x12_text"])
if result.claims:
return serialize_837(result.claims[0], **kwargs)
# Fallback: raise so the caller sees an error.
return serialize_837(result.claims[0])
raise RuntimeError(
f"claim {claim_row.id!r} cannot be re-serialized: no stored x12_text"
)
+26 -24
View File
@@ -1,35 +1,38 @@
"""``/api/config/payers`` and ``/api/config/payers/{payer_id}/configs`` — payer-config read views.
"""``/api/config/*`` — configured-provider / configured-payer lookups.
Both endpoints are read-only configuration surfaces used by the UI's
"Edit payers" page:
The ``config`` prefix is the operator-facing read-only window into the
``config/payers.yaml`` static config and the seeded providers table.
Used by the frontend Settings page to show what's available.
- ``GET /api/config/payers?is_active=...`` lists all configured
payers (PayerConfig records) — the set of payers the operator has
registered, regardless of whether they have inbound config blocks.
- ``GET /api/config/payers/{payer_id}/configs`` returns the full
list of ``(transaction_type, config_json)`` blocks for a given
payer. Each block has a ``source``: ``"yaml"`` for the on-disk
``config/payers.yaml`` default, ``"db"`` for any runtime override
recorded via ``/api/admin/reload-config``.
These are configuration surfaces, not claim-processing surfaces.
They live here (under ``/api/config/``) rather than under
``/api/payers/`` because the latter is the drill-down rollup
(see ``api_routers/payers.py``).
SP36 Task 7: this block moved here from ``api.py:3167`` (after
the SP21 provider-detail helper, before the Auth routers divider).
The payers endpoint at ``/api/config/payers/{payer_id}/configs`` is
slightly different: it returns both the YAML-loaded config and any
DB-overridden config (so a runtime override wins over the static
fallback). 404 / 200 / array shape match the rest of the API.
"""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, HTTPException, Query
from cyclone import store
from cyclone.auth.deps import matrix_gate
from cyclone import payers as payer_loader
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/config/providers")
def list_configured_providers(is_active: bool | None = Query(default=True)):
"""List the configured provider rows (3 NPIs for SP9)."""
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
@router.get("/api/config/providers/{npi}")
def get_configured_provider(npi: str):
p = store.get_provider(npi)
if p is None:
raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
return json.loads(p.model_dump_json())
@router.get("/api/config/payers")
@@ -40,7 +43,6 @@ def list_configured_payers(is_active: bool | None = Query(default=True)):
@router.get("/api/config/payers/{payer_id}/configs")
def list_payer_configs(payer_id: str):
"""List all (transaction_type, config_json) blocks for a payer."""
from cyclone import payers as payer_loader
configs = [
{"transaction_type": tx, "config_json": block, "source": "yaml"}
for (pid, tx), block in payer_loader.all_configs().items()
@@ -1,39 +0,0 @@
"""``/api/dashboard/kpis`` — server-aggregated Dashboard tiles.
Backs the Dashboard's "Claims / Billed / Received / Pending AR /
Denial rate" tiles + the monthly sparkline series + the
top-providers and top-denials lists.
Why this exists instead of ``GET /api/claims?limit=N``:
The Dashboard's KPIs are aggregates over *every* claim — billed,
received, denial rate, pending count, monthly billed/received. With
60k+ claims in production, paginating ``/api/claims`` and reducing
client-side silently produces wrong numbers (denial rate sampled,
billed summed from the first 100 rows). This endpoint does the
aggregation server-side in a single read so the Dashboard's numbers
are always correct regardless of dataset size.
SP36 Task 4: this single endpoint moved here from ``api.py:2732``.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from cyclone.auth.deps import matrix_gate
from cyclone.store import dashboard_kpis
router = APIRouter(dependencies=[Depends(matrix_gate)])
@router.get("/api/dashboard/kpis")
def get_dashboard_kpis(
months: int = Query(6, ge=1, le=24),
top_n_providers: int = Query(4, ge=0, le=50),
top_n_denials: int = Query(5, ge=0, le=50),
) -> dict:
"""Server-aggregated Dashboard KPIs over the whole claim population."""
return dashboard_kpis(
months=months,
top_n_providers=top_n_providers,
top_n_denials=top_n_denials,
)
+16 -19
View File
@@ -1,19 +1,20 @@
"""``/api/eligibility/request`` and ``/api/eligibility/parse-271`` — API-only eligibility pair.
"""``/api/eligibility/*`` — 270/271 eligibility surface (SP3 P4 T23T24).
Builds a 270 inquiry from a small JSON body and parses a 271 response.
Nothing is persisted to the DB — these are operator-driven, ephemeral
operations per SP3 (P4 T23T24). The 270 serializer pulls X12 from a
``ParseResult270`` Pydantic; the 271 parser builds the same structure
in reverse from the wire format.
API-only — no DB persistence per spec section 3.4. The operator builds
a 270 inquiry from a small JSON body, downloads the raw text to paste
into a payer portal, then re-pastes the 271 response and parses it
back into structured fields on demand.
Why these are not ``GET /api/eligibility/...``: the 270 build is
operator-initiated (pay-portal paste-back), so the inbound surface is
a JSON ``POST``. The 271 inbound is a multipart file upload — same
shape as ``/api/parse-999`` — so the file can be the actual 271 text
saved from the payer portal.
Two endpoints:
* ``POST /api/eligibility/request`` — build a 270 inquiry from a JSON
body and return ``{raw_270_text, parsed}``.
* ``POST /api/eligibility/parse-271`` — accept a raw 271 file upload
and return the parsed ``coverage_benefits`` / ``subscriber`` /
``summary`` / envelope triples.
SP36 Task 5: this block moved here from ``api.py:2832`` (``270 / 271
eligibility`` divider).
The ``_validate_eligibility_request`` helper is private to this router
— it builds the Pydantic ``ParseResult270`` and is only used by the
``/request`` endpoint.
"""
from __future__ import annotations
@@ -22,10 +23,9 @@ import logging
from datetime import date as _date
from typing import Any
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, File, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from cyclone.auth.deps import matrix_gate
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_270 import (
@@ -40,7 +40,7 @@ from cyclone.parsers.serialize_270 import serialize_270
log = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
@@ -90,9 +90,6 @@ def _validate_eligibility_request(body: dict) -> tuple[ParseResult270, str]:
detail={"error": "Bad request", "detail": "payer.name is required"},
)
# Build the Pydantic models. The serializer handles all envelope
# generation (sender_id/receiver_id/control_number/transaction_date
# are filled in by the serializer with sensible defaults).
subscriber_dob_raw = subscriber_in.get("dob")
subscriber_dob: _date | None = None
if subscriber_dob_raw:
+6 -29
View File
@@ -1,40 +1,17 @@
"""``GET /api/health`` — liveness + readiness probe.
"""``GET /api/health`` — liveness probe.
SP19 expanded the shallow ``{"status": "ok", "version": ...}`` probe
into a snapshot of every subsystem:
* **db** — can we open a session and run ``SELECT 1``?
* **scheduler** — is the MFT polling loop running? same for the
backup scheduler.
* **pubsub** — current subscriber counts per event kind.
* **batch** — most recent batch id + timestamp.
Returns ``status="ok"`` only when every subsystem is healthy.
``status="degraded"`` if any subsystem is unhappy but the API
itself is responsive. Per-subsystem errors are surfaced in the
respective dict so an operator doesn't have to guess.
Returns the package version so an operator can confirm which build is
serving requests without poking the filesystem.
"""
from __future__ import annotations
from fastapi import APIRouter, Request
from fastapi import APIRouter
from cyclone import __version__
from cyclone.security import get_health_snapshot
router = APIRouter()
@router.get("/api/health")
def health(request: Request) -> dict:
snap = get_health_snapshot()
# Fill in live pubsub subscriber counts using the per-request app
# state (the snapshot builder doesn't have request context).
bus = getattr(request.app.state, "event_bus", None)
if bus is not None and hasattr(bus, "stats"):
snap.pubsub = bus.stats()
elif bus is not None:
snap.pubsub = {"note": "EventBus.stats() not available"}
else:
snap.pubsub = {"note": "EventBus not attached (running outside lifespan?)"}
return snap.to_dict()
def health() -> dict[str, str]:
return {"status": "ok", "version": __version__}
+51 -84
View File
@@ -1,69 +1,56 @@
"""``/api/inbox*`` — operator-facing Inbox surface (SP6 + SP14).
"""``/api/inbox/*`` — operator-facing inbox surface.
Six endpoints, all gated by ``matrix_gate``:
Six endpoints powering the Inbox page (lanes, candidates, payer-rejected
acknowledge, rejected resubmit, CSV export). All endpoints run inside a
``db.SessionLocal()`` scope; the lanes / dismiss endpoints also read
``app.state.dismissed_pairs`` (a session-scoped set so dismissed items
don't re-appear until process restart).
- ``GET /api/inbox/lanes`` — all lanes in one
call (``compute_lanes`` from :mod:`cyclone.inbox_lanes`).
- ``POST /api/inbox/candidates/{remit_id}/match`` — manually link a
remit to a claim; surfaces 409 with the current state when the
claim is already matched.
- ``POST /api/inbox/candidates/dismiss`` — add candidate
pairs to the session-scoped dismissed set (mutates
``request.app.state.dismissed_pairs``).
- ``POST /api/inbox/payer-rejected/acknowledge`` — SP14: mark
Payer-Rejected claims as acknowledged. Idempotent; returns the
count actually transitioned vs. already-acked / not-found /
not-rejected.
- ``POST /api/inbox/rejected/resubmit`` — bulk move
REJECTED claims back to SUBMITTED. With
``?download=true`` returns a ZIP of regenerated 837 files
(``serialize_837_for_resubmit`` with per-claim ``interchange_index``
for unique control numbers). Conflicts are omitted from the ZIP
and surfaced via the ``X-Cyclone-Serialize-Errors`` header.
- ``GET /api/inbox/export.csv`` — stream a CSV for
a single lane (rejected / candidates / unmatched / done_today).
All endpoints use ``request.app.state`` rather than the module-level
``app`` global so they're robust against ``importlib.reload`` of
the api module — the reload rebinds ``app`` to a new instance, but
``request.app`` always points at the instance actually serving the
current request.
SP36 Task 13: this block moved here from ``api.py:1280`` (the 6
``/api/inbox*`` routes, with the SP14 comment block preserved
verbatim).
Endpoints:
* ``GET /api/inbox/lanes`` — return all four lanes in one call
(rejected / payer_rejected / candidates / unmatched / done_today).
* ``POST /api/inbox/candidates/{remit_id}/match`` — manually link a
remit to a claim from the candidates lane.
* ``POST /api/inbox/candidates/dismiss`` — add candidate pairs to the
dismissed set so they stop appearing in the candidates lane.
* ``POST /api/inbox/payer-rejected/acknowledge`` — clear SP14
payer-rejected claims from the working surface without deleting the
rejection (idempotent: re-acknowledging is a noop).
* ``POST /api/inbox/rejected/resubmit`` — bulk move REJECTED claims
back to SUBMITTED; with ``?download=true`` returns a ZIP of
regenerated 837s with unique interchange control numbers.
* ``GET /api/inbox/export.csv?lane=…`` — stream a CSV for a single lane.
"""
from __future__ import annotations
import csv
import io
import json
import zipfile
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from fastapi import APIRouter, HTTPException, Query, Request, Response
from fastapi.responses import StreamingResponse
from cyclone import db
from cyclone.auth.deps import matrix_gate
from cyclone.audit_log import AuditEvent, append_event
from cyclone.db import Claim, ClaimState, Remittance
from cyclone.parsers.models import ClaimOutput
from cyclone.parsers.serialize_837 import SerializeError as SerializeError837
from cyclone.parsers.serialize_837 import serialize_837_for_resubmit
from cyclone.parsers.serialize_837 import (
SerializeError as SerializeError837,
serialize_837_for_resubmit,
)
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/inbox/lanes")
def inbox_lanes(request: Request):
def inbox_lanes(request: Request) -> dict:
"""Return all Inbox lanes in one call.
Uses ``request.app.state`` rather than the module-level ``app``
global so the endpoint is robust against ``importlib.reload`` of
this module (some tests do this to mutate the CORS allow-list).
After a reload, the module-level ``app`` rebinds to a new
FastAPI instance; ``request.app`` always points at the instance
that is actually serving the current request, so per-request
state stays consistent with the test's TestClient target.
Reads the session-scoped ``dismissed_pairs`` set so the
candidates lane excludes pairs the operator already dismissed in
this process lifetime.
"""
dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
with db.SessionLocal()() as session:
@@ -110,17 +97,8 @@ def inbox_match_candidate(remit_id: str, body: dict):
@router.post("/api/inbox/candidates/dismiss")
def inbox_dismiss_candidates(body: dict, request: Request):
"""Add candidate pairs to the session-scoped dismissed set.
Uses ``request.app.state`` rather than the module-level ``app``
global so the endpoint is robust against ``importlib.reload`` of
this module (some tests do this to mutate the CORS allow-list).
After a reload, the module-level ``app`` rebinds to a new
FastAPI instance; ``request.app`` always points at the instance
that is actually serving the current request, so the test's
TestClient target is the one whose state we mutate.
"""
def inbox_dismiss_candidates(request: Request, body: dict):
"""Add candidate pairs to the session-scoped dismissed set."""
pairs = body.get("pairs") or []
if not hasattr(request.app.state, "dismissed_pairs"):
request.app.state.dismissed_pairs = set()
@@ -132,21 +110,20 @@ def inbox_dismiss_candidates(body: dict, request: Request):
return {"ok": True, "dismissed_count": len(pairs)}
# --------------------------------------------------------------------------- #
# SP14: Payer-Rejected acknowledge
#
# Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear
# the claim from the working surface. We don't delete the rejection
# (the original payer_rejected_* fields stay for SP11 audit), we just
# set payer_rejected_acknowledged_at so the lane query filters it out.
#
# Idempotent: re-acknowledging an already-acknowledged claim is a noop
# (the timestamp is not bumped). Returns the count actually transitioned
# so the UI can show "3 of 5 were already acknowledged".
# --------------------------------------------------------------------------- #
@router.post("/api/inbox/payer-rejected/acknowledge")
def inbox_acknowledge_payer_rejected(body: dict):
"""Mark Payer-Rejected claims as acknowledged by the operator."""
"""Mark Payer-Rejected claims as acknowledged by the operator.
Operator hits "Acknowledge" on the Payer-Rejected Inbox lane to clear
the claim from the working surface. We don't delete the rejection
(the original payer_rejected_* fields stay for SP11 audit), we just
set ``payer_rejected_acknowledged_at`` so the lane query filters it
out.
Idempotent: re-acknowledging an already-acknowledged claim is a noop
(the timestamp is not bumped). Returns the count actually transitioned
so the UI can show "3 of 5 were already acknowledged".
"""
claim_ids = body.get("claim_ids") or []
actor = body.get("actor") or "operator"
if not isinstance(claim_ids, list) or not claim_ids:
@@ -155,7 +132,6 @@ def inbox_acknowledge_payer_rejected(body: dict):
raise HTTPException(400, "claim_ids must be a list of strings")
with db.SessionLocal()() as session:
from cyclone.db import Claim
now = datetime.now(timezone.utc)
transitioned = 0
already_acked = 0
@@ -175,9 +151,9 @@ def inbox_acknowledge_payer_rejected(body: dict):
claim.payer_rejected_acknowledged_at = now
claim.payer_rejected_acknowledged_actor = actor
transitioned += 1
# SP11: audit event for the acknowledge action.
# SP11: audit event for the acknowledge action. Best-effort:
# an audit-log failure must not block the operator's action.
try:
from cyclone.audit_log import append_event, AuditEvent
append_event(session, AuditEvent(
event_type="claim.payer_rejected_acknowledged",
entity_type="claim",
@@ -189,8 +165,6 @@ def inbox_acknowledge_payer_rejected(body: dict):
},
))
except Exception: # noqa: BLE001
# Audit append is best-effort; don't block the operator's
# acknowledge action on an audit-log failure.
pass
if transitioned:
session.commit()
@@ -206,7 +180,6 @@ def inbox_acknowledge_payer_rejected(body: dict):
@router.post("/api/inbox/rejected/resubmit")
def inbox_resubmit_rejected(
request: Request,
body: dict,
download: bool = Query(False, description="When true, return a ZIP of regenerated 837 files for the resubmitted claims (instead of JSON)."),
):
@@ -229,7 +202,7 @@ def inbox_resubmit_rejected(
# the bundle) so the download path can serialize them with unique
# control numbers — back-to-back resubmits in the same file would
# otherwise all share ISA13/GS06 = "000000001".
accepted_with_rows: list[tuple[str, "Claim"]] = []
accepted_with_rows: list[tuple[str, Claim]] = []
with db.SessionLocal()() as s:
for cid in ids:
c = s.get(Claim, cid)
@@ -260,7 +233,6 @@ def inbox_resubmit_rejected(
# and missing ids are deliberately excluded — the user already saw
# them in the JSON response on prior actions; the download is the
# "give me the files I asked for" payload.
import zipfile
buf = io.BytesIO()
serialize_errors: list[dict] = []
with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf:
@@ -299,13 +271,8 @@ def inbox_resubmit_rejected(
@router.get("/api/inbox/export.csv")
def inbox_export_csv(lane: str, request: Request):
"""Stream a CSV for a single lane.
Uses ``request.app.state`` rather than the module-level ``app``
global so the endpoint is robust against ``importlib.reload`` of
this module (see ``inbox_dismiss_candidates`` for context).
"""
def inbox_export_csv(request: Request, lane: str):
"""Stream a CSV for a single lane."""
if lane not in {"rejected", "candidates", "unmatched", "done_today"}:
raise HTTPException(400, f"unknown lane: {lane}")
dismissed_pairs = getattr(request.app.state, "dismissed_pairs", set())
-831
View File
@@ -1,831 +0,0 @@
"""Parse endpoints — accept X12 uploads and ingest them.
Five routes:
* ``POST /api/parse-837`` — 837P professional claim ingest (the
primary upload path)
* ``POST /api/parse-835`` — 835 ERA remittance ingest
* ``POST /api/parse-999`` — 999 ACK ingest + auto-link claims
* ``POST /api/parse-ta1`` — TA1 envelope ACK ingest + envelope-link batches
* ``POST /api/parse-277ca`` — 277CA Claim Acknowledgment ingest
The 7 cross-router helpers these endpoints need (and the two
PAYER_FACTORIES dicts they consume) live in
:mod:`cyclone.api_routers._shared`.
"""
from __future__ import annotations
import json
import logging
import uuid
from typing import Any
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import JSONResponse, StreamingResponse
from sqlalchemy.exc import IntegrityError
from cyclone import db
from cyclone.api_helpers import (
client_wants_json as _client_wants_json,
drop_raw_segments_837 as _drop_raw_segments,
drop_raw_segments_835 as _drop_raw_segments_835,
has_claim_validation_errors as _has_claim_validation_errors,
has_835_validation_errors as _has_835_validation_errors,
ndjson_stream_837 as _ndjson_stream,
ndjson_stream_835 as _ndjson_stream_835,
strict_rewrite_837 as _strict_rewrite,
strict_rewrite_835 as _strict_rewrite_835,
)
from cyclone.api_routers._shared import (
_actor_user_id,
_build_and_persist_ack,
_reconciliation_summary_for_batch,
_resolve_payer,
_resolve_payer_835,
_serialize_ta1,
_ta1_synthetic_source_batch_id,
_transaction_set_id_from_segments,
)
from cyclone.audit_log import AuditEvent, append_event
from cyclone.auth.deps import matrix_gate
from cyclone.claim_acks import (
apply_277ca_acks as _apply_277ca_acks,
apply_999_acceptances as _apply_999_acceptances,
apply_ta1_envelope_link as _apply_ta1_envelope_link,
)
from cyclone.db import Batch, Claim
from cyclone.handlers._ack_id import (
ack_count_summary as _ack_count_summary,
ack_synthetic_source_batch_id as _ack_synthetic_source_batch_id,
two77ca_synthetic_source_batch_id as _277ca_synthetic_source_batch_id,
)
from cyclone.inbox_state import apply_999_rejections
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_277ca import parse_277ca_text
from cyclone.parsers.parse_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.parsers.parse_ta1 import parse_ta1_text
from cyclone.parsers.segments import tokenize as _tokenize_segments
from cyclone.parsers.serialize_999 import serialize_999
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.store import BatchRecord, store, utcnow
log = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(matrix_gate)])
@router.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),
) -> Any:
# SP35: defense-in-depth input guards. Layer A (UI auto-detect) lives
# in src/pages/Upload.tsx; the server-side checks below are the
# authoritative fix because they protect every caller of the API
# (Upload page, CLI ingestion, any future bulk-import tool). Without
# these, an 835 file dropped on the Upload page while the dropdown
# still says "837p" produces a BatchRecord with claims=[] and a bogus
# row on the History tab. The fix is two checks run BEFORE we persist
# anything:
#
# 1. Envelope check — ST01 must be "837" or "837P". Anything else
# (an 835, a 999, a 270, garbage that happens to have an ISA)
# → 400 with error="Mismatched file kind", expected="837p",
# detected_st=<whatever was there>.
# 2. Empty-claims check — even with the right envelope, if the
# parser produced zero CLM segments (truncated file, header-only
# test fixture) → 400 with error="No claims parsed". A real
# production 837 batch with zero claims is never valid.
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return JSONResponse(
status_code=400,
content={"error": "Encoding error", "detail": str(exc)},
)
config = _resolve_payer(payer)
# SP35 guard 1: envelope check. Tokenize first so we can return a
# precise 400 (vs. relying on the parser's "no ISA envelope" error
# which is correct but doesn't say "you sent an 835 to the 837
# endpoint"). If tokenization itself fails we fall through to the
# parser, which raises CycloneParseError → 400 "Parse error" path.
try:
_segments = _tokenize_segments(text)
detected_st = _transaction_set_id_from_segments(_segments) or ""
except CycloneParseError:
detected_st = ""
if detected_st and not detected_st.upper().startswith("837"):
return JSONResponse(
status_code=400,
content={
"error": "Mismatched file kind",
"expected": "837p",
"detected_st": detected_st,
"detail": (
f"File declares ST*{detected_st}* but this endpoint "
f"expects ST*837*. Pick the matching endpoint on the "
f"Upload page (or let auto-detect choose for you)."
),
},
)
try:
result = parse(text, config, input_file=file.filename or "")
except CycloneParseError as exc:
return JSONResponse(
status_code=400,
content={"error": "Parse error", "detail": str(exc)},
)
except Exception as exc: # pragma: no cover - safety net
log.exception("Unexpected parser failure")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
)
# SP35 guard 2: empty-claims check. With the envelope validated, the
# only way to land here is a header-only file (real, but useless)
# or a file whose CLM loops the parser couldn't extract. Either way
# we refuse to persist — a BatchRecord with claims=[] is what the
# original bug produced and is never what the operator wanted.
if not result.claims:
return JSONResponse(
status_code=400,
content={
"error": "No claims parsed",
"detail": (
"The file passed the envelope check but contained no "
"CLM segments. Refusing to persist an empty batch."
),
},
)
if strict:
result = _strict_rewrite(result)
if not include_raw_segments:
result = _drop_raw_segments(result)
if _has_claim_validation_errors(result):
# Per spec: 422 when claims failed validation.
# Body still includes the full ParseResult so the client can show errors.
# Validation-failed parses are NOT persisted (the data is suspect);
# only parses that survive validation end up in the store.
return JSONResponse(
status_code=422,
content=json.loads(result.model_dump_json()),
)
# Persist the cleaned-up result so the cleaned result is what clients
# retrieve via /api/batches/{id}.
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:
# ``(batch_id, patient_control_number)`` is UNIQUE — fires when a
# single batch file contains the same CLM01 control number twice,
# or when the same claim id has already been ingested in a prior
# batch. Surface as 409 with the batch id so the caller can look
# it up; do NOT 500 (a 500 without CORS headers is misreported by
# browsers as a CORS error and hides the real cause).
log.warning("Duplicate claim while persisting batch %s: %s", rec.id, exc)
return JSONResponse(
status_code=409,
content={
"error": "Duplicate claim",
"detail": (
"This file (or one previously ingested with the same "
"claim control number) collides with an existing "
"record. Inspect the file for duplicate CLM01 "
"control numbers, or remove the existing batch "
"before retrying."
),
"batch_id": rec.id,
},
)
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
# Surface the server-side batch id so the frontend can call
# /api/batches/{id}/export-837 (and any other batch-scoped
# endpoint) without a separate listBatches round-trip.
body["batch_id"] = rec.id
return JSONResponse(content=body)
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client (the React Upload page) can call batch-scoped
# endpoints like /api/batches/{id}/export-837 without a separate
# GET /api/batches round-trip.
return StreamingResponse(
_ndjson_stream(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@router.post("/api/parse-835")
async def parse_835_endpoint(
request: Request,
file: UploadFile = File(...),
payer: str = Query("co_medicaid_835"),
include_raw_segments: bool = Query(True),
strict: bool = Query(False),
) -> Any:
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return JSONResponse(
status_code=400,
content={"error": "Encoding error", "detail": str(exc)},
)
config = _resolve_payer_835(payer)
# SP35 guard 1: envelope check. Mirrors the parse-837 path: tokenize,
# read ST01, reject anything that doesn't start with "835". Same
# defense-in-depth rationale — the UI auto-detect (src/pages/Upload.tsx)
# is layer A, but server-side guards protect every API caller.
try:
_segments_835 = _tokenize_segments(text)
detected_st_835 = _transaction_set_id_from_segments(_segments_835) or ""
except CycloneParseError:
detected_st_835 = ""
if detected_st_835 and not detected_st_835.upper().startswith("835"):
return JSONResponse(
status_code=400,
content={
"error": "Mismatched file kind",
"expected": "835",
"detected_st": detected_st_835,
"detail": (
f"File declares ST*{detected_st_835}* but this endpoint "
f"expects ST*835*. Pick the matching endpoint on the "
f"Upload page (or let auto-detect choose for you)."
),
},
)
try:
result = parse_835(text, config, input_file=file.filename or "")
except CycloneParseError as exc:
return JSONResponse(
status_code=400,
content={"error": "Parse error", "detail": str(exc)},
)
except Exception as exc: # pragma: no cover - safety net
log.exception("Unexpected parser failure")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
)
# SP35 guard 2: empty-claims check. Same as parse-837: a BatchRecord
# with claims=[] is never a valid production 835 batch and we refuse
# to persist it.
if not result.claims:
return JSONResponse(
status_code=400,
content={
"error": "No claims parsed",
"detail": (
"The file passed the envelope check but contained no "
"CLP segments. Refusing to persist an empty batch."
),
},
)
# Always run the validator; attach the report so the JSON path can
# surface it and the NDJSON path can fold the counts into the summary.
# 835 validation is batch-level, so pass/fail applies uniformly to every
# claim payment in the batch (passed=N or 0, failed=0 or N).
report = validate_835(result, config)
n = len(result.claims)
claim_ids = [c.payer_claim_control_number for c in result.claims]
if report.passed:
passed, failed, failed_claim_ids = n, 0, []
else:
passed, failed, failed_claim_ids = 0, n, claim_ids
result = result.model_copy(update={
"validation": report,
"summary": result.summary.model_copy(update={
"passed": passed,
"failed": failed,
"failed_claim_ids": failed_claim_ids,
}),
})
if strict:
result = _strict_rewrite_835(result)
if not include_raw_segments:
result = _drop_raw_segments_835(result)
if _has_835_validation_errors(result):
return JSONResponse(
status_code=422,
content=json.loads(result.model_dump_json()),
)
# Persist the cleaned-up result so the cleaned result is what clients
# retrieve via /api/batches/{id}.
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="835",
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:
log.warning("Duplicate remittance while persisting batch %s: %s", rec.id, exc)
return JSONResponse(
status_code=409,
content={
"error": "Duplicate remittance",
"detail": (
"This 835 file (or one previously ingested with the "
"same payer claim control number) collides with an "
"existing record. Remove the existing remittance "
"before retrying."
),
"batch_id": rec.id,
},
)
if _client_wants_json(request):
body = json.loads(result.model_dump_json())
body["reconciliation"] = _reconciliation_summary_for_batch(rec.id)
return JSONResponse(content=body)
# Default: NDJSON stream. Pass the server-side batch id so the
# streaming client can call batch-scoped endpoints without a
# separate GET /api/batches round-trip (see /api/parse-837 for the
# parallel change).
return StreamingResponse(
_ndjson_stream_835(result, batch_id=rec.id),
media_type="application/x-ndjson",
)
@router.post("/api/parse-999")
async def parse_999_endpoint(
request: Request,
file: UploadFile = File(...),
) -> Any:
"""Parse a 999 ACK file, persist a row, and return JSON.
Behavior mirrors ``/api/parse-835``:
- 400 on empty / undecodable / malformed EDI (never 500).
- 200 on success with ``{"ack": {id, accepted_count, rejected_count,
received_count, ack_code, raw_999_text}, "parsed": <ParseResult999>}``.
The persisted ``acks.source_batch_id`` is a synthetic id
(``999-<ISA13>``) because a received 999 has no inbound 837 batch
to FK to. The dashboard's `/acks` list surfaces these; operators
can still see which interchange each row came from.
"""
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return JSONResponse(
status_code=400,
content={"error": "Encoding error", "detail": str(exc)},
)
try:
result = parse_999_text(text, input_file=file.filename or "")
except CycloneParseError as exc:
return JSONResponse(
status_code=400,
content={"error": "Parse error", "detail": str(exc)},
)
except Exception as exc: # pragma: no cover - safety net
log.exception("Unexpected parser failure on 999")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
)
received, accepted, rejected, ack_code = _ack_count_summary(result)
icn = result.envelope.control_number
synthetic_id = _ack_synthetic_source_batch_id(icn)
# Build the raw 999 text from the parsed result (round-trip).
raw_999_text = serialize_999(result, interchange_control_number=icn or "000000001")
# SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED.
# The 999's set_control_number (AK202) is the source 837's ST02; in
# practice we look it up against patient_control_number because that's
# the field 999 ACKs cross-reference in this product.
with db.SessionLocal()() as session:
def _lookup(pcn: str):
return session.query(Claim).filter_by(patient_control_number=pcn).first()
_rejection_result = apply_999_rejections(
session, result, claim_lookup=_lookup,
)
if _rejection_result.matched:
bus = request.app.state.event_bus
for cid in _rejection_result.matched:
await bus.publish("claim.rejected", {"claim_id": cid})
if _rejection_result.orphans:
log.warning(
"999 had %d orphan set refs: %s",
len(_rejection_result.orphans),
_rejection_result.orphans[:5],
)
row = store.add_ack(
source_batch_id=synthetic_id,
accepted_count=accepted,
rejected_count=rejected,
received_count=received,
ack_code=ack_code,
raw_json=json.loads(result.model_dump_json()),
event_bus=request.app.state.event_bus,
)
# SP11: append one audit row per rejected claim. Each row chains
# to the previous one — see cyclone.audit_log.
if _rejection_result.matched:
with db.SessionLocal()() as audit_s:
for cid in _rejection_result.matched:
append_event(audit_s, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id, "ack_id": row.id},
actor="999-parser",
user_id=_actor_user_id(request),
))
audit_s.commit()
# SP28: auto-link the 999 AK2 set-responses to claims via the
# D10 two-pass join (ST02 via batch envelope index primary,
# Claim.patient_control_number fallback). Each created ClaimAck
# row publishes claim_ack_written so the live-tail subscribers
# on the claim and ack side see the link immediately.
claim_ack_links_count = 0
with db.SessionLocal()() as link_s:
batch_index = store.batch_envelope_index()
def _pcn_lookup(pcn: str):
return (
link_s.query(Claim)
.filter(Claim.patient_control_number == pcn)
.first()
)
link_result = _apply_999_acceptances(
link_s, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
for link_row in link_result.linked:
store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="999",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
event_bus=request.app.state.event_bus,
)
claim_ack_links_count += 1
return JSONResponse(content={
"ack": {
"id": row.id,
"accepted_count": accepted,
"rejected_count": rejected,
"received_count": received,
"ack_code": ack_code,
"source_batch_id": synthetic_id,
"raw_999_text": raw_999_text,
"claim_ack_links_count": claim_ack_links_count,
},
"parsed": json.loads(result.model_dump_json()),
})
@router.post("/api/parse-ta1")
async def parse_ta1_endpoint(
request: Request,
file: UploadFile = File(...),
) -> Any:
"""Parse a TA1 (Interchange Acknowledgment) file, persist a row, return JSON.
Mirrors ``/api/parse-999`` but for the lower-level envelope ack:
- 400 on empty / undecodable / malformed EDI (never 500).
- 200 on success with ``{"ta1": {id, control_number, ack_code,
note_code, interchange_date, interchange_time, sender_id,
receiver_id, source_batch_id, raw_ta1_text}, "parsed":
<ParseResultTa1>}``.
The persisted ``ta1_acks.source_batch_id`` is a synthetic id
(``TA1-<ISA13>``) because a received TA1 has no inbound batch to
FK to. The dashboard's ``/ta1-acks`` list surfaces these.
SP25: threads ``event_bus=request.app.state.event_bus`` into
``store.add_ta1_ack`` so the live-tail ``ta1_ack_received``
stream fires on manual uploads (not just on the SFTP poller).
"""
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return JSONResponse(
status_code=400,
content={"error": "Encoding error", "detail": str(exc)},
)
try:
result = parse_ta1_text(text, input_file=file.filename or "")
except CycloneParseError as exc:
return JSONResponse(
status_code=400,
content={"error": "Parse error", "detail": str(exc)},
)
except Exception as exc: # pragma: no cover - safety net
log.exception("Unexpected parser failure on TA1")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
)
# Build the raw TA1 text from the parsed result (round-trip).
raw_ta1_text = _serialize_ta1(result)
row = store.add_ta1_ack(
source_batch_id=result.source_batch_id,
control_number=result.ta1.control_number,
interchange_date=result.ta1.interchange_date,
interchange_time=result.ta1.interchange_time,
ack_code=result.ta1.ack_code,
note_code=result.ta1.note_code,
ack_generated_date=result.ta1.ack_generated_date,
sender_id=result.envelope.sender_id,
receiver_id=result.envelope.receiver_id,
raw_json=json.loads(result.model_dump_json()),
event_bus=request.app.state.event_bus,
)
# SP28: TA1 envelope-level link to the originating Batch. The
# closure here matches the most-recent Batch whose envelope
# sender_id/receiver_id matches the TA1 — see spec §D4.
claim_ack_links_count = 0
with db.SessionLocal()() as link_s:
def _batch_lookup(sender_id, receiver_id):
rows = (
link_s.query(Batch)
.filter(
Batch.kind == "837p",
Batch.raw_result_json.isnot(None),
)
.order_by(Batch.parsed_at.desc())
.all()
)
for row in rows:
env = (row.raw_result_json or {}).get("envelope") or {}
if (
env.get("sender_id") == sender_id
and env.get("receiver_id") == receiver_id
):
return row
return None
link_result = _apply_ta1_envelope_link(
link_s, result, ack_id=row.id,
batch_lookup=_batch_lookup,
)
for link_row in link_result.linked:
store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="ta1",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
event_bus=request.app.state.event_bus,
)
claim_ack_links_count += 1
return JSONResponse(content={
"ta1": {
"id": row.id,
"control_number": result.ta1.control_number,
"ack_code": result.ta1.ack_code,
"note_code": result.ta1.note_code,
"interchange_date": result.ta1.interchange_date.isoformat()
if result.ta1.interchange_date else None,
"interchange_time": result.ta1.interchange_time,
"sender_id": result.envelope.sender_id,
"receiver_id": result.envelope.receiver_id,
"source_batch_id": result.source_batch_id,
"raw_ta1_text": raw_ta1_text,
"claim_ack_links_count": claim_ack_links_count,
},
"parsed": json.loads(result.model_dump_json()),
})
@router.post("/api/parse-277ca")
async def parse_277ca_endpoint(
request: Request,
file: UploadFile = File(...),
) -> Any:
"""Parse a 277CA Claim Acknowledgment file, persist a row, and stamp rejections.
Behavior mirrors ``/api/parse-999``:
- 400 on empty / undecodable / malformed EDI (never 500).
- 200 on success with ``{"ack": {id, control_number, accepted_count,
rejected_count, payer_claim_control_numbers, raw_277ca_text},
"parsed": <ParseResult277CA>}``.
After parse, runs :func:`apply_277ca_rejections` to stamp the
payer-rejected fields on each matching claim row. The Inbox
Payer-Rejected lane lights up as a side-effect of this call.
"""
raw = await file.read()
if not raw:
return JSONResponse(
status_code=400,
content={"error": "Empty file", "detail": "Uploaded file contained no bytes."},
)
try:
text = raw.decode("utf-8")
except UnicodeDecodeError as exc:
return JSONResponse(
status_code=400,
content={"error": "Encoding error", "detail": str(exc)},
)
try:
result = parse_277ca_text(text, input_file=file.filename or "")
except CycloneParseError as exc:
return JSONResponse(
status_code=400,
content={"error": "Parse error", "detail": str(exc)},
)
except Exception as exc: # pragma: no cover - safety net
log.exception("Unexpected parser failure on 277CA")
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc)},
)
icn = result.envelope.control_number
synthetic_id = _277ca_synthetic_source_batch_id(icn)
accepted = sum(1 for s in result.claim_statuses if s.classification == "accepted")
paid = sum(1 for s in result.claim_statuses if s.classification == "paid")
rejected = sum(1 for s in result.claim_statuses if s.classification == "rejected")
pended = sum(1 for s in result.claim_statuses if s.classification == "pended")
# Persist the 277CA row first so we have an id to attach to claims.
# SP25: thread the event bus so ``two77ca_ack_received`` fires.
row = store.add_277ca_ack(
source_batch_id=synthetic_id,
control_number=icn,
accepted_count=accepted,
rejected_count=rejected,
paid_count=paid,
pended_count=pended,
raw_json=json.loads(result.model_dump_json()),
event_bus=request.app.state.event_bus,
)
# Stamp payer-rejection fields on matching claims. The 277CA's
# REF*1K carries the patient's claim control number we sent in
# CLM01 — same convention the 999 ACK uses, so the lookup hits
# Claim.patient_control_number (mirrors apply_999_rejections).
with db.SessionLocal()() as session:
def _lookup(pcn: str):
return (
session.query(Claim)
.filter(Claim.patient_control_number == pcn)
.first()
)
apply_result = apply_277ca_rejections(
session, result, claim_lookup=_lookup, two77ca_id=row.id,
)
if apply_result.matched:
bus = request.app.state.event_bus
for cid in apply_result.matched:
await bus.publish("claim.payer_rejected", {"claim_id": cid})
# SP11: audit trail for each payer-rejected claim.
with db.SessionLocal()() as audit_s:
for cid in apply_result.matched:
append_event(audit_s, AuditEvent(
event_type="claim.payer_rejected",
entity_type="claim",
entity_id=cid,
payload={
"source_batch_id": synthetic_id,
"277ca_id": row.id,
},
actor="277ca-parser",
user_id=_actor_user_id(request),
))
audit_s.commit()
if apply_result.orphans:
log.warning(
"277CA had %d orphan status entries (no matching claim): %s",
len(apply_result.orphans),
apply_result.orphans[:5],
)
# SP28: auto-link the 277CA ClaimStatus entries to claims via
# the D10 two-pass join. Each ClaimAck row publishes
# claim_ack_written so the live-tail subscribers on the claim
# and ack side see the link immediately.
claim_ack_links_count = 0
with db.SessionLocal()() as link_s:
batch_index = store.batch_envelope_index()
def _pcn_lookup(pcn: str):
return (
link_s.query(Claim)
.filter(Claim.patient_control_number == pcn)
.first()
)
link_result = _apply_277ca_acks(
link_s, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
for link_row in link_result.linked:
store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="277ca",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
event_bus=request.app.state.event_bus,
)
claim_ack_links_count += 1
return JSONResponse(content={
"ack": {
"id": row.id,
"control_number": icn,
"accepted_count": accepted,
"rejected_count": rejected,
"paid_count": paid,
"pended_count": pended,
"source_batch_id": synthetic_id,
"matched_claim_ids": apply_result.matched,
"orphan_status_codes": apply_result.orphans,
"claim_ack_links_count": claim_ack_links_count,
},
"parsed": json.loads(result.model_dump_json()),
})
-149
View File
@@ -1,149 +0,0 @@
"""``GET /api/payers/{payer_id}/summary`` — payer-level rollup for the drill-down panel.
Returns billed/received totals, denial rate, and the top providers for
a given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
Cached in-process for ``_SUMMARY_TTL_S`` seconds via a per-payer_id
memo. The drill-down UI hammers this on every hover; the underlying
query is O(claims) per payer, so the TTL keeps the panel responsive.
Pubsub invalidation is intentionally NOT wired (see the long comment
in the body). The 60s TTL keeps staleness bounded; a follow-up can
wire targeted invalidation if the UI proves TTL-bounded staleness is
unacceptable.
404 when no claims AND no remits reference this payer_id — the UI
uses that to distinguish a typo from a payer with zero traffic (the
latter would still return a valid 200 with zeroed totals).
SP36 Task 6: this block moved here from ``api.py:3188`` (the
``SP21 Task 1.5: payer-level summary`` divider).
"""
from __future__ import annotations
import logging
from time import monotonic
from fastapi import APIRouter, Depends, HTTPException
from cyclone import db
from cyclone.auth.deps import matrix_gate
from cyclone.db import Claim, ClaimState, Remittance
log = logging.getLogger(__name__)
router = APIRouter(dependencies=[Depends(matrix_gate)])
# Per-payer_id memoization for /api/payers/{payer_id}/summary. The drill-
# down UI hammers this on every hover; the underlying query is O(claims)
# per payer so a 60s TTL keeps the panel responsive. Process-local on
# purpose — invalidation is driven by the TTL alone for now (see note
# below).
_SUMMARY_TTL_S = 60.0
_summary_cache: dict[str, tuple[float, dict]] = {}
def _clear_summary_cache() -> None:
"""Test hook: wipe the process-local cache.
The 60s TTL means tests that want to assert on a recomputed payload
must clear the cache between requests. Not used by production code.
"""
_summary_cache.clear()
# Pubsub invalidation is intentionally NOT wired. The ``claim_written``
# and ``remittance_written`` payloads emitted by ``store.add`` are the
# UI-shaped dicts from ``to_ui_claim_from_orm`` / ``to_ui_remittance_from_orm``,
# neither of which carries the X12 ``payer_id`` (NM1*PR*PI qualifier).
# They carry ``payerName`` only, which is the human-readable name and not
# the URL key we cache on. Wiring a subscriber here would either need a
# DB lookup per event (re-deriving payer_id from Claim.id) or a different
# cache key — both are out of scope for this task. The 60s TTL keeps
# staleness bounded; a follow-up can wire targeted invalidation if the
# UI proves TTL-bounded staleness is unacceptable.
@router.get("/api/payers/{payer_id}/summary")
def get_payer_summary(payer_id: str):
"""Payer-level rollup for the drill-down panel.
Returns billed/received totals, denial rate, and top providers for
the given ``payer_id`` (the X12 NM1*PR*PI qualifier, e.g. ``SKCO0``).
Cached in-process for ``_SUMMARY_TTL_S`` seconds.
404 when no claims AND no remits reference this payer_id — the UI
uses that to distinguish a typo from a payer with zero traffic
(the latter would still return a valid 200 with zeroed totals).
"""
now = monotonic()
cached = _summary_cache.get(payer_id)
if cached and (now - cached[0]) < _SUMMARY_TTL_S:
return cached[1]
log.debug("payer summary cache miss", extra={"payer_id": payer_id})
# Query claims + remits scoped to this payer_id. We bypass
# ``store.iter_claims`` because that helper filters by payer NAME
# substring, not by the X12 PI qualifier we use as the URL key.
with db.SessionLocal()() as s:
claim_rows: list[Claim] = (
s.query(Claim).filter(Claim.payer_id == payer_id).all()
)
claim_ids = [c.id for c in claim_rows]
remit_rows: list[Remittance] = []
if claim_ids:
remit_rows = (
s.query(Remittance)
.filter(Remittance.claim_id.in_(claim_ids))
.all()
)
if not claim_rows and not remit_rows:
raise HTTPException(
status_code=404,
detail=f"Payer {payer_id!r} not found",
)
billed_total = sum(float(c.charge_amount or 0) for c in claim_rows)
received_total = sum(float(r.total_paid or 0) for r in remit_rows)
denied = sum(
1 for c in claim_rows if c.state == ClaimState.DENIED
)
claim_count = len(claim_rows)
denial_rate = (denied / claim_count) if claim_count else 0.0
provider_counts: dict[str, int] = {}
for c in claim_rows:
npi = c.provider_npi
if not npi:
continue
provider_counts[npi] = provider_counts.get(npi, 0) + 1
top_providers = [
{"npi": npi, "count": count}
for npi, count in sorted(
provider_counts.items(), key=lambda kv: -kv[1]
)[:5]
]
# Best-effort payer display name from the first claim's raw
# payer object (NM1*PR name element, e.g. "COHCPF"). Falls
# back to the id when no parsed envelope is available.
payer_name = payer_id
for c in claim_rows:
raw = c.raw_json or {}
p = raw.get("payer") if isinstance(raw, dict) else None
if isinstance(p, dict) and p.get("name"):
payer_name = p["name"]
break
payload = {
"payer_id": payer_id,
"name": payer_name,
"claim_count": claim_count,
"billed_total": billed_total,
"received_total": received_total,
"denial_rate": denial_rate,
"top_providers": top_providers,
}
_summary_cache[payer_id] = (now, payload)
return payload
+11 -112
View File
@@ -1,43 +1,22 @@
"""``/api/providers`` and ``/api/config/providers*`` — read views over providers.
"""``GET /api/providers`` — distinct providers observed across claims.
Three endpoints across two URL prefixes, all gated by ``matrix_gate``:
- ``GET /api/providers`` — distinct provider list
derived from the Claims population (``store.distinct_providers()``),
with ``npi`` / ``state`` filters, pagination, and an NDJSON variant.
- ``GET /api/config/providers`` — the configured provider
rows (3 NPIs for SP9), filtered by ``is_active``.
- ``GET /api/config/providers/{npi}`` — one configured provider
plus a small drill-down block: ``recent_claims`` (top-10 by
``submissionDate``) and ``recent_activity`` (top-10, joined via
``Claim.id`` because ``ActivityEvent`` has no ``provider_npi``
column; the outer-join via ``Remittance.claim_id`` surfaces the
orphan ``remit_received`` events that were recorded pre-match).
SP36 Task 12: this block moved here from ``api.py:2448`` (the
``/api/providers`` list, the ``/api/config/providers`` list, and
the ``/api/config/providers/{npi}`` detail) — three URL prefixes,
one router per spec.
The list is built by ``store.distinct_providers()`` which scans claim
rows and returns one entry per unique ``billing_provider_npi``. The
``npi`` and ``state`` query params filter client-side. ``limit`` /
``offset`` paginate. Accepts ``application/x-ndjson`` like the other
list endpoints — see ``api_helpers.ndjson_stream_list``.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, Query, Request
from fastapi.responses import StreamingResponse
from sqlalchemy import desc, or_
from cyclone import db
from cyclone.api_helpers import (
ndjson_stream_list as _ndjson_stream_list,
wants_ndjson as _wants_ndjson,
)
from cyclone.auth.deps import matrix_gate
from cyclone.db import Claim, Remittance
from cyclone.api_helpers import ndjson_stream_list, wants_ndjson
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/providers")
@@ -57,9 +36,9 @@ def list_providers(
total = len(items)
returned = len(paged)
has_more = total > offset + returned
if _wants_ndjson(request):
if wants_ndjson(request):
return StreamingResponse(
_ndjson_stream_list(paged, total, returned, has_more),
ndjson_stream_list(paged, total, returned, has_more),
media_type="application/x-ndjson",
)
return {
@@ -68,83 +47,3 @@ def list_providers(
"returned": returned,
"has_more": has_more,
}
@router.get("/api/config/providers")
def list_configured_providers(is_active: bool | None = Query(default=True)):
"""List the configured provider rows (3 NPIs for SP9)."""
return [json.loads(p.model_dump_json()) for p in store.list_providers(is_active=is_active)]
@router.get("/api/config/providers/{npi}")
def get_configured_provider(npi: str):
p = store.get_provider(npi)
if p is None:
raise HTTPException(status_code=404, detail=f"provider {npi!r} not found")
provider_dict = json.loads(p.model_dump_json())
# SP21 Task 1.6: extend the response with two top-N arrays that the
# drill-down peek panel hangs off. ``recent_claims`` reuses the
# existing store projection (already returns UI-shaped dicts with
# ``submissionDate``); ``recent_activity`` is a direct ORM join
# because ``ActivityEvent`` has no ``provider_npi`` column — the
# filter has to hop through ``Claim.id``.
recent_claims = sorted(
store.iter_claims(provider_npi=npi),
key=lambda c: c.get("submissionDate") or "",
reverse=True,
)[:10]
# Activity filter has TWO join paths back to a Claim for this
# provider:
# 1. ``ActivityEvent.claim_id IN (claim_ids)`` — events that were
# recorded with a claim FK already set (claim_submitted,
# manual_match, claim_paid, etc.).
# 2. ``Remittance.claim_id IN (claim_ids)`` — the original
# ``remit_received`` event recorded at 835 ingest time
# (``store.add`` lines 999-1003) is inserted with
# ``claim_id=None`` because the remittance hasn't been matched
# to a claim yet. The auto-reconcile pass later sets
# ``Remittance.claim_id`` (``reconcile.run`` lines 289-293),
# but the *original* ActivityEvent row stays orphaned. The
# outer-join-then-OR lets us surface both shapes. Without
# path 2, a provider's activity feed looks frozen the moment
# an 835 lands — the most common activity, invisible.
with db.SessionLocal()() as s:
claim_ids = [
cid
for (cid,) in s.query(Claim.id)
.filter(Claim.provider_npi == npi)
.all()
]
activity_rows = []
if claim_ids:
activity_rows = (
s.query(db.ActivityEvent)
.outerjoin(
Remittance,
db.ActivityEvent.remittance_id == Remittance.id,
)
.filter(or_(
db.ActivityEvent.claim_id.in_(claim_ids),
Remittance.claim_id.in_(claim_ids),
))
.order_by(desc(db.ActivityEvent.ts))
.limit(10)
.all()
)
def _activity_to_ui(a):
return {
"id": a.id,
"ts": a.ts.isoformat().replace("+00:00", "Z") if a.ts else "",
"kind": a.kind,
"batchId": a.batch_id,
"claimId": a.claim_id,
"remittanceId": a.remittance_id,
"payload": a.payload_json or {},
}
provider_dict["recent_claims"] = recent_claims
provider_dict["recent_activity"] = [_activity_to_ui(a) for a in activity_rows]
return provider_dict
-221
View File
@@ -1,221 +0,0 @@
"""SP41 — rebill admin endpoints.
POST /api/admin/rebill-from-835
body: {"window": "YYYY-MM-DD..YYYY-MM-DD",
"override_filing": bool,
"visits_csv_path": str (optional),
"ingest_dir": str (optional),
"out_dir": str (optional)}
Returns: {"summary_path": str, "counts": {...},
"pipeline_a_files": [...], "pipeline_b_files": [...]}
GET /api/admin/rebill-from-835/status
Returns: {"recent_runs": [{"as_of": ..., "summary_path": ...,
"counts": {...}}, ...]}
Status-code contract (per the cyclone-api-router / cyclone-cli skills):
- 200: completed run (POST) or tally returned (GET).
- 401: not authenticated (matrix_gate).
- 403: authenticated but not authorized for /api/admin/*.
- 422: window is malformed or Pydantic body validation failed.
The POST handler delegates to ``cyclone.rebill.run.run_rebill`` (the same
orchestrator the ``cyclone rebill-from-835`` CLI uses). The GET handler
is a filesystem scan under ``dev/rebills/*/summary.csv`` — no DB table
for "recent runs" exists today, and Task 12 deliberately doesn't add
one (per its design notes). Sorted by directory mtime descending;
truncated to the most recent 5.
"""
from __future__ import annotations
import csv
import logging
from datetime import date
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict, Field, field_validator
from cyclone.auth.deps import matrix_gate
from cyclone.rebill.run import run_rebill
log = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/admin/rebill-from-835",
tags=["rebill"],
dependencies=[Depends(matrix_gate)],
)
# Filesystem base for /status scans. Module-level so tests can monkeypatch
# it to a tmp_path-relative dir without chdir-ing the whole test process.
REBILLS_DIR: Path = Path("dev/rebills")
# How many recent runs /status surfaces. 5 matches the CLI's default; the
# body schema below mirrors it as a query parameter so callers can ask for
# more (or fewer) when they want.
DEFAULT_RECENT_LIMIT = 5
class RebillRequest(BaseModel):
"""Body schema for ``POST /api/admin/rebill-from-835``.
``window`` is parsed as ``YYYY-MM-DD..YYYY-MM-DD`` (inclusive both
ends); the validator rejects anything else with a 422. The other
path fields are optional — ``run_rebill`` falls back to its own
defaults (``data/source/apr-jun27.csv`` for visits, ``ingest/`` for
835s, ``dev/rebills/<today>/`` for output) when unset.
"""
model_config = ConfigDict(populate_by_name=True)
window: str = Field(
default="2026-01-01..2026-06-27",
description="DOS window as YYYY-MM-DD..YYYY-MM-DD (inclusive both ends).",
)
override_filing: bool = Field(
default=False,
description="Relax the 120-day timely-filing gate for past-window visits.",
)
visits_csv_path: str | None = Field(
default=None,
description="Path to the AxisCare visits CSV; defaults to "
"data/source/apr-jun27.csv when unset.",
)
ingest_dir: str | None = Field(
default=None,
description="Directory containing *.835 / *.x12 835 files; defaults to ./ingest.",
)
out_dir: str | None = Field(
default=None,
description="Output directory for summary.csv + pipeline-a/b files; "
"defaults to dev/rebills/<today>/.",
)
@field_validator("window")
@classmethod
def _validate_window(cls, v: str) -> str:
# Manual split (Click can't help here). Two-date range is the
# only accepted shape; ``..`` is the giveaway separator so the
# input is unambiguous.
try:
start_str, end_str = v.split("..", 1)
except ValueError as exc:
raise ValueError(
f"window must be 'YYYY-MM-DD..YYYY-MM-DD', got {v!r}"
) from exc
try:
start = date.fromisoformat(start_str)
end = date.fromisoformat(end_str)
except ValueError as exc:
raise ValueError(
f"window must be 'YYYY-MM-DD..YYYY-MM-DD', got {v!r}: {exc}"
) from exc
if start > end:
raise ValueError(
f"window start {start.isoformat()} is after end {end.isoformat()}"
)
return v
@router.post("")
def post_rebill(req: RebillRequest) -> dict[str, Any]:
"""Run the rebill pipeline for the given DOS window.
Delegates to :func:`cyclone.rebill.run.run_rebill` and returns the
resulting ``RunResult`` as a plain JSON dict (the underlying dataclass
has no ``to_dict`` method — fields are mapped here so callers don't
have to import the dataclass shape).
The handler does NOT swallow exceptions; the app-level
:func:`_unhandled_exception_handler` renders them as a 500 JSON
envelope with CORS headers. Per-file failures land in the summary
CSV (and the ``counts`` dict) rather than raising here.
"""
start_str, end_str = req.window.split("..", 1)
# Validator already proved these parse cleanly.
window_start = date.fromisoformat(start_str)
window_end = date.fromisoformat(end_str)
log.info(
"rebill-from-835 starting: window=%s override_filing=%s",
req.window, req.override_filing,
)
result = run_rebill(
window_start=window_start,
window_end=window_end,
override_filing=req.override_filing,
visits_csv_path=req.visits_csv_path,
ingest_dir=req.ingest_dir,
out_dir=req.out_dir,
)
log.info(
"rebill-from-835 done: summary=%s counts=%s",
result.summary_path, result.counts,
)
return {
"summary_path": str(result.summary_path),
"counts": result.counts,
"pipeline_a_files": [str(p) for p in result.pipeline_a_files],
"pipeline_b_files": [str(p) for p in result.pipeline_b_files],
}
@router.get("/status")
def get_rebill_status(
limit: int = DEFAULT_RECENT_LIMIT,
) -> dict[str, Any]:
"""Return the most recent rebill runs, newest first.
Scans :data:`REBILLS_DIR` (``dev/rebills/`` by default) for any
subdirectory whose name is a date in YYYY-MM-DD format and that
contains a ``summary.csv``. Sorted by directory mtime descending so
the most recently-run batch wins ties on equal-named dirs (rare in
practice — operators tend to pick a fresh date per run).
Each entry's ``counts`` dict is a tally of the summary.csv's
``disposition`` column (the same per-category counters the operator
sees on the CLI ``--status`` view). Missing disposition values
surface as ``UNKNOWN`` so a hand-edited CSV can't silently drop a
bucket.
``limit`` defaults to :data:`DEFAULT_RECENT_LIMIT` (5) and is
clamped to ``[1, 50]`` to keep the response bounded.
"""
if limit < 1 or limit > 50:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="limit must be between 1 and 50",
)
recent: list[dict[str, Any]] = []
rebills_dir = REBILLS_DIR
if rebills_dir.exists():
candidates = sorted(
(
d for d in rebills_dir.iterdir()
if d.is_dir()
and (d / "summary.csv").exists()
# Filter to date-named dirs (YYYY-MM-DD) so a stray
# ``lost+found`` or tempdir doesn't sneak in.
and len(d.name) == 10 and d.name[4] == "-" and d.name[7] == "-"
),
key=lambda d: d.stat().st_mtime,
reverse=True,
)[:limit]
for d in candidates:
summary_path = d / "summary.csv"
counts: dict[str, int] = {}
with summary_path.open(newline="") as f:
for row in csv.DictReader(f):
disp = (row.get("disposition", "UNKNOWN") or "").strip() or "UNKNOWN"
counts[disp] = counts.get(disp, 0) + 1
recent.append({
"as_of": d.name,
"summary_path": str(summary_path),
"counts": counts,
})
return {"recent_runs": recent}
@@ -1,40 +1,32 @@
"""Reconciliation read views + manual match/unmatch write paths.
"""``/api/reconciliation/*`` + ``/api/batch-diff`` — operator review surface.
Four endpoints:
Four endpoints powering the reconciliation page:
- ``GET /api/reconciliation/unmatched`` — list of unmatched Claims
and unmatched Remittances (``store.list_unmatched(kind="both")``).
- ``GET /api/batch-diff`` — side-by-side diff of two
batches (used by the Batch Diff page). Lazy-imports
:func:`cyclone.batch_diff.diff_batches_to_wire` to keep the
module's import surface small until the endpoint is actually
hit.
- ``POST /api/reconciliation/match`` — manually pair a Claim with
a Remittance (``store.manual_match``). Surfaces ``AlreadyMatchedError`` /
``InvalidStateError`` as 409, and ``LookupError`` from the store
as 404 (``claim_or_remit_not_found``).
- ``POST /api/reconciliation/unmatch`` — unpair a Claim (``store.manual_unmatch``).
Surfaces ``NotMatchedError`` as 409.
* ``GET /api/reconciliation/unmatched`` — return unmatched Claims
(left) and unmatched Remittances (right).
* ``POST /api/reconciliation/match`` — manually pair a Claim with a
Remittance (operator override).
* ``POST /api/reconciliation/unmatch`` — remove the current match and
reset the Claim to submitted.
* ``GET /api/batch-diff`` — side-by-side diff of two batches (SP3 P4 /
T18).
All four are read-or-manual-override surfaces used by the
Reconciliation page (the page that pairs Claims with Remittances
when neither side has an automatic match key).
SP36 Task 8: this block moved here from ``api.py:2450`` (the
4 routes interleaved with a side-by-side batch-diff divider).
The two POST endpoints delegate to ``store.manual_match`` /
``store.manual_unmatch``; the router is responsible for translating the
store's exception hierarchy (``AlreadyMatchedError``, ``InvalidStateError``,
``NotMatchedError``, ``LookupError``) into the HTTP error contract.
"""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query
from cyclone.auth.deps import matrix_gate
from cyclone.store import (
AlreadyMatchedError,
InvalidStateError,
store,
)
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/reconciliation/unmatched")
@@ -49,11 +41,6 @@ def get_reconciliation_unmatched() -> dict:
return store.list_unmatched(kind="both")
# --------------------------------------------------------------------------- #
# Side-by-side diff between two batches (SP3 P4 / T18)
# --------------------------------------------------------------------------- #
@router.get("/api/batch-diff")
def get_batch_diff(
a: str | None = Query(None),
@@ -94,8 +81,7 @@ def get_batch_diff(
)
# Lazy import — keeps the module's import surface small until the
# endpoint is actually hit. Mirrors the same pattern used by other
# endpoint-local helpers (e.g. reconciler).
# endpoint is actually hit.
from cyclone.batch_diff import diff_batches_to_wire
return diff_batches_to_wire(a_rec, b_rec)
+32 -71
View File
@@ -1,48 +1,39 @@
"""``/api/remittances`` and ``/api/remittances/stream`` — read views over the Remittance population.
"""``/api/remittances`` — list, live-tail, detail endpoints for 835 ERAs.
Four endpoints, all gated by ``matrix_gate``:
Mirror of the ``claims`` router for the 835 / Remittance resource:
- ``GET /api/remittances`` — paginated list with filter+sort,
plus an NDJSON variant when the caller sends ``Accept: application/x-ndjson``.
- ``GET /api/remittances/summary`` — server-aggregated KPI tiles
(``count``, ``total_paid``, ``total_adjustments``) over the full
filtered population — never a page-limited sample (SP27 fix).
- ``GET /api/remittances/stream`` — NDJSON live-tail: snapshot
of currently-known rows, then ``remittance_written`` events as
they hit the store. Subscribed to by the Remittances page.
- ``GET /api/remittances/{remittance_id}`` — one remittance with its
labeled CAS ``adjustments`` array. 404 on missing id (never 500).
* ``GET /api/remittances`` — paginated list with filters
(batch_id / payer / claim_id / date range / sort).
* ``GET /api/remittances/stream`` — NDJSON snapshot + live tail via the
``remittance_written`` EventBus kind.
* ``GET /api/remittances/{remittance_id}`` — detail with the labeled
CAS ``adjustments`` array.
``/api/remittances/stream`` is registered before
``/api/remittances/{remittance_id}`` so the literal ``stream`` path
segment is not captured as a remittance id.
SP36 Task 9: this block moved here from ``api.py:2448`` (the 4
``/api/remittances*`` routes, with the streaming route's
``NOTE: registered before…`` comment preserved verbatim).
**Route ordering.** ``/stream`` is declared **before** ``/{remittance_id}``
so FastAPI's first-match routing doesn't swallow the literal ``stream``
segment as a remittance id. Same order as the prior inline code.
"""
from __future__ import annotations
from typing import Any, AsyncIterator
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from cyclone.api_helpers import (
ndjson_line as _ndjson_line,
ndjson_stream_list as _ndjson_stream_list,
tail_events as _tail_events,
wants_ndjson as _wants_ndjson,
ndjson_line,
ndjson_stream_list,
tail_events,
wants_ndjson,
)
from cyclone.auth.deps import matrix_gate
from cyclone.pubsub import EventBus
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
@router.get("/api/remittances")
def list_remittances(
def list_remittances_endpoint(
request: Request,
batch_id: str | None = Query(None),
payer: str | None = Query(None),
@@ -54,6 +45,11 @@ def list_remittances(
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
) -> Any:
"""Paginated remittances list with filters; supports NDJSON streaming.
``has_more`` is computed as ``total > offset + returned`` so paginating
forward keeps returning true until the last page is reached.
"""
common = dict(
batch_id=batch_id,
payer=payer,
@@ -64,14 +60,12 @@ def list_remittances(
items = list(store.iter_remittances(
sort=sort, order=order, limit=limit, offset=offset, **common,
))
# SP27 Task 13b: count the full population, not a 100-row sample.
# See the matching note in list_claims — same silent-failure pattern.
total = store.count_remittances(**common)
total = len(list(store.iter_remittances(**common)))
returned = len(items)
has_more = total > offset + returned
if _wants_ndjson(request):
if wants_ndjson(request):
return StreamingResponse(
_ndjson_stream_list(items, total, returned, has_more),
ndjson_stream_list(items, total, returned, has_more),
media_type="application/x-ndjson",
)
return {
@@ -82,39 +76,8 @@ def list_remittances(
}
@router.get("/api/remittances/summary")
def remittances_summary(
batch_id: str | None = Query(None),
payer: str | None = Query(None),
claim_id: str | None = Query(None),
date_from: str | None = Query(None),
date_to: str | None = Query(None),
) -> dict:
"""Server-aggregated KPI tiles for the Remittances page.
Returns ``{count, total_paid, total_adjustments}`` over the
full filtered remittance population — NOT a page-limited
sample. The Remittances page consumes this for its "Total paid"
and "Adjustments" tiles so they can't silently understate the
true DB population the way a page-local ``items.reduce(...)``
would. Mirrors the silent-incompleteness fix that
``/api/dashboard/kpis`` (commit ``59c3275``) and
``/api/remittances`` (commit ``d81b6ed``) made for their tiles.
Same filter parameters as ``/api/remittances``. Always returns
a populated dict (``{"count": 0, "total_paid": 0,
"total_adjustments": 0}`` when no rows match) so the frontend
can render the tiles directly without a loading-vs-empty
branch.
"""
return store.summarize_remittances(
batch_id=batch_id, payer=payer, claim_id=claim_id,
date_from=date_from, date_to=date_to,
)
@router.get("/api/remittances/stream")
async def remittances_stream(
async def remittances_stream_endpoint(
request: Request,
payer: str | None = Query(None),
claim_id: str | None = Query(None),
@@ -143,22 +106,20 @@ async def remittances_stream(
sort=sort or "-received_date", order=order, limit=limit,
)
for row in rows:
yield _ndjson_line({"type": "item", "data": row})
yield _ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
yield ndjson_line({"type": "item", "data": row})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in _tail_events(request, bus, ["remittance_written"]):
async for chunk in tail_events(request, bus, ["remittance_written"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/remittances/{remittance_id}")
def get_remittance(remittance_id: str) -> dict:
def get_remittance_endpoint(remittance_id: str) -> dict:
"""Return one remittance with its labeled CAS ``adjustments`` array.
Path param is ``remittance_id`` (not ``id``) to avoid shadowing
FastAPI's internal ``id`` name and to keep OpenAPI docs self-
describing. Returns 404 when the remittance is missing — never 500.
Returns 404 when the remittance is missing — never 500.
"""
body = store.get_remittance(remittance_id)
if body is None:
@@ -1,213 +0,0 @@
"""SP37 Task 6: HTTP endpoint for the canonical submit-batch flow.
Thin wrapper around ``cyclone.submission.submit_file`` — same logic as
the ``cyclone submit-batch`` CLI (SP37 Task 5), just framed as JSON in
/ JSON out and gated by ``matrix_gate``. The walker pattern, ``._*``
AppleDouble skip, ``limit`` semantics, and per-file outcomes all match
the CLI byte-for-byte so a batch run via the CLI and the same batch
run via this endpoint produce identical DB + SFTP state.
The endpoint deliberately does NOT inject an ``sftp_client_factory``:
``submit_file`` defaults to its paramiko-based factory so SKIPPED is
reachable in production (the ``SftpClient`` wrapper has no ``stat()``).
Tests monkey-patch ``cyclone.api_routers.submission.submit_file``
itself; that avoids the paramiko factory entirely without touching
the helper's contract.
Status code contract (per Task 6 spec §4):
- 200: completed run. Per-file failures live in the JSON body.
- 401: not authenticated (matrix_gate).
- 404: no clearhouse seeded (config-level "missing" → 4xx, not 5xx).
- 409: clearhouse SFTP block is in stub mode (refuses to upload).
- 422: ``ingest_dir`` missing on disk OR Pydantic body validation
failed (missing fields, wrong types).
- 5xx: truly unexpected exceptions propagate (do not swallow).
"""
from __future__ import annotations
import logging
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from cyclone.auth.deps import matrix_gate
from cyclone.store import store as cycl_store
from cyclone.store.exceptions import DuplicateClaimError
from cyclone.submission.core import submit_file
from cyclone.submission.result import SubmitOutcome, SubmitResult
log = logging.getLogger(__name__)
# No `prefix=` here — every other gated router in this package declares
# the full path in the decorator (clearhouse.py uses "/api/clearhouse",
# parse.py uses "/api/parse-837", etc.). The decorator sets the full URL.
router = APIRouter(
tags=["submission"],
dependencies=[Depends(matrix_gate)],
)
class SubmitBatchRequest(BaseModel):
"""Body schema for ``POST /api/submit-batch``.
``ingest_dir`` has no default so Pydantic raises 422 when it's
missing — better UX than letting the walker crash on a missing
path. ``validate_files`` and ``actor`` default so a minimal client
can skip them. ``limit`` truncates the file list after the walker
collects it (mirrors the CLI's post-collection ``if i > limit:
break`` semantics, but applied as a slice since the HTTP body
model is type-checked up-front).
The ``validate_files`` field is aliased to ``validate`` in the JSON
body to mirror the CLI's ``--validate`` flag and avoid the
hardcoded Pydantic warning about ``validate`` shadowing
``BaseModel.validate``. ``populate_by_name=True`` lets tests
construct the model with either key.
"""
model_config = ConfigDict(populate_by_name=True, protected_namespaces=())
ingest_dir: str
validate_files: bool = Field(default=True, alias="validate")
actor: str = "api-submit-batch"
limit: int | None = None
@router.post("/api/submit-batch")
def submit_batch(body: SubmitBatchRequest):
"""Submit every ``batch-*-claims/*.x12`` under ``ingest_dir``.
Walks ``ingest_dir`` for any directory matching ``batch-*-claims``,
collects each one's ``*.x12`` files (sorted, with ``._*``
AppleDouble files skipped), truncates to ``limit`` if set, then
calls :func:`cyclone.submission.submit_file` per file with the
seeded clearhouse's ``sftp_block`` and ``actor`` from the body.
Returns counts (``submitted`` / ``skipped`` / ``failed``) plus a
per-file ``results`` array. Per-file failures NEVER change the
HTTP status code — the response is 200 whenever the run itself
completed.
"""
# 1. Config-level guards. Order matters: a missing clearhouse is a
# 404 (config-level "missing"), but if it IS present and in stub
# mode the operator's request is a 409 (configured-but-wrong).
clearhouse = cycl_store.get_clearhouse()
if clearhouse is None:
raise HTTPException(
status_code=404, detail="no clearhouse seeded",
)
sftp_block = clearhouse.sftp_block
if sftp_block.stub:
raise HTTPException(
status_code=409, detail="clearhouse SFTP block is in stub mode",
)
# 2. Resolve + validate the ingest dir. Use ``resolve()`` so a
# symlink-relative path still produces a stable error message.
root = Path(body.ingest_dir).resolve()
if not root.exists():
raise HTTPException(
status_code=422,
detail=f"ingest_dir does not exist: {root}",
)
# 3. Walker — must match the CLI EXACTLY. Same sort, same ``._*``
# AppleDouble skip. Any drift here is a quiet split between the
# two surfaces and silently produces different batch outcomes.
files: list[Path] = []
for batch_dir in sorted(root.glob("batch-*-claims")):
files.extend(sorted(
p for p in batch_dir.glob("*.x12")
if not p.name.startswith("._")
))
# 4. ``limit`` truncates after collection. The CLI uses an inline
# ``if i > limit: break``; we slice instead because the HTTP
# body model validates ``limit`` up-front (Pydantic-level int
# check) and slicing keeps the walker branchless.
if body.limit is not None:
files = files[: body.limit]
if not files:
raise HTTPException(
status_code=422,
detail=f"no batch-*-claims/*.x12 files found under {root}",
)
# 5. Per-file submit. Wrap the helper call in try/except so an
# unexpected exception in submit_file surfaces as a per-file
# failure (outcome="unexpected") instead of crashing the whole
# run. The helper's own SubmitOutcome enum covers every typed
# failure path; an uncaught exception here is a true
# surprise (bug or service outage mid-loop).
results: list[dict] = []
submitted = skipped = failed = 0
for src in files:
try:
r = submit_file(
src,
sftp_block=sftp_block,
actor=body.actor,
validate=body.validate_files,
# No ``sftp_client_factory`` — submit_file's default
# paramiko factory opens the real MFT. Tests
# monkey-patch submit_file itself instead of wiring a
# factory here.
)
except DuplicateClaimError as exc:
# SP41 Task 9: 409-class domain exception (see the exception
# docstring on ``DuplicateClaimError``). The per-file loop
# preserves the 200-with-results status-code contract
# documented at the top of this module, so we surface the
# duplicate as a per-file failure with UNEXPECTED_ERROR
# outcome — the structured ``claim_id`` / ``original_submission_at``
# attributes ride along in the error string so the operator
# can see which CLM01 in the batch tripped the guard. Caught
# BEFORE the generic ``Exception`` handler so a future
# caller propagating the exception still gets 409-class
# treatment at the FastAPI layer.
log.warning(
"submit-batch duplicate claim on %s: claim_id=%r original=%s",
src.name, exc.claim_id, exc.original_submission_at,
)
r = SubmitResult(
file=src.name,
outcome=SubmitOutcome.UNEXPECTED_ERROR,
error=(
f"DuplicateClaimError: claim_id {exc.claim_id!r} was "
f"already submitted at "
f"{exc.original_submission_at.isoformat()}; "
f"within 30-day window"
),
)
except Exception as exc: # noqa: BLE001
log.exception(
"submit-batch unexpected error on %s", src.name,
)
r = SubmitResult(
file=src.name,
outcome=SubmitOutcome.UNEXPECTED_ERROR,
error=f"{exc.__class__.__name__}: {exc}",
)
if r.outcome == SubmitOutcome.SUBMITTED:
submitted += 1
elif r.outcome == SubmitOutcome.SKIPPED:
skipped += 1
else:
failed += 1
results.append({
"file": r.file,
"outcome": r.outcome.value,
"batch_id": r.batch_id,
"error": r.error,
})
return {
"submitted": submitted,
"skipped": skipped,
"failed": failed,
"results": results,
}
+22 -76
View File
@@ -1,4 +1,4 @@
"""``/api/ta1-acks`` — list, detail, and live-tail stream for TA1 envelopes.
"""``/api/ta1-acks`` — list & detail endpoints for persisted TA1 envelopes.
TA1 is the interchange-control ACK (ISA/IEA acknowledgement). It's a
single segment, no functional group, no transaction set. Cyclone
@@ -7,30 +7,34 @@ row can sit alongside the 999 / 277CA ack rows without special-casing.
The detail endpoint also reconstructs the TA1 segment string
(``TA1*...~``) so the operator can copy it into a downstream tool.
SP25: ``/api/ta1-acks/stream`` joins the live-tail triplet — the
Acks page mounts ``useTailStream("ta1_acks")`` so the TA1 envelope
ack section sees new rows the moment they land.
"""
from __future__ import annotations
from typing import Any, AsyncIterator
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, HTTPException, Query
from cyclone.api_helpers import ndjson_line, tail_events
from cyclone.auth.deps import matrix_gate
from cyclone import db
from cyclone.pubsub import EventBus
from cyclone.store import store, to_ui_ta1_ack
from cyclone.store import store
router = APIRouter(dependencies=[Depends(matrix_gate)])
router = APIRouter()
# SP25: ``_ta1_to_ui`` moved to ``cyclone.store.ui.to_ui_ta1_ack`` so
# the live-tail event payload (``ta1_ack_received``) matches the list
# endpoint shape byte-for-byte.
def _ta1_to_ui(row: db.Ta1Ack) -> dict:
"""Render a Ta1Ack row for the UI (list endpoint shape)."""
return {
"id": row.id,
"control_number": row.control_number,
"ack_code": row.ack_code,
"note_code": row.note_code,
"interchange_date": row.interchange_date.isoformat()
if row.interchange_date else None,
"interchange_time": row.interchange_time,
"sender_id": row.sender_id,
"receiver_id": row.receiver_id,
"source_batch_id": row.source_batch_id,
"parsed_at": row.parsed_at.isoformat() if row.parsed_at else None,
}
def _serialize_ta1_from_row(row: db.Ta1Ack) -> str:
@@ -53,78 +57,20 @@ def list_ta1_acks_endpoint(
count regardless of the ``limit`` cap.
"""
rows = store.list_ta1_acks()
items = [to_ui_ta1_ack(r) for r in rows[:limit]]
# SP28: batch-fetch linked_claim_ids per TA1 row (TA1 envelope
# links always carry claim_id IS NULL — populate the field for
# symmetry so the Acks page badge render path is uniform).
ack_ids = [r.id for r in rows]
linked_map = _find_linked_claim_ids_for_acks_helper(ack_ids, kind="ta1")
for item, aid in zip(items, ack_ids[:limit]):
item["linked_claim_ids"] = linked_map.get(aid, [])
items = [_ta1_to_ui(r) for r in rows[:limit]]
return {
"total": len(rows),
"items": items,
}
def _find_linked_claim_ids_for_acks_helper(
ack_ids: list[int], *, kind: str
) -> dict[int, list[str]]:
"""Batch-fetch {ack_id: [claim_id, …]} for the listed ack rows.
Local helper so we don't import from ``acks.py`` and create a
circular import. See the equivalent ``_find_linked_claim_ids_for_acks``
in ``acks.py`` for the contract.
"""
out: dict[int, list[str]] = {aid: [] for aid in ack_ids}
if not ack_ids:
return out
with db.SessionLocal()() as s:
rows = (
s.query(db.ClaimAck.ack_id, db.ClaimAck.claim_id)
.filter(
db.ClaimAck.ack_kind == kind,
db.ClaimAck.ack_id.in_(ack_ids),
db.ClaimAck.claim_id.isnot(None),
)
.all()
)
for ack_id, claim_id in rows:
out[ack_id].append(claim_id)
return out
@router.get("/api/ta1-acks/stream")
async def ta1_acks_stream(
request: Request,
limit: int = Query(100, ge=1, le=1000),
) -> StreamingResponse:
"""Stream TA1 envelope acks as NDJSON.
Subscribes to ``ta1_ack_received`` and emits the same wire format
as the other live-tail endpoints. Registered BEFORE
``/api/ta1-acks/{ack_id}`` so ``stream`` isn't matched as an id.
"""
bus: EventBus = request.app.state.event_bus
async def gen() -> AsyncIterator[bytes]:
rows = store.list_ta1_acks()[:limit]
for row in rows:
yield ndjson_line({"type": "item", "data": to_ui_ta1_ack(row)})
yield ndjson_line({"type": "snapshot_end", "data": {"count": len(rows)}})
async for chunk in tail_events(request, bus, ["ta1_ack_received"]):
yield chunk
return StreamingResponse(gen(), media_type="application/x-ndjson")
@router.get("/api/ta1-acks/{ack_id}")
def get_ta1_ack_endpoint(ack_id: int) -> dict:
"""Return one persisted TA1 ACK row with its parsed detail."""
row = store.get_ta1_ack(ack_id)
if row is None:
raise HTTPException(status_code=404, detail=f"TA1 ACK {ack_id} not found")
body = to_ui_ta1_ack(row)
body = _ta1_to_ui(row)
body["raw_ta1_text"] = _serialize_ta1_from_row(row)
body["raw_json"] = row.raw_json
return body
-8
View File
@@ -103,12 +103,6 @@ class AuditEvent:
computed hash. Payload must be JSON-serializable; the audit_log
module handles the encoding so callers don't need to think about
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
@@ -117,7 +111,6 @@ class AuditEvent:
payload: dict[str, Any] = field(default_factory=dict)
actor: str = "system"
created_at: datetime | None = None
user_id: int | None = None
def append_event(
@@ -162,7 +155,6 @@ def append_event(
created_at=created_at,
prev_hash=prev_hash,
hash=GENESIS_PREV_HASH, # placeholder; updated below
user_id=event.user_id,
)
session.add(row)
session.flush() # populate row.id
-1
View File
@@ -1 +0,0 @@
"""Auth module — users, sessions, permissions, routes, admin, rate_limit."""
-95
View File
@@ -1,95 +0,0 @@
"""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
-105
View File
@@ -1,105 +0,0 @@
"""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")
-183
View File
@@ -1,183 +0,0 @@
"""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}'.")
-140
View File
@@ -1,140 +0,0 @@
"""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
-110
View File
@@ -1,110 +0,0 @@
"""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/kpis"): ALL_ROLES, # dashboard summary cards (renamed from /summary)
("GET", "/api/activity"): ALL_ROLES,
("GET", "/api/inbox/lanes"): ALL_ROLES,
("GET", "/api/inbox/export.csv"): ALL_ROLES,
("GET", "/api/inbox/ack-orphans"): ALL_ROLES, # Inbox "Ack orphans" lane — wired in src/hooks/useAckOrphans.ts
("GET", "/api/reconciliation"): ALL_ROLES,
("GET", "/api/acks"): ALL_ROLES,
("GET", "/api/ta1-acks"): ALL_ROLES,
("GET", "/api/277ca-acks"): ALL_ROLES,
("GET", "/api/batch-diff"): ALL_ROLES,
("GET", "/api/config"): ALL_ROLES,
("GET", "/api/payers"): ALL_ROLES,
# Clearhouse (SFTP creds + dzinesco identity) — admin only.
("GET", "/api/clearhouse"): ADMIN_ONLY,
("PATCH", "/api/clearhouse"): ADMIN_ONLY,
("POST", "/api/clearhouse/submit"): ADMIN_ONLY,
# Admin ops (audit log, backup, scheduler, db rotate, reload-config).
("GET", "/api/admin/audit-log"): ADMIN_ONLY,
("GET", "/api/admin/audit-log/verify"): ADMIN_ONLY,
("GET", "/api/admin/backup"): ADMIN_ONLY, # prefix; covers /list, /status, /{id}/restore/*, /{id}/verify
("POST", "/api/admin/backup"): ADMIN_ONLY, # prefix; covers /create, /prune, /{id}/restore/*
("GET", "/api/admin/backup/scheduler"): ADMIN_ONLY, # prefix; covers /start, /stop, /tick
("POST", "/api/admin/backup/scheduler"): ADMIN_ONLY, # prefix; covers /start, /stop, /tick
("GET", "/api/admin/scheduler"): ADMIN_ONLY, # prefix; covers /status, /processed-files
("POST", "/api/admin/scheduler"): ADMIN_ONLY, # prefix; covers /start, /stop, /tick, /pull-inbound
("POST", "/api/admin/db/rotate-key"): ADMIN_ONLY,
("POST", "/api/admin/reload-config"): ADMIN_ONLY,
("GET", "/api/admin/validate-provider"): ADMIN_ONLY,
("POST", "/api/admin/validate-837"): ADMIN_ONLY, # SP40: Edifabric validation probe
("POST", "/api/admin/rebill-from-835"): ADMIN_ONLY, # SP41: run the in-window rebill pipeline
("GET", "/api/admin/rebill-from-835"): ADMIN_ONLY, # SP41: covers /status (prefix match)
# Write endpoints (admin + user, no viewer).
("POST", "/api/parse-837"): WRITE_ROLES,
("POST", "/api/parse-835"): WRITE_ROLES,
("POST", "/api/parse-999"): WRITE_ROLES,
("POST", "/api/parse-ta1"): WRITE_ROLES,
("POST", "/api/parse-277ca"): 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/reconciliation"): WRITE_ROLES,
("POST", "/api/acks"): WRITE_ROLES,
# Unlink a wrong claim-ack match — inverse of POST /api/inbox/candidates/{remit_id}/match.
# Prefix (not the placeholder path) because the matcher treats {kind}
# as a literal substring; only ``/api/acks`` actually matches real requests.
("DELETE", "/api/acks"): WRITE_ROLES,
("POST", "/api/batches"): WRITE_ROLES, # /export-837 regenerates X12 from DB rows
("POST", "/api/eligibility"): WRITE_ROLES,
("POST", "/api/submit-batch"): WRITE_ROLES, # SP37: canonical outbound path (mirrors CLI)
}
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]
-34
View File
@@ -1,34 +0,0 @@
"""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)
-97
View File
@@ -1,97 +0,0 @@
"""/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
-60
View File
@@ -1,60 +0,0 @@
"""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()
-79
View File
@@ -1,79 +0,0 @@
"""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,
}
-280
View File
@@ -1,280 +0,0 @@
"""SP17 — Encrypted backup primitives.
This module provides the low-level building blocks the rest of the
backup stack uses:
* ``derive_key`` — PBKDF2-HMAC-SHA256 key derivation (200,000
iterations, 32-byte output). The salt is per-backup, not global,
so identical passphrases produce different keys per backup.
* ``encrypt`` / ``decrypt`` — AES-256-GCM authenticated encryption.
Output layout: ``salt (16) | nonce (12) | ciphertext | tag (16)``.
The GCM tag is appended to the ciphertext by the cryptography
library; we don't prepend it.
* ``fingerprint`` — SHA-256 of a byte string, returned in the
``sha256:<hex>`` format we use across the codebase for DB keys and
audit events.
* ``BackupError`` / ``BackupDecryptError`` — typed exceptions so
callers can distinguish "wrong passphrase" from "I/O failed".
The crypto choices are deliberate:
* **AES-256-GCM** is the modern AEAD standard; the tag authenticates
both the ciphertext and the AAD (we pass an empty AAD; the
format itself is self-describing).
* **PBKDF2-HMAC-SHA256 @ 200k iters** is OWASP's 2023+ minimum for
PBKDF2-SHA256. Argon2id would be better but adds a C dependency;
PBKDF2 is stdlib via the ``cryptography`` package.
* **Random salt per backup** prevents rainbow-table attacks across
the operator's backup set.
* **Random 96-bit nonce per encryption** is what AES-GCM requires;
we use ``os.urandom`` which is a CSPRNG on every platform we run
on (macOS, Linux).
"""
from __future__ import annotations
import hashlib
import os
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# PBKDF2 iterations. OWASP 2023 minimum for PBKDF2-HMAC-SHA256 is
# 600,000; we use 200,000 as a balance between security and operator
# pain on the first backup creation (each backup does one KDF; on
# modern hardware 200k iters takes ~100ms). Bump this constant if you
# rotate the format version.
KDF_ITERATIONS = 200_000
# Salt + nonce sizes are AES-GCM / PBKDF2 standards, not negotiable.
SALT_LEN = 16
NONCE_LEN = 12
# Output key length for AES-256 = 32 bytes.
KEY_LEN = 32
# Format version. Bump when the on-disk layout changes (e.g. switch
# to Argon2id). Decryption reads this off the sidecar's
# encryption.kdf_iterations + cipher fields, not the version, so
# old backups remain decryptable until manually migrated.
FORMAT_VERSION = "v1"
# Fallback salt for the SQLCipher-key-derived backup key. Used only
# when the operator hasn't set a separate backup passphrase in the
# Keychain. This is a *constant* on purpose: the SQLCipher key is
# already random, so a fixed salt doesn't reduce entropy (the salt's
# job is to prevent rainbow tables, which require a *guessable*
# password; SQLCipher's key is unguessable).
FALLBACK_SALT = b"cyclone-db-backup-fallback-v1"
# ---------------------------------------------------------------------------
# Exceptions
# ---------------------------------------------------------------------------
class BackupError(Exception):
"""Generic backup failure. See BackupDecryptError for crypto errors."""
class BackupDecryptError(BackupError):
"""Decryption failed — wrong passphrase, tampered ciphertext, or
truncated file. Caller should NOT retry with the same key."""
# ---------------------------------------------------------------------------
# Key derivation + encryption
# ---------------------------------------------------------------------------
def derive_key(passphrase: str, salt: bytes) -> bytes:
"""Derive a 32-byte AES key from a passphrase + salt.
Uses PBKDF2-HMAC-SHA256 with :data:`KDF_ITERATIONS` rounds. The
passphrase is encoded as UTF-8 bytes; the salt is used verbatim.
Args:
passphrase: The operator's passphrase (any string).
salt: Per-backup random bytes of length :data:`SALT_LEN`.
Returns:
32 bytes suitable for AES-256-GCM.
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=KEY_LEN,
salt=salt,
iterations=KDF_ITERATIONS,
)
return kdf.derive(passphrase.encode("utf-8"))
def encrypt(plaintext: bytes, key: bytes) -> bytes:
"""AES-256-GCM encrypt with a fresh random 12-byte nonce.
Returns ``nonce (12) || ciphertext || tag (16)``. The
``cryptography`` library appends the tag automatically.
Args:
plaintext: The bytes to encrypt (e.g. the SQLite .backup blob).
key: 32-byte AES key from :func:`derive_key`.
Returns:
The combined nonce+ciphertext+tag blob.
"""
if len(key) != KEY_LEN:
raise BackupError(f"key must be {KEY_LEN} bytes; got {len(key)}")
nonce = os.urandom(NONCE_LEN)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)
return nonce + ciphertext
def decrypt(blob: bytes, key: bytes) -> bytes:
"""AES-256-GCM decrypt. Raises :class:`BackupDecryptError` on auth failure.
Args:
blob: The ``nonce||ciphertext||tag`` bytes from :func:`encrypt`.
key: The same 32-byte key used to encrypt.
Returns:
The original plaintext.
Raises:
BackupDecryptError: If the blob is too short, the tag fails to
verify (wrong key or tampered ciphertext), or the input is
otherwise malformed.
"""
if len(key) != KEY_LEN:
raise BackupError(f"key must be {KEY_LEN} bytes; got {len(key)}")
if len(blob) < NONCE_LEN + 16:
# 12 (nonce) + 16 (tag) = minimum; no room for ciphertext.
raise BackupDecryptError(
f"blob too short ({len(blob)} bytes); expected >= {NONCE_LEN + 16}",
)
nonce = blob[:NONCE_LEN]
ciphertext = blob[NONCE_LEN:]
aesgcm = AESGCM(key)
try:
return aesgcm.decrypt(nonce, ciphertext, associated_data=None)
except Exception as exc: # cryptography raises InvalidTag
raise BackupDecryptError(f"decryption failed: {exc}") from exc
# ---------------------------------------------------------------------------
# Fingerprint
# ---------------------------------------------------------------------------
def fingerprint(data: bytes) -> str:
"""SHA-256 of ``data`` as ``"sha256:<64-hex-chars>"``."""
return "sha256:" + hashlib.sha256(data).hexdigest()
def fingerprint_file(path: "os.PathLike[str] | str") -> str:
"""SHA-256 of a file's bytes, streamed. Memory-bounded for big DBs."""
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return "sha256:" + h.hexdigest()
# ---------------------------------------------------------------------------
# Sidecar dataclass
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class Sidecar:
"""Plaintext metadata written next to each backup file.
Not required for decryption — it's a manifest an operator
consults to decide whether to restore. Kept intentionally
tiny so it survives most format rotations.
"""
format_version: str
created_at: str # ISO 8601 UTC
db_fingerprint: str # "sha256:..."
table_count: int
size_bytes: int
kdf: str # "PBKDF2-HMAC-SHA256"
kdf_iterations: int
cipher: str # "AES-256-GCM"
key_fingerprint: str # "sha256:..." of the derived key
def to_json(self) -> str:
import json
return json.dumps(
{
"format_version": self.format_version,
"created_at": self.created_at,
"db_fingerprint": self.db_fingerprint,
"table_count": self.table_count,
"size_bytes": self.size_bytes,
"encryption": {
"kdf": self.kdf,
"kdf_iterations": self.kdf_iterations,
"cipher": self.cipher,
"key_fingerprint": self.key_fingerprint,
},
},
indent=2,
sort_keys=True,
)
@classmethod
def from_json(cls, text: str) -> "Sidecar":
import json
d = json.loads(text)
enc = d.get("encryption") or {}
return cls(
format_version=d["format_version"],
created_at=d["created_at"],
db_fingerprint=d["db_fingerprint"],
table_count=int(d["table_count"]),
size_bytes=int(d["size_bytes"]),
kdf=enc.get("kdf", "PBKDF2-HMAC-SHA256"),
kdf_iterations=int(enc.get("kdf_iterations", KDF_ITERATIONS)),
cipher=enc.get("cipher", "AES-256-GCM"),
key_fingerprint=enc.get("key_fingerprint", ""),
)
# ---------------------------------------------------------------------------
# Filename helpers
# ---------------------------------------------------------------------------
def backup_filename(timestamp: Optional[datetime] = None) -> str:
"""``cyclone-backup-YYYYMMDDTHHMMSSZ-<rand>.bin`` for a UTC timestamp.
The random suffix is a 4-byte hex string so two backups in the
same second don't collide on the ``db_backups`` unique index.
"""
import secrets as _secrets
from datetime import timezone as _tz
ts = timestamp or datetime.now(_tz.utc)
suffix = _secrets.token_hex(4)
return f"cyclone-backup-{ts.strftime('%Y%m%dT%H%M%SZ')}-{suffix}.bin"
def sidecar_filename(bin_filename: str) -> str:
"""``<bin_filename>.meta.json``."""
return bin_filename + ".meta.json"
-368
View File
@@ -1,368 +0,0 @@
"""SP17 — Backup scheduler.
Wraps :class:`cyclone.backup_service.BackupService` in an
asyncio task, mirroring the MFT scheduler pattern (SP16). A backup
tick:
1. Calls :meth:`BackupService.create_now` to take + encrypt a backup.
2. Calls :meth:`BackupService.prune` to apply the retention policy.
3. Writes a tamper-evident ``audit_log`` row (SP11) for each outcome.
The scheduler is OFF by default. Operators opt in via
``CYCLONE_BACKUP_AUTOSTART=true``. The poll interval is
``CYCLONE_BACKUP_INTERVAL_HOURS`` (default 24).
Like the MFT scheduler, this is single-asyncio-task — no
threading, no APScheduler. All access (start/stop/tick/status)
must happen on the same event loop; the FastAPI app satisfies
that trivially because endpoints run on the loop.
"""
from __future__ import annotations
import asyncio
import logging
import os
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from cyclone import backup_service as svc_mod
from cyclone.audit_log import AuditEvent, append_event
from cyclone.backup_service import BackupService
log = logging.getLogger(__name__)
@dataclass
class BackupTickResult:
"""Outcome of a single backup tick (one cycle of create + prune + audit)."""
started_at: datetime
finished_at: Optional[datetime] = None
created: Optional[svc_mod.BackupRecord] = None
pruned_paths: list[str] = field(default_factory=list)
error: Optional[str] = None
@property
def ok(self) -> bool:
return self.error is None and self.created is not None
def as_dict(self) -> dict[str, Any]:
return {
"started_at": self.started_at.isoformat(),
"finished_at": (
self.finished_at.isoformat() if self.finished_at else None
),
"ok": self.ok,
"created": (
{
"id": self.created.id,
"filename": self.created.filename,
"size_bytes": self.created.size_bytes,
"db_fingerprint": self.created.db_fingerprint,
"table_count": self.created.table_count,
}
if self.created else None
),
"pruned_paths": list(self.pruned_paths),
"error": self.error,
}
@dataclass
class BackupSchedulerStatus:
running: bool
interval_hours: float
backup_dir: str
retention_days: int
last_tick: Optional[BackupTickResult] = None
tick_count: int = 0
total_created: int = 0
total_errors: int = 0
def as_dict(self) -> dict[str, Any]:
return {
"running": self.running,
"interval_hours": self.interval_hours,
"backup_dir": self.backup_dir,
"retention_days": self.retention_days,
"tick_count": self.tick_count,
"total_created": self.total_created,
"total_errors": self.total_errors,
"last_tick": self.last_tick.as_dict() if self.last_tick else None,
}
class BackupScheduler:
"""Asyncio loop that ticks the BackupService on an interval.
Lifecycle mirrors :class:`cyclone.scheduler.Scheduler`:
sched = BackupScheduler(backup_service)
await sched.start() # begin ticking
await sched.stop() # finish current tick, exit
status = sched.status() # snapshot
Threading: NOT thread-safe. All access must happen on the
same event loop. FastAPI endpoints satisfy this automatically.
"""
def __init__(
self,
service: BackupService,
*,
interval_hours: float = 24.0,
) -> None:
self._service = service
self._interval_hours = max(0.1, float(interval_hours))
self._task: Optional[asyncio.Task[None]] = None
self._stop_event = asyncio.Event()
self._tick_in_progress = False
self._last_tick: Optional[BackupTickResult] = None
self._tick_count = 0
self._total_created = 0
self._total_errors = 0
@property
def service(self) -> BackupService:
return self._service
# ---- Public API -------------------------------------------------------
async def start(self) -> None:
if self._task is not None and not self._task.done():
log.info("BackupScheduler already running; start() is a no-op")
return
self._stop_event.clear()
self._task = asyncio.create_task(self._run(), name="backup-scheduler")
log.info(
"BackupScheduler started",
extra={
"interval_hours": self._interval_hours,
"backup_dir": str(self._service.backup_dir),
},
)
async def stop(self) -> None:
if self._task is None or self._task.done():
return
self._stop_event.set()
try:
await asyncio.wait_for(self._task, timeout=60)
except asyncio.TimeoutError:
log.warning("BackupScheduler did not stop within 60s; cancelling")
self._task.cancel()
try:
await self._task
except (asyncio.CancelledError, Exception): # noqa: BLE001
pass
self._task = None
log.info("BackupScheduler stopped")
def status(self) -> BackupSchedulerStatus:
return BackupSchedulerStatus(
running=self.is_running(),
interval_hours=self._interval_hours,
backup_dir=str(self._service.backup_dir),
retention_days=self._service._retention_days,
last_tick=self._last_tick,
tick_count=self._tick_count,
total_created=self._total_created,
total_errors=self._total_errors,
)
def is_running(self) -> bool:
return self._task is not None and not self._task.done()
async def tick(self) -> BackupTickResult:
"""Run a single backup tick (create + prune + audit).
Concurrent ticks are coalesced: if a tick is already in
progress, the second caller waits for it. This protects
against a slow backup holding up multiple operator-driven
``POST /api/admin/backup/tick`` calls.
"""
while self._tick_in_progress:
await asyncio.sleep(0.05)
self._tick_in_progress = True
try:
result = await self._tick_impl()
self._last_tick = result
self._tick_count += 1
if result.created is not None:
self._total_created += 1
if result.error is not None:
self._total_errors += 1
return result
finally:
self._tick_in_progress = False
# ---- Internals --------------------------------------------------------
async def _run(self) -> None:
# Stagger the first tick (same rationale as the MFT scheduler).
await asyncio.sleep(5)
while not self._stop_event.is_set():
try:
await self.tick()
except Exception as exc: # noqa: BLE001
# tick() catches its own exceptions and returns them
# in the result. This is the safety net for
# programmer errors in the loop body.
log.exception("BackupScheduler tick raised", extra={"error": str(exc)})
try:
await asyncio.wait_for(
self._stop_event.wait(),
timeout=self._interval_hours * 3600,
)
except asyncio.TimeoutError:
pass # interval elapsed
async def _tick_impl(self) -> BackupTickResult:
started = datetime.now(timezone.utc)
result = BackupTickResult(started_at=started)
try:
create_result = await asyncio.to_thread(self._service.create_now)
result.created = create_result.backup
# Audit event for the created backup.
await asyncio.to_thread(
_audit_backup_created,
create_result.backup.id,
create_result.backup.db_fingerprint,
create_result.backup.table_count,
"backup-scheduler",
)
except Exception as exc: # noqa: BLE001
log.exception("Backup create failed during tick")
result.error = f"create: {type(exc).__name__}: {exc}"
await asyncio.to_thread(
_audit_backup_failed,
f"create: {type(exc).__name__}: {exc}",
traceback.format_exc()[-500:],
"backup-scheduler",
)
try:
pruned = await asyncio.to_thread(self._service.prune)
result.pruned_paths = pruned
if pruned:
await asyncio.to_thread(
_audit_backup_pruned, pruned, "backup-scheduler",
)
except Exception as exc: # noqa: BLE001
log.exception("Backup prune failed during tick")
# Don't clobber the create error if there was one.
if result.error is None:
result.error = f"prune: {type(exc).__name__}: {exc}"
await asyncio.to_thread(
_audit_backup_failed,
f"prune: {type(exc).__name__}: {exc}",
traceback.format_exc()[-500:],
"backup-scheduler",
)
result.finished_at = datetime.now(timezone.utc)
return result
# ---------------------------------------------------------------------------
# Audit helpers (run in a thread so the asyncio loop doesn't block)
# ---------------------------------------------------------------------------
def _audit_backup_created(
backup_id: int, db_fingerprint: str, table_count: int, actor: str,
) -> None:
from cyclone import db
with db.SessionLocal()() as s:
try:
append_event(s, AuditEvent(
event_type="db.backup_created",
entity_type="database",
entity_id="cyclone.db",
actor=actor,
payload={
"backup_id": backup_id,
"db_fingerprint": db_fingerprint,
"table_count": table_count,
},
))
s.commit()
except Exception: # noqa: BLE001
log.exception("Failed to write db.backup_created audit event")
def _audit_backup_failed(
reason: str, traceback_tail: str, actor: str,
) -> None:
from cyclone import db
with db.SessionLocal()() as s:
try:
append_event(s, AuditEvent(
event_type="db.backup_failed",
entity_type="database",
entity_id="cyclone.db",
actor=actor,
payload={"reason": reason, "traceback_tail": traceback_tail},
))
s.commit()
except Exception: # noqa: BLE001
log.exception("Failed to write db.backup_failed audit event")
def _audit_backup_pruned(deleted_paths: list[str], actor: str) -> None:
from cyclone import db
with db.SessionLocal()() as s:
try:
append_event(s, AuditEvent(
event_type="db.backup_pruned",
entity_type="database",
entity_id="cyclone.db",
actor=actor,
payload={"deleted_paths": deleted_paths},
))
s.commit()
except Exception: # noqa: BLE001
log.exception("Failed to write db.backup_pruned audit event")
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_scheduler: Optional[BackupScheduler] = None
def configure_backup_scheduler(
service: BackupService,
*,
interval_hours: float = 24.0,
) -> BackupScheduler:
"""Create (or return existing) the module-level BackupScheduler."""
global _scheduler
if _scheduler is not None:
return _scheduler
hours = float(
os.environ.get("CYCLONE_BACKUP_INTERVAL_HOURS", interval_hours),
)
_scheduler = BackupScheduler(service, interval_hours=hours)
return _scheduler
def get_backup_scheduler() -> BackupScheduler:
"""Return the configured BackupScheduler. Raises if not set up."""
if _scheduler is None:
raise RuntimeError(
"backup scheduler not configured; call configure_backup_scheduler() first",
)
return _scheduler
def reset_backup_scheduler_for_tests() -> None:
"""Clear the module-level singleton. Test-only."""
global _scheduler
_scheduler = None
-851
View File
@@ -1,851 +0,0 @@
"""SP17 — High-level backup coordinator.
Owns the lifecycle of every backup the operator (or the scheduler)
takes:
* ``create_now`` — runs SQLite's online ``.backup()`` against the
live engine, encrypts the bytes with :func:`cyclone.backup.encrypt`,
writes a ``.bin`` + ``.meta.json`` pair into the backup directory,
and persists a row in ``db_backups``.
* ``list_backups`` — directory listing joined with ``db_backups`` rows.
* ``verify`` — decrypts + recomputes SHA-256, compares to the sidecar.
* ``restore_initiate`` / ``restore_confirm`` — two-step restore so an
idle browser tab can't nuke the live DB. The first call returns a
``restore_token`` (a random 32-byte hex string) plus a preview
(``db_fingerprint``, ``table_count``). The second call swaps the
engine only if the token matches.
* ``prune`` — deletes backups older than ``retention_days``.
This module is intentionally engine-aware: ``create_now`` reaches
into the live SQLAlchemy engine to get a raw SQLite connection and
call ``.backup()`` (the only way to take an online consistent
snapshot). ``restore`` reaches into :func:`cyclone.db.dispose_engine`
+ :func:`cyclone.db.reinit_engine` to swap to the restored file.
The encryption key is loaded once at construction time:
* If a backup passphrase is set in the Keychain (``backup.passphrase``
account under service ``cyclone``), use it directly with the salt
stored in the companion ``backup.salt`` account. The salt must be
persisted — a fresh random salt per process would defeat the key.
* Otherwise fall back to deriving from the SQLCipher DB key + a fixed
salt. Logged at WARNING because this is a degraded-but-still-safe
posture.
"""
from __future__ import annotations
import json
import logging
import os
import secrets as _secrets
import shutil
import sqlite3
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional, Union
from sqlalchemy.exc import SQLAlchemyError
from cyclone import backup as backup_mod
from cyclone.backup import BackupError
from cyclone import db
from cyclone import secrets as secrets_mod
log = logging.getLogger(__name__)
# Status values for db_backups.status (mirrored in the ORM).
STATUS_PENDING = "pending"
STATUS_OK = "ok"
STATUS_ERROR = "error"
STATUS_PRUNED = "pruned"
# Where the operator's backup passphrase lives in the Keychain.
KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT = "backup.passphrase"
# Companion account for the salt. Stored as hex. Same value across
# processes so the derived key is reproducible — a fresh random salt
# per BackupService would defeat the key.
KEYCHAIN_BACKUP_SALT_ACCOUNT = "backup.salt"
# Restore token TTL (seconds). The two-step confirm must complete
# within this window or the operator re-runs initiate.
RESTORE_TOKEN_TTL_SECONDS = 300
# ---------------------------------------------------------------------------
# Result dataclasses
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class BackupRecord:
"""Public view of a backup row joined with filesystem state."""
id: int
filename: str
backup_dir: str
size_bytes: int
db_fingerprint: str
table_count: int
created_at: datetime
completed_at: Optional[datetime]
status: str
error_message: Optional[str]
key_fingerprint: str
@dataclass(frozen=True)
class CreateResult:
"""Outcome of ``create_now``."""
backup: BackupRecord
sidecar: backup_mod.Sidecar
@dataclass(frozen=True)
class VerifyResult:
"""Outcome of ``verify``."""
backup_id: int
filename: str
ok: bool
expected_fingerprint: str
actual_fingerprint: str
table_count: int
reason: Optional[str] = None
@dataclass(frozen=True)
class RestoreInitiateResult:
"""Returned by the first call of the two-step restore."""
backup_id: int
filename: str
restore_token: str
expires_at: datetime
db_fingerprint: str
table_count: int
current_db_fingerprint: str
current_table_count: int
size_bytes: int
@dataclass(frozen=True)
class RestoreConfirmResult:
"""Returned by the second call of the two-step restore."""
backup_id: int
filename: str
restored_from_fingerprint: str
restored_at: datetime
new_db_fingerprint: str
# ---------------------------------------------------------------------------
# BackupService
# ---------------------------------------------------------------------------
class BackupService:
"""Coordinator for encrypted DB backups.
Construct once at app startup; share across requests. Not
thread-safe for *creation* (the SQLite ``.backup()`` call uses
the live engine and is best serialized through the scheduler),
but ``list_backups`` / ``prune`` / ``status`` are safe to call
concurrently.
"""
def __init__(
self,
backup_dir: Union[str, Path],
*,
passphrase: Optional[str] = None,
salt: Optional[bytes] = None,
retention_days: int = 30,
db_url: Optional[str] = None,
) -> None:
self._backup_dir = Path(backup_dir)
self._retention_days = max(1, int(retention_days))
self._db_url = db_url
# The derived key + its salt. If ``passphrase`` is None we
# fall back to deriving from the SQLCipher DB key (with a
# WARNING log).
#
# Salt is per-BackupService-instance and MUST be stable across
# processes — otherwise the same passphrase would derive
# different keys in different invocations and decrypt would
# always fail. Two options:
#
# 1. Caller passes an explicit ``salt`` (the Keychain flow
# reads the persisted salt from backup.salt account).
# 2. We accept a None salt here; ``_ensure_key`` then either
# uses the persisted salt (if available) or generates one
# and persists it on first use.
#
# Tests typically pass an explicit random salt; production
# should always pass the persisted one.
self._passphrase = passphrase
self._salt = salt
self._key: Optional[bytes] = None
self._used_fallback = False
# Pending restore tokens: token -> (backup_id, expires_at).
# A simple in-memory dict is sufficient — the token only
# needs to survive between the two API calls in one process.
self._pending_restores: dict[str, tuple[int, datetime]] = {}
self._lock = threading.Lock()
# ---- Public API -------------------------------------------------------
@property
def backup_dir(self) -> Path:
return self._backup_dir
@property
def key_fingerprint(self) -> str:
"""SHA-256 of the current derived key, or "" if not yet derived."""
if self._key is None:
return ""
return backup_mod.fingerprint(self._key)
def create_now(self) -> CreateResult:
"""Take an encrypted backup of the live DB right now.
Crash-safe: any failure marks the ``db_backups`` row as
``error``, removes any partial files from the backup dir, and
re-raises the exception.
"""
# 1. Make sure the backup dir exists.
self._backup_dir.mkdir(parents=True, exist_ok=True)
# 2. Allocate a filename + insert a pending row.
from cyclone.db import DbBackup # late import — circular otherwise
from cyclone.store import store as cycl_store
filename = backup_mod.backup_filename()
created_at = datetime.now(timezone.utc)
row = cycl_store.add_backup_pending(
filename=filename,
backup_dir=str(self._backup_dir),
)
try:
# 3. Run SQLite's online .backup() to a temp file.
# We use a private path *inside* the backup dir so the
# operator can see what crashed if it does.
staging_db = self._backup_dir / f".{filename}.staging.db"
self._sqlite_backup_to(staging_db)
# 4. Encrypt.
plaintext = staging_db.read_bytes()
db_fp = backup_mod.fingerprint(plaintext)
key = self._ensure_key()
blob = backup_mod.encrypt(plaintext, key)
# 5. Move encrypted blob into place + write sidecar.
target = self._backup_dir / filename
target.write_bytes(blob)
staging_db.unlink()
table_count = self._count_tables_in_blob(plaintext)
sidecar = backup_mod.Sidecar(
format_version=backup_mod.FORMAT_VERSION,
created_at=created_at.isoformat(),
db_fingerprint=db_fp,
table_count=table_count,
size_bytes=len(blob),
kdf="PBKDF2-HMAC-SHA256",
kdf_iterations=backup_mod.KDF_ITERATIONS,
cipher="AES-256-GCM",
key_fingerprint=backup_mod.fingerprint(key),
)
sidecar_path = self._backup_dir / backup_mod.sidecar_filename(filename)
sidecar_path.write_text(sidecar.to_json())
# 6. Mark the row as ok.
with db.SessionLocal()() as s:
row = s.get(DbBackup, row.id)
row.status = STATUS_OK
row.size_bytes = len(blob)
row.db_fingerprint = db_fp
row.table_count = table_count
row.completed_at = datetime.now(timezone.utc)
s.commit()
s.refresh(row)
record = self._row_to_record(row)
log.info(
"Backup created",
extra={
"backup_id": record.id,
"backup_filename": record.filename,
"size_bytes": record.size_bytes,
"db_fingerprint": record.db_fingerprint,
},
)
return CreateResult(backup=record, sidecar=sidecar)
except Exception as exc: # noqa: BLE001
log.exception("Backup create failed")
try:
with db.SessionLocal()() as s:
row = s.get(DbBackup, row.id)
row.status = STATUS_ERROR
row.error_message = f"{type(exc).__name__}: {exc}"[:500]
row.completed_at = datetime.now(timezone.utc)
s.commit()
# Best-effort cleanup of any partial files.
for p in [
self._backup_dir / filename,
self._backup_dir / f".{filename}.staging.db",
self._backup_dir / backup_mod.sidecar_filename(filename),
]:
if p.exists():
try:
p.unlink()
except OSError:
pass
except Exception: # noqa: BLE001
log.exception("Failed to mark backup row as error")
raise
def list_backups(
self,
*,
limit: int = 100,
status: Optional[str] = None,
) -> list[BackupRecord]:
"""List ``db_backups`` rows newest first.
Joins the filesystem state (presence of ``.bin`` and
``.meta.json``) implicitly via :attr:`BackupRecord.status`:
a row marked ``pruned`` had its files deleted by the
retention policy.
"""
from cyclone.db import DbBackup
with db.SessionLocal()() as s:
q = s.query(DbBackup)
if status is not None:
q = q.filter(DbBackup.status == status)
rows = q.order_by(DbBackup.id.desc()).limit(limit).all()
return [self._row_to_record(r) for r in rows]
def verify(self, backup_id: int) -> VerifyResult:
"""Decrypt + checksum-verify a backup against its sidecar.
Does NOT trust the sidecar's ``db_fingerprint`` field alone;
recomputes the SHA-256 from the decrypted blob and compares.
"""
from cyclone.db import DbBackup
with db.SessionLocal()() as s:
row = s.get(DbBackup, backup_id)
if row is None:
raise BackupError(f"backup {backup_id} not found")
record = self._row_to_record(row)
sidecar = self._read_sidecar(record.filename)
if sidecar is None:
return VerifyResult(
backup_id=record.id, filename=record.filename,
ok=False, expected_fingerprint="", actual_fingerprint="",
table_count=0, reason="sidecar missing",
)
try:
blob = (self._backup_dir / record.filename).read_bytes()
except FileNotFoundError:
return VerifyResult(
backup_id=record.id, filename=record.filename,
ok=False,
expected_fingerprint=sidecar.db_fingerprint,
actual_fingerprint="",
table_count=sidecar.table_count,
reason="backup file missing",
)
try:
plaintext = backup_mod.decrypt(blob, self._ensure_key())
except backup_mod.BackupDecryptError as exc:
return VerifyResult(
backup_id=record.id, filename=record.filename,
ok=False,
expected_fingerprint=sidecar.db_fingerprint,
actual_fingerprint="",
table_count=sidecar.table_count,
reason=str(exc),
)
actual_fp = backup_mod.fingerprint(plaintext)
return VerifyResult(
backup_id=record.id,
filename=record.filename,
ok=(actual_fp == sidecar.db_fingerprint),
expected_fingerprint=sidecar.db_fingerprint,
actual_fingerprint=actual_fp,
table_count=sidecar.table_count,
reason=None if actual_fp == sidecar.db_fingerprint else "fingerprint mismatch",
)
def restore_initiate(self, backup_id: int) -> RestoreInitiateResult:
"""First half of the two-step restore.
Decrypts the backup into a temp file and reads its current
``db_fingerprint`` + ``table_count``. Returns a one-shot
``restore_token`` the operator must echo back to
:meth:`restore_confirm` within 5 minutes.
"""
from cyclone.db import DbBackup
with db.SessionLocal()() as s:
row = s.get(DbBackup, backup_id)
if row is None:
raise BackupError(f"backup {backup_id} not found")
if row.status != STATUS_OK:
raise BackupError(
f"backup {backup_id} status is {row.status!r}; only 'ok' backups can be restored",
)
record = self._row_to_record(row)
# Decrypt into a staging file so the confirm step is fast.
staging = self._backup_dir / f".restore-{record.filename}.staging.db"
try:
blob = (self._backup_dir / record.filename).read_bytes()
except FileNotFoundError as exc:
raise BackupError(f"backup file missing: {record.filename}") from exc
try:
plaintext = backup_mod.decrypt(blob, self._ensure_key())
except backup_mod.BackupDecryptError as exc:
raise BackupError(f"decrypt failed: {exc}") from exc
staging.write_bytes(plaintext)
# Snapshot the live DB's fingerprint for the operator's "are
# you sure you want to do this?" preview.
live_fp, live_count = self._live_fingerprint_and_count()
token = _secrets.token_hex(32)
expires_at = datetime.now(timezone.utc) + timedelta(
seconds=RESTORE_TOKEN_TTL_SECONDS,
)
with self._lock:
self._pending_restores[token] = (record.id, expires_at)
log.info(
"Restore initiated",
extra={
"backup_id": record.id,
"token_prefix": token[:8],
"expires_at": expires_at.isoformat(),
},
)
return RestoreInitiateResult(
backup_id=record.id,
filename=record.filename,
restore_token=token,
expires_at=expires_at,
db_fingerprint=backup_mod.fingerprint(plaintext),
table_count=self._count_tables_in_blob(plaintext),
current_db_fingerprint=live_fp,
current_table_count=live_count,
size_bytes=len(plaintext),
)
def restore_confirm(
self,
backup_id: int,
restore_token: str,
*,
actor: str = "operator",
) -> RestoreConfirmResult:
"""Second half of the two-step restore.
Validates the token, copies the decrypted staging file over
the live DB path, disposes + reopens the engine. Raises
``BackupError`` on any mismatch.
"""
now = datetime.now(timezone.utc)
with self._lock:
entry = self._pending_restores.pop(restore_token, None)
if entry is None:
raise BackupError("restore_token not found (already consumed or never issued)")
token_backup_id, expires_at = entry
if token_backup_id != backup_id:
raise BackupError(
f"restore_token was for backup {token_backup_id}, not {backup_id}",
)
if now > expires_at:
raise BackupError(
f"restore_token expired at {expires_at.isoformat()}; re-run initiate",
)
from cyclone.db import DbBackup
with db.SessionLocal()() as s:
row = s.get(DbBackup, backup_id)
if row is None:
raise BackupError(f"backup {backup_id} disappeared mid-restore")
record = self._row_to_record(row)
staging = self._backup_dir / f".restore-{record.filename}.staging.db"
if not staging.exists():
raise BackupError(
f"staging restore file missing: {staging.name}; re-run initiate",
)
target_db_path = self._live_db_path()
if target_db_path is None:
raise BackupError(
"cannot determine live DB file path (non-sqlite URL?)",
)
# Pre-restore fingerprint for the audit event.
restored_from_fp = backup_mod.fingerprint(staging.read_bytes())
# The swap: dispose engine → copy file → reinit engine.
# Anything between dispose and reinit raises (queries that
# are in-flight get a "database is locked" or
# "no such table" error); we accept that because the
# operator already confirmed.
db.dispose_engine()
try:
# Atomic copy via temp + rename so a crash mid-copy
# doesn't leave a half-written DB file.
tmp_target = target_db_path.with_suffix(
target_db_path.suffix + f".restoring-{_secrets.token_hex(4)}",
)
shutil.copyfile(staging, tmp_target)
os.replace(tmp_target, target_db_path)
finally:
staging.unlink(missing_ok=True)
db.reinit_engine()
# Post-restore fingerprint from the now-live engine.
new_fp, _ = self._live_fingerprint_and_count()
log.warning(
"Restore complete: backup_id=%d actor=%s from=%s to=%s",
backup_id, actor, restored_from_fp, new_fp,
)
return RestoreConfirmResult(
backup_id=record.id,
filename=record.filename,
restored_from_fingerprint=restored_from_fp,
restored_at=datetime.now(timezone.utc),
new_db_fingerprint=new_fp,
)
def prune(self, *, now: Optional[datetime] = None) -> list[str]:
"""Delete backups older than ``retention_days``. Returns deleted paths.
Marks the ``db_backups`` rows ``pruned`` so the operator can
still see what was deleted (and when).
"""
from cyclone.db import DbBackup
cutoff = (now or datetime.now(timezone.utc)) - timedelta(
days=self._retention_days,
)
deleted: list[str] = []
with db.SessionLocal()() as s:
q = s.query(DbBackup).filter(
DbBackup.status == STATUS_OK,
DbBackup.created_at < cutoff,
)
for row in q.all():
# Delete the file pair; ignore if already gone.
bin_path = Path(row.backup_dir) / row.filename
meta_path = Path(row.backup_dir) / backup_mod.sidecar_filename(row.filename)
for p in (bin_path, meta_path):
try:
if p.exists():
p.unlink()
deleted.append(str(p))
except OSError as exc:
log.warning("Failed to delete %s: %s", p, exc)
row.status = STATUS_PRUNED
s.add(row)
s.commit()
log.info(
"Pruned old backups",
extra={
"deleted_count": len(deleted),
"cutoff": cutoff.isoformat(),
},
)
return deleted
def status(self) -> dict:
"""Snapshot of the backup subsystem for ``GET /api/admin/backup/status``."""
from cyclone.db import DbBackup
from sqlalchemy import func
with db.SessionLocal()() as s:
total = s.query(func.count(DbBackup.id)).scalar() or 0
ok_count = s.query(func.count(DbBackup.id)).filter(
DbBackup.status == STATUS_OK,
).scalar() or 0
error_count = s.query(func.count(DbBackup.id)).filter(
DbBackup.status == STATUS_ERROR,
).scalar() or 0
pruned_count = s.query(func.count(DbBackup.id)).filter(
DbBackup.status == STATUS_PRUNED,
).scalar() or 0
last_row = (
s.query(DbBackup)
.filter(DbBackup.status.in_([STATUS_OK, STATUS_ERROR]))
.order_by(DbBackup.id.desc())
.first()
)
last_ok_row = (
s.query(DbBackup)
.filter(DbBackup.status == STATUS_OK)
.order_by(DbBackup.id.desc())
.first()
)
disk_bytes = 0
try:
for p in self._backup_dir.iterdir():
if p.is_file() and p.suffix == ".bin":
disk_bytes += p.stat().st_size
except OSError:
pass
return {
"backup_dir": str(self._backup_dir),
"retention_days": self._retention_days,
"totals": {
"all": total,
"ok": ok_count,
"error": error_count,
"pruned": pruned_count,
},
"disk_bytes": disk_bytes,
"last_backup_at": (
last_row.created_at.isoformat() if last_row and last_row.created_at else None
),
"last_backup_status": last_row.status if last_row else None,
"last_ok_backup_at": (
last_ok_row.created_at.isoformat()
if last_ok_row and last_ok_row.created_at else None
),
"used_fallback_key": self._used_fallback,
}
# ---- Internals --------------------------------------------------------
def _ensure_key(self) -> bytes:
"""Derive (or return cached) AES key. Triggers fallback + WARNING log
if no passphrase was provided at construction time.
If a passphrase is set but no salt was passed at construction,
look one up from the Keychain (``backup.salt`` account). On
a fresh install, generate + persist a salt on first use so
subsequent invocations derive the same key.
"""
if self._key is not None:
return self._key
if self._passphrase:
salt = self._salt
if salt is None:
# Try the Keychain.
stored = secrets_mod.get_secret(KEYCHAIN_BACKUP_SALT_ACCOUNT)
if stored:
salt = bytes.fromhex(stored.strip())
else:
# First run: generate + persist.
salt = os.urandom(backup_mod.SALT_LEN)
secrets_mod.set_secret(
KEYCHAIN_BACKUP_SALT_ACCOUNT,
salt.hex(),
)
log.info(
"Generated + persisted backup salt to Keychain "
"(account %r)",
KEYCHAIN_BACKUP_SALT_ACCOUNT,
)
self._key = backup_mod.derive_key(self._passphrase, salt)
return self._key
# Fallback: derive from SQLCipher DB key. This is degraded
# security (the SQLCipher key is meant to unlock the DB, not
# the backup), but it's strictly better than plaintext.
from cyclone import db_crypto
db_key = db_crypto.get_db_key() if db_crypto.is_encryption_enabled() else None
if not db_key:
# No passphrase AND no SQLCipher key — refuse.
raise BackupError(
"no backup passphrase set and SQLCipher is not enabled; "
"either set a backup passphrase in the Keychain or "
"enable SQLCipher encryption",
)
log.warning(
"Backup using fallback key derived from SQLCipher DB key "
"(no separate backup passphrase set); set one via "
"`cyclone backup init-passphrase` for stronger isolation",
extra={"key_source": "sqlcipher_fallback"},
)
self._used_fallback = True
self._key = backup_mod.derive_key(db_key, backup_mod.FALLBACK_SALT)
return self._key
def _sqlite_backup_to(self, target_path: Path) -> None:
"""Run SQLite's online ``.backup()`` against the live engine.
Works for both plain SQLite and SQLCipher because sqlcipher3
is API-compatible with sqlite3. The ``.backup()`` API takes
a *target* connection; we make a fresh sqlite3 connection to
the target file (which doesn't exist yet) and copy into it.
"""
url = self._db_url or db._resolve_url()
if not url.startswith("sqlite"):
raise BackupError(
f"only sqlite URLs are supported for online backup; got {url!r}",
)
# Drive the backup off the live engine so we capture the
# current state of all tables atomically (SQLite's .backup
# holds a read lock on the source for the duration).
engine = db.engine() # raises RuntimeError if init_db() wasn't called
with engine.raw_connection() as raw:
src_conn = raw.driver_connection # sqlite3.Connection / sqlcipher3.Connection
if target_path.exists():
target_path.unlink()
dst_conn = sqlite3.connect(str(target_path))
try:
src_conn.backup(dst_conn)
finally:
dst_conn.close()
def _count_tables_in_blob(self, plaintext: bytes) -> int:
"""Open the decrypted DB in-memory and count user tables."""
tmp = self._backup_dir / f".count-tables-{_secrets.token_hex(4)}.db"
try:
tmp.write_bytes(plaintext)
conn = sqlite3.connect(str(tmp))
try:
rows = conn.execute(
"SELECT count(*) FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%'",
).fetchone()
return int(rows[0])
finally:
conn.close()
finally:
tmp.unlink(missing_ok=True)
def _live_fingerprint_and_count(self) -> tuple[str, int]:
"""Fingerprint + table count of the *current* live DB."""
try:
engine = db.engine()
except RuntimeError:
return "", 0
# Use a temp-file .backup so we don't have to worry about
# online-vs-offline semantics.
tmp = self._backup_dir / f".live-fp-{_secrets.token_hex(4)}.db"
try:
with engine.raw_connection() as raw:
conn = raw.driver_connection
if tmp.exists():
tmp.unlink()
dst = sqlite3.connect(str(tmp))
try:
conn.backup(dst)
finally:
dst.close()
data = tmp.read_bytes()
return backup_mod.fingerprint(data), self._count_tables_in_blob(data)
finally:
tmp.unlink(missing_ok=True)
def _live_db_path(self) -> Optional[Path]:
"""Resolve the filesystem path of the live DB, or None for non-sqlite."""
url = self._db_url or db._resolve_url()
if not url.startswith("sqlite"):
return None
# Strip the driver prefix: sqlite:///abs or sqlite:///./rel
prefix = "sqlite:///"
if url.startswith(prefix):
return Path(url[len(prefix):])
if url.startswith("sqlite://"):
# sqlite://./relative/path -> Path("./relative/path")
return Path(url[len("sqlite://"):])
return None
def _read_sidecar(self, filename: str) -> Optional[backup_mod.Sidecar]:
p = self._backup_dir / backup_mod.sidecar_filename(filename)
if not p.exists():
return None
try:
return backup_mod.Sidecar.from_json(p.read_text())
except (json.JSONDecodeError, KeyError, ValueError) as exc:
log.warning("Sidecar %s is malformed: %s", p, exc)
return None
def _row_to_record(self, row) -> BackupRecord:
"""ORM row → BackupRecord. Reads key_fingerprint from the sidecar if present."""
sidecar = self._read_sidecar(row.filename)
return BackupRecord(
id=row.id,
filename=row.filename,
backup_dir=row.backup_dir,
size_bytes=row.size_bytes or 0,
db_fingerprint=row.db_fingerprint or "",
table_count=row.table_count or 0,
created_at=row.created_at,
completed_at=row.completed_at,
status=row.status,
error_message=row.error_message,
key_fingerprint=sidecar.key_fingerprint if sidecar else "",
)
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_service: Optional[BackupService] = None
def configure_backup_service(
backup_dir: Union[str, Path],
*,
passphrase: Optional[str] = None,
salt: Optional[bytes] = None,
retention_days: int = 30,
db_url: Optional[str] = None,
) -> BackupService:
"""Create (or replace) the module-level BackupService singleton."""
global _service
if _service is not None:
return _service
_service = BackupService(
backup_dir=backup_dir,
passphrase=passphrase,
salt=salt,
retention_days=retention_days,
db_url=db_url,
)
return _service
def get_backup_service() -> BackupService:
"""Return the configured BackupService. Raises RuntimeError if not set up."""
if _service is None:
raise RuntimeError("backup service not configured; call configure_backup_service() first")
return _service
def reset_backup_service_for_tests() -> None:
"""Clear the module-level singleton. Test-only."""
global _service
_service = None
-492
View File
@@ -1,492 +0,0 @@
"""SP28: per-ACK auto-linker.
Three helpers, one per ACK kind, all run inside the same DB
transaction that persists the Ack row. Each returns a slice of
:class:`ClaimAckLinkRow` dataclasses describing what to link —
the CALLER persists those rows via
:func:`cyclone.store.claim_acks.add_claim_ack` (which owns the
publish-from-store contract).
The two-pass join lives in
:func:`lookup_claims_for_ack_set_response` (D10): ST02 via the
batch envelope index (primary) + ``Claim.patient_control_number``
(fallback). Plus :func:`link_manual` for the manual-fallback
endpoint.
The helpers do NOT write to the DB session — they are pure
readers over the session + parse result + the supplied
``batch_envelope_index`` / ``pc_claim_lookup`` / ``batch_lookup``
closures. This matches the existing
``cyclone.inbox_state.apply_999_rejections`` pattern and lets the
store facade own the publish-from-store contract for live-tail.
See ``docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md``
§3 for the per-AK2 granularity, the two-pass join, and the
idempotency contract enforced by ``ux_claim_acks_dedup``.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Optional
from sqlalchemy.orm import Session
from cyclone.db import Batch, Claim
log = logging.getLogger(__name__)
@dataclass
class ClaimAckLinkRow:
"""One row to insert into ``claim_acks``.
Populated by the helpers; persisted by the caller via
:func:`cyclone.store.claim_acks.add_claim_ack`. Carries every
column the store needs to build the ORM row + the
``claim_ack_written`` event payload.
"""
claim_id: Optional[str] = None
batch_id: Optional[str] = None
ak2_index: Optional[int] = None
set_control_number: Optional[str] = None
set_accept_reject_code: Optional[str] = None
@dataclass
class ClaimAckLinkResult:
"""Outcome of a single helper call.
``linked`` is the list of :class:`ClaimAckLinkRow` rows the
caller should persist via ``cycl_store.add_claim_ack``.
``orphans`` is a list of free-form strings the join couldn't
resolve — for 999/277CA these are ``set_control_number``
values; for TA1 they're the TA1 ICN when no matching batch was
found.
"""
linked: list[ClaimAckLinkRow] = field(default_factory=list)
orphans: list[str] = field(default_factory=list)
# ---------------------------------------------------------------------------
# D10 two-pass join
# ---------------------------------------------------------------------------
def lookup_claims_for_ack_set_response(
session: Session,
set_control_number: str,
*,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
) -> list[Claim]:
"""Two-pass join for a single AK2 set_response / 277CA claim_status.
D10 (spec): for a 999 AK2-2 ``set_control_number`` or a 277CA REF*1K
``payer_claim_control_number``, return every claim this ack
acknowledges. The primary join is
``Batch.envelope.control_number == set_control_number`` (== source
837's ST02 on Gainwell batches); the fallback is
``Claim.patient_control_number == set_control_number``.
Returns 0..N matching claims (one-ack-to-many when one 837 batch
shipped multiple claims under one ST02).
Args:
session: SQLAlchemy session the caller owns.
set_control_number: the AK2-2 / REF*1K value to resolve.
batch_envelope_index: optional pre-built index that maps
``Batch.envelope.control_number`` → ``batch.id`` (built
once per ingest via ``store.batch_envelope_index``).
Pass to skip the per-set-response ``Batch`` scan in Pass 1.
pc_claim_lookup: optional pre-built callable that maps a PCN
to a single claim. Falls back to a session-wide query
when not supplied.
Pass 1 wins. The two paths cannot both fire for the same
``set_control_number`` — if Pass 1 returns one or more claims,
Pass 2 is skipped. This is the false-positive guard from
spec §7.
"""
if not set_control_number:
return []
# -- Pass 1: Batch.envelope.control_number primary --------------
# Accept either a plain dict (the common case — built once per
# ingest via ``store.batch_envelope_index``) or a callable for
# test-side closures. Normalize to ``idx.get`` so the rest of
# the function stays uniform.
idx: Optional[Callable[[str], Optional[str]]] = None
if batch_envelope_index is not None:
if callable(batch_envelope_index):
idx = batch_envelope_index
else:
idx = batch_envelope_index.get
matched_ids: list[str] = []
if idx is not None:
batch_id = idx(set_control_number)
if batch_id is not None:
matched_ids = [
cid for (cid,) in (
session.query(Claim.id)
.filter(Claim.batch_id == batch_id)
.all()
)
]
else:
# Fallback: scan all batches once per call. Slow but correct;
# callers SHOULD pass the index.
rows = (
session.query(Batch.id, Batch.raw_result_json)
.filter(Batch.kind == "837p")
.all()
)
for bid, raw in rows:
env = (raw or {}).get("envelope") or {}
if env.get("control_number") == set_control_number:
matched_ids = [
cid for (cid,) in (
session.query(Claim.id)
.filter(Claim.batch_id == bid)
.all()
)
]
break
if matched_ids:
claims = (
session.query(Claim)
.filter(Claim.id.in_(matched_ids))
.all()
)
if claims:
return list(claims)
# -- Pass 2: Claim.patient_control_number fallback ---------------
if pc_claim_lookup is not None:
single = pc_claim_lookup(set_control_number)
if single is not None:
return [single]
return []
matches = (
session.query(Claim)
.filter(Claim.patient_control_number == set_control_number)
.all()
)
return list(matches)
# ---------------------------------------------------------------------------
# Per-ACK helpers — walk the parsed result and produce ClaimAckLinkRow
# dataclasses. The CALLER persists via cycl_store.add_claim_ack so the
# publish-from-store contract owns the live-tail event emission.
# ---------------------------------------------------------------------------
def _existing_link_claim_ids(
session: Session,
*,
claim_ids: list[str],
ack_kind: str,
ack_id: int,
) -> set[str]:
"""Return the subset of ``claim_ids`` that already have a link row.
Mirrors the partial unique index
``ux_claim_acks_dedup(claim_id, ack_kind, ack_id, ak2_index)
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL``. The
pre-check is here so we skip ``session.add`` and avoid
IntegrityError log noise on re-ingest. For TA1 (claim_id IS
NULL) the helpers do their own check.
"""
if not claim_ids:
return set()
rows = (
session.query(Claim.claim_id) # placeholder; replaced below
if False else
session.query(Claim.id)
.filter(Claim.id.in_([]))
.all()
)
# Replace the placeholder with the real query — needed because
# ClaimAck is not imported above (avoids circular import).
from cyclone.db import ClaimAck
existing = (
session.query(ClaimAck.claim_id)
.filter(
ClaimAck.ack_kind == ack_kind,
ClaimAck.ack_id == ack_id,
ClaimAck.claim_id.in_(claim_ids),
)
.all()
)
return {cid for (cid,) in existing if cid is not None}
def apply_999_acceptances(
session: Session,
parsed_999,
*,
ack_id: int,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""For every AK2 set-response, build one ``ClaimAckLinkRow`` per matched claim.
Both accepted AND rejected AK2s produce a link row (so the
ClaimDrawer panel can show the rejection inline via the
``set_accept_reject_code`` color-coded chip). Orphans are returned
but not linked.
Idempotent: rows the dedup index already covers (re-ingest of an
identical file) are skipped silently — the pre-check is here to
avoid ``IntegrityError`` log noise.
One AK2 can produce multiple ``claim_acks`` rows when the source
837 batch carried more than one claim under a shared ST02 (rare
on this codebase but supported by D10 / the schema).
Returns:
A :class:`ClaimAckLinkResult` whose ``linked`` list contains
one :class:`ClaimAckLinkRow` per AK2-to-claim match. The
caller persists each row via ``cycl_store.add_claim_ack``.
"""
result = ClaimAckLinkResult()
set_responses = getattr(parsed_999, "set_responses", None) or []
# Resolve all set_control_numbers up front so we can do one
# batched dedup query per (ack_kind, ack_id).
resolved: list[tuple[int, "object", list[Claim], str, str]] = []
candidate_claim_ids: list[str] = []
for idx, sr in enumerate(set_responses):
scn = getattr(sr, "set_control_number", None) or ""
code = getattr(sr.set_accept_reject, "code", None) or ""
claims = lookup_claims_for_ack_set_response(
session, scn,
batch_envelope_index=batch_envelope_index,
pc_claim_lookup=pc_claim_lookup,
)
if not claims:
result.orphans.append(scn)
continue
resolved.append((idx, sr, claims, scn, code))
candidate_claim_ids.extend(c.id for c in claims)
existing_ids = _existing_link_claim_ids(
session,
claim_ids=candidate_claim_ids,
ack_kind="999",
ack_id=ack_id,
)
for idx, sr, claims, scn, code in resolved:
for claim in claims:
if claim.id in existing_ids:
continue
result.linked.append(ClaimAckLinkRow(
claim_id=claim.id,
batch_id=None,
ak2_index=idx,
set_control_number=scn,
set_accept_reject_code=code,
))
return result
def apply_277ca_acks(
session: Session,
parsed_277ca,
*,
ack_id: int,
batch_envelope_index: Optional[Callable[[str], Optional[str]]] = None,
pc_claim_lookup: Optional[Callable[[str], Optional[Claim]]] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""For every ClaimStatus with a payer_claim_control_number, build a ClaimAckLinkRow.
Accepted AND rejected ClaimStatuses both link — the
``set_accept_reject_code`` carries the STC category code. The
``claim_acks`` row is independent of the existing
``Claim.payer_rejected_at`` mutation from
:func:`cyclone.inbox_state_277ca.apply_277ca_rejections` (which
fires before this helper in the handler).
Returns:
A :class:`ClaimAckLinkResult` whose ``linked`` list contains
one :class:`ClaimAckLinkRow` per ClaimStatus match. The
caller persists each row via ``cycl_store.add_claim_ack``.
"""
result = ClaimAckLinkResult()
statuses = getattr(parsed_277ca, "claim_statuses", None) or []
resolved: list[tuple["object", list[Claim], str, str]] = []
candidate_claim_ids: list[str] = []
for status in statuses:
scn = getattr(status, "payer_claim_control_number", None) or ""
raw_code = getattr(status, "status_code", None) or ""
# ``status_code`` may be a category like "A6:19:PR" — keep
# the whole STC composite so the UI can render the
# category without re-parsing raw_json. Truncate to 8 chars
# (column width).
code = (raw_code or "")[:8]
if not scn:
# No REF*1K — orphan. Surface the STC composite so the
# operator can correlate via the ack's raw_json.
orphan_key = code or "(no REF*1K)"
result.orphans.append(orphan_key)
continue
claims = lookup_claims_for_ack_set_response(
session, scn,
batch_envelope_index=batch_envelope_index,
pc_claim_lookup=pc_claim_lookup,
)
if not claims:
result.orphans.append(scn)
continue
resolved.append((status, claims, scn, code))
candidate_claim_ids.extend(c.id for c in claims)
existing_ids = _existing_link_claim_ids(
session,
claim_ids=candidate_claim_ids,
ack_kind="277ca",
ack_id=ack_id,
)
for status, claims, scn, code in resolved:
for claim in claims:
if claim.id in existing_ids:
continue
result.linked.append(ClaimAckLinkRow(
claim_id=claim.id,
batch_id=None,
ak2_index=None,
set_control_number=scn,
set_accept_reject_code=code or None,
))
return result
def apply_ta1_envelope_link(
session: Session,
parsed_ta1,
*,
ack_id: int,
batch_lookup: Callable[[str, str], Optional[Batch]],
now: Optional[datetime] = None,
) -> ClaimAckLinkResult:
"""Build a TA1 envelope-level link row to the most-recent matching Batch.
TA1 is envelope-level only (ISA/IEA, no per-claim granularity).
The link row has ``claim_id IS NULL`` and ``batch_id`` populated.
Args:
parsed_ta1: a :class:`cyclone.parsers.models_ta1.ParseResultTa1`.
batch_lookup: ``(sender_id, receiver_id) -> Batch | None``.
The handler supplies a closure that walks
``session.query(Batch).order_by(parsed_at.desc())``. Returning
``None`` produces an orphan (no batch match).
Returns:
A :class:`ClaimAckLinkResult` with 0..1 row in ``linked``.
Idempotent via dedup on ``(ack_kind='ta1', ack_id)`` (claim_id
IS NULL so the partial unique index doesn't catch it; the
pre-check here is a Python-side query).
"""
result = ClaimAckLinkResult()
envelope = getattr(parsed_ta1, "envelope", None)
if envelope is None:
return result
ta1_obj = getattr(parsed_ta1, "ta1", None)
ack_code = getattr(ta1_obj, "ack_code", None) or ""
# Dedup: same (ack_kind, ack_id) → at most one TA1 envelope link.
# Done in Python because the partial unique index requires
# claim_id IS NOT NULL.
from cyclone.db import ClaimAck
existing = (
session.query(ClaimAck.id)
.filter(
ClaimAck.ack_kind == "ta1",
ClaimAck.ack_id == ack_id,
ClaimAck.claim_id.is_(None),
)
.first()
)
if existing is not None:
return result
batch = batch_lookup(envelope.sender_id or "", envelope.receiver_id or "")
if batch is None:
orphan_key = (
getattr(ta1_obj, "control_number", None)
or envelope.control_number
or ""
)
result.orphans.append(orphan_key)
return result
result.linked.append(ClaimAckLinkRow(
claim_id=None,
batch_id=batch.id,
ak2_index=None,
set_control_number=None,
set_accept_reject_code=ack_code,
))
return result
def link_manual(
session: Session,
*,
claim_id: str,
ack_kind: str,
ack_id: int,
set_control_number: Optional[str] = None,
set_accept_reject_code: Optional[str] = None,
ak2_index: Optional[int] = None,
now: Optional[datetime] = None,
) -> ClaimAckLinkRow:
"""Return one manual link row (the caller persists it via the store).
Used by ``POST /api/acks/{kind}/{ack_id}/match-claim``. Returns a
:class:`ClaimAckLinkRow` describing the row to insert. The caller
is responsible for persistence so it can own the publish-from-store
contract.
Idempotency: callers should pre-check via ``session.query(ClaimAck)
.filter(...).first()`` and skip when a row already exists; the
:class:`cyclone.store.claim_acks.add_claim_ack` implementation also
re-checks via the partial unique index.
Raises ``LookupError`` when the referenced claim doesn't exist
(the caller maps that to 404).
"""
if ack_kind not in ("999", "277ca", "ta1"):
raise ValueError(f"link_manual: unknown ack_kind={ack_kind!r}")
claim = session.get(Claim, claim_id)
if claim is None:
raise LookupError(f"claim {claim_id} not found")
return ClaimAckLinkRow(
claim_id=claim_id,
batch_id=None,
ak2_index=ak2_index,
set_control_number=set_control_number,
set_accept_reject_code=set_accept_reject_code,
)
__all__ = [
"ClaimAckLinkResult",
"ClaimAckLinkRow",
"apply_999_acceptances",
"apply_277ca_acks",
"apply_ta1_envelope_link",
"link_manual",
"lookup_claims_for_ack_set_response",
]
+4 -288
View File
@@ -24,7 +24,6 @@ stub secret and the paramiko auth will fail loudly at connect time.
from __future__ import annotations
import asyncio
import io
import logging
import os
@@ -41,75 +40,6 @@ from cyclone.providers import SftpBlock
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Per-op SFTP timeout (SP27 Task 8)
#
# paramiko is synchronous; without a timeout, a hung ``listdir_attr``
# freezes the worker thread indefinitely. The 06/25 silent hang was
# exactly this — the MFT server TCP-acked but stopped responding, the
# scheduler's ``asyncio.to_thread`` waited forever, and the operator
# had no signal that polling had stalled.
#
# The async wrappers below apply ``asyncio.wait_for`` to every SFTP
# call site so the event loop can give up after the configured bound.
# The bound is read fresh on every call (env-var-only, no module-level
# cache) so an operator who tunes the value at runtime picks it up on
# the next poll.
# ---------------------------------------------------------------------------
_DEFAULT_SFTP_OP_TIMEOUT_SECONDS = 30.0
def _op_timeout_seconds() -> float:
"""Per-op SFTP timeout from ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS``.
Default 30s. Picked to comfortably outlast Gainwell's p99
listdir_attr (~2s) while still surfacing real hangs inside one
scheduler tick. Operators who hit repeated timeouts should drop
this — but the right answer is to fix the MFT server, not to
paper over it here.
"""
raw = os.environ.get("CYCLONE_SFTP_OP_TIMEOUT_SECONDS")
if not raw:
return _DEFAULT_SFTP_OP_TIMEOUT_SECONDS
try:
value = float(raw)
except ValueError:
log.warning(
"CYCLONE_SFTP_OP_TIMEOUT_SECONDS=%r is not a float; using default %.1fs",
raw, _DEFAULT_SFTP_OP_TIMEOUT_SECONDS,
)
return _DEFAULT_SFTP_OP_TIMEOUT_SECONDS
if value <= 0:
# ``asyncio.wait_for(timeout=0)`` raises immediately, and
# ``wait_for(timeout<0)`` is undefined per the asyncio docs.
# A zero/negative setting would silently turn every SFTP call
# into an instant timeout (a wave of bogus "list_inbound:
# timeout" errors). Treat the value as bad and fall back.
log.warning(
"CYCLONE_SFTP_OP_TIMEOUT_SECONDS=%r must be positive; using default %.1fs",
raw, _DEFAULT_SFTP_OP_TIMEOUT_SECONDS,
)
return _DEFAULT_SFTP_OP_TIMEOUT_SECONDS
return value
@dataclass(frozen=True)
class SftpStat:
"""File metadata returned by :meth:`SftpClient.stat`.
Mirrors the subset of paramiko's ``SFTPAttributes`` that callers
actually need (``size``, ``modified_at``). Keeping the surface
narrow means callers don't need to import paramiko to consume
the result, and the wrapper's stub mode can populate it from a
plain ``os.stat_result`` without any paramiko dance.
Frozen so callers can hash / cache the result if they need to.
"""
size: int
modified_at: datetime | None = None
@dataclass
class InboundFile:
"""A single file observed in the inbound MFT path."""
@@ -157,132 +87,19 @@ class SftpClient:
dir and returns :class:`InboundFile` records pointing at the
cache copy. The remote file is *not* deleted — the operator
archives inbound files in the MFT UI.
Gainwell's MFT puts advisory ``*_warn.txt`` files in the same
inbound path. They're text-format side-channel notes (not X12
envelopes) and are skipped at list time. Use
:meth:`list_inbound_names` if you need the raw list without
the download.
"""
if self._stub:
return self._list_inbound_stub()
return self._list_inbound_paramiko()
def list_inbound_names(self) -> list[InboundFile]:
"""Lightweight listing: returns metadata only, no file download.
Use this when you want to filter the inbound set (e.g. by date)
before paying the download cost. Pair with
:meth:`download_inbound` to fetch a filtered subset on demand.
Real mode is implemented as a single SFTP ``listdir_attr`` call
— sub-second on Gainwell's MFT — versus the full
:meth:`list_inbound` which downloads every file. The
``*_warn.txt`` advisory files are filtered out the same way.
Stub mode returns the same as :meth:`list_inbound` (the stub
only knows about local files; no download cost).
"""
if self._stub:
return self._list_inbound_stub()
return self._list_inbound_names_paramiko()
def download_inbound(self, f: InboundFile) -> Path:
"""Download a single inbound file to its ``local_path``.
Idempotent: if ``f.local_path`` already exists and is
non-empty, the download is skipped. Callers should use the
``InboundFile`` returned by :meth:`list_inbound_names` and pass
it back here — ``local_path`` is the planned cache location
and matches the path the scheduler will read from.
Returns:
The on-disk path (same as ``f.local_path``).
Raises:
FileNotFoundError: if the file is missing locally in stub
mode, or if the remote file disappears between list
and download in real mode.
"""
if f.local_path.exists() and f.local_path.stat().st_size > 0:
log.debug(
"SFTP: %s already cached at %s, skipping download",
f.name, f.local_path,
)
return f.local_path
if self._stub:
# Stub mode: no remote — the file is supposed to already be
# at f.local_path (operator-dropped). If it isn't there, the
# operator hasn't seeded the stub; raise loudly.
if not f.local_path.is_file():
raise FileNotFoundError(
f"inbound stub file not found: {f.local_path}"
)
return f.local_path
return self._download_inbound_paramiko(f)
def read_file(self, remote_path: str) -> bytes:
"""Read bytes from a remote path.
Stub mode: reads from ``{staging_dir}/{remote_path}``. Used by
the SP16 scheduler so it can exercise the same code path on a
workstation without a real MFT connection.
"""
"""Read bytes from a remote path. Stub raises in stub mode."""
if self._stub:
return self._read_file_stub(remote_path)
raise RuntimeError(
"Stub SFTP cannot read remote files. Use the local staging dir."
)
return self._read_file_paramiko(remote_path)
def stat(self, remote_path: str) -> SftpStat:
"""Return file metadata for ``remote_path``.
Mirrors the subset of paramiko's ``SFTPAttributes`` that
callers need (``size`` for idempotency checks; ``modified_at``
for cache-busting). The SP37 submission helper uses
``stat().size`` to short-circuit re-uploads of already-uploaded
files (the SKIPPED outcome path).
Stub mode: reads ``os.stat_result`` from
``{staging_dir}/{remote_path}``.
Real mode: calls ``sftp.stat(remote_path)`` on a paramiko
connection.
Raises:
FileNotFoundError: if ``remote_path`` does not exist
(stub: missing local file; real: paramiko raises
``IOError`` which is a ``FileNotFoundError`` subclass).
"""
if self._stub:
return self._stat_stub(remote_path)
return self._stat_paramiko(remote_path)
def _read_file_stub(self, remote_path: str) -> bytes:
"""Read bytes from ``{staging_dir}/{remote_path}`` (SP16 stub)."""
staging = Path(self._block.staging_dir).resolve()
target = staging / remote_path.lstrip("/")
if not target.is_file():
raise FileNotFoundError(f"inbound stub file not found: {target}")
return target.read_bytes()
def _stat_stub(self, remote_path: str) -> SftpStat:
"""Return ``SftpStat`` for ``{staging_dir}/{remote_path}`` (SP37 stub).
Mirrors what paramiko's ``sftp.stat()`` returns in real mode:
``size`` from ``st.st_size`` and ``modified_at`` from
``st.st_mtime``. Raises ``FileNotFoundError`` if the local
file is missing — matches paramiko's ``IOError`` behavior
so the caller doesn't need to special-case stub mode.
"""
staging = Path(self._block.staging_dir).resolve()
target = staging / remote_path.lstrip("/")
if not target.is_file():
raise FileNotFoundError(f"inbound stub file not found: {target}")
st = target.stat()
return SftpStat(
size=st.st_size,
modified_at=datetime.fromtimestamp(st.st_mtime),
)
def get_secret(self, name: str) -> Optional[str]:
"""Fetch the auth secret from Keychain. Returns the stub secret if absent."""
value = secrets.get_secret(name)
@@ -291,37 +108,6 @@ class SftpClient:
return secrets.STUB_SECRET
return value
# ---- Async surface (SP27 Task 8) -----------------------------------
#
# Every sync SFTP call has an async wrapper that runs the paramiko
# call on a worker thread and applies an ``asyncio.wait_for(...
# timeout=N)`` around it. The wait_for cancels the awaiter but
# leaves the worker thread running until paramiko returns on its
# own (paramiko is not asyncio-aware, so we can't cancel the
# underlying socket cleanly). The scheduler should treat
# ``asyncio.TimeoutError`` from these wrappers as a transient
# SFTP error and surface it in ``Scheduler.status()`` (Task 9).
#
# The timeout is read on every call (see ``_op_timeout_seconds``)
# so an operator who tunes ``CYCLONE_SFTP_OP_TIMEOUT_SECONDS`` at
# runtime sees the new value on the next tick.
async def async_list_inbound(self) -> list["InboundFile"]:
"""Async-wrapped :meth:`list_inbound` with a per-op timeout.
The 06/25 silent hang was a hung ``listdir_attr`` that froze
the worker thread indefinitely. This wrapper applies
``asyncio.wait_for(...)`` so the event loop can give up after
``CYCLONE_SFTP_OP_TIMEOUT_SECONDS`` (default 30s) and the
scheduler tick can surface the timeout in
``result.errors`` (and, once Task 9 lands, in
``Scheduler.status()``).
"""
return await asyncio.wait_for(
asyncio.to_thread(self.list_inbound),
timeout=_op_timeout_seconds(),
)
# ---- Stub implementations (SP9) -------------------------------------
def _write_bytes_stub(self, remote_path: str, content: bytes) -> Path:
@@ -487,12 +273,6 @@ class SftpClient:
if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000:
# Directory entry — skip.
continue
if attr.filename.endswith("_warn.txt"):
# Gainwell's MFT drops text-format advisory notes in
# the same inbound path. They're side-channel noise,
# not X12 envelopes — skip at list time so we don't
# download ~600 advisory files per poll.
continue
remote = f"{inbound_dir.rstrip('/')}/{attr.filename}"
cache_path = cache_dir / attr.filename
# Download into cache. We use ``prefetch`` to keep memory
@@ -508,56 +288,6 @@ class SftpClient:
))
return files
def _list_inbound_names_paramiko(self) -> list[InboundFile]:
"""List inbound names via paramiko; do NOT download (lightweight).
Same ``listdir_attr`` iteration as
:meth:`_list_inbound_paramiko`, but the returned
:class:`InboundFile` records have ``local_path`` set to the
planned cache location without actually fetching the file.
Pair with :meth:`_download_inbound_paramiko` to fetch on
demand. Skips ``*_warn.txt`` advisory files the same way.
"""
with self._connect() as (ssh, sftp):
inbound_dir = self._block.paths.get("inbound", "/")
staging = Path(self._block.staging_dir).resolve()
inbound_rel = inbound_dir.lstrip("/")
cache_dir = staging / inbound_rel
cache_dir.mkdir(parents=True, exist_ok=True)
files: list[InboundFile] = []
try:
attrs = sftp.listdir_attr(inbound_dir)
except IOError as exc:
log.warning("SFTP: cannot list %s: %s", inbound_dir, exc)
return []
for attr in sorted(attrs, key=lambda a: a.filename):
if attr.st_mode and (attr.st_mode & 0o170000) == 0o040000:
# Directory entry — skip.
continue
if attr.filename.endswith("_warn.txt"):
continue
cache_path = cache_dir / attr.filename
files.append(InboundFile(
name=attr.filename,
size=attr.st_size or 0,
modified_at=datetime.fromtimestamp(attr.st_mtime or 0),
local_path=cache_path,
))
return files
def _download_inbound_paramiko(self, f: InboundFile) -> Path:
"""Download a single ``f`` to ``f.local_path`` (idempotent on size>0)."""
with self._connect() as (ssh, sftp):
inbound_dir = self._block.paths.get("inbound", "/")
remote = f"{inbound_dir.rstrip('/')}/{f.name}"
f.local_path.parent.mkdir(parents=True, exist_ok=True)
with sftp.open(remote, "rb") as src, open(f.local_path, "wb") as dst:
shutil.copyfileobj(src, dst, length=64 * 1024)
log.info("SFTP: downloaded %d bytes for %s", f.local_path.stat().st_size, f.name)
return f.local_path
def _read_file_paramiko(self, remote_path: str) -> bytes:
with self._connect() as (ssh, sftp):
buf = io.BytesIO()
@@ -565,20 +295,6 @@ class SftpClient:
shutil.copyfileobj(f, buf, length=64 * 1024)
return buf.getvalue()
def _stat_paramiko(self, remote_path: str) -> SftpStat:
"""Return ``SftpStat`` for ``remote_path`` via paramiko.
Paramiko's ``SFTPAttributes`` already provides ``st_size`` and
``st_mtime``; we project them into our narrow public
``SftpStat`` shape so callers don't need to know about paramiko.
"""
with self._connect() as (ssh, sftp):
attr = sftp.stat(remote_path)
return SftpStat(
size=attr.st_size or 0,
modified_at=datetime.fromtimestamp(attr.st_mtime or 0),
)
# ---------------------------------------------------------------------------
# Module-level helper
File diff suppressed because it is too large Load Diff
+1 -286
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
import enum
import json
import os
from datetime import date, datetime, timezone
from datetime import date, datetime
from decimal import Decimal
from pathlib import Path
from typing import Optional
@@ -30,8 +30,6 @@ from sqlalchemy import (
Numeric,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker
@@ -222,13 +220,6 @@ class Batch(Base):
totals_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
validation_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
raw_result_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True)
# SP37 Task 2: source 837's ST02 (transaction set control number).
# Populated from ``Envelope.transaction_set_control_number`` by
# ``store.write.add_record`` for 837P batches; NULL for 835 batches
# (the column is an 837P-specific join key for 999 AK2 resolution).
# Migration 0020 adds the column additively; no backfill required for
# pre-existing rows that lack the value.
transaction_set_control_number: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
claims: Mapped[list["Claim"]] = relationship(
back_populates="batch", cascade="all, delete-orphan"
@@ -257,7 +248,6 @@ class Claim(Base):
service_date_to: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
charge_amount: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
payer_id: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
state: Mapped[ClaimState] = mapped_column(
Enum(ClaimState, native_enum=False), nullable=False, default=ClaimState.SUBMITTED
@@ -326,13 +316,6 @@ class Claim(Base):
Index("ix_claims_state", "state"),
Index("ix_claims_patient_control_number", "patient_control_number"),
Index("ix_claims_service_date_from", "service_date_from"),
# SP27 Task 11: matched-pair drift check (run at startup)
# scans ``WHERE matched_remittance_id IS NOT NULL``. Without
# this index it's a full claim scan. The reverse side
# (``ix_remittances_claim_id``) is added in 0007. Pure index
# (non-unique) — a claim without a match is fine, reversals
# leave the previous claim/claim match intact.
Index("ix_claims_matched_remittance_id", "matched_remittance_id"),
)
@@ -353,7 +336,6 @@ class Remittance(Base):
claim_id: Mapped[Optional[str]] = mapped_column(
String(64), ForeignKey("claims.id"), nullable=True
)
rendering_provider_npi: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
status_code: Mapped[str] = mapped_column(String(4), nullable=False)
status_label: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
total_charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False, default=Decimal("0"))
@@ -451,45 +433,6 @@ class ServiceLinePayment(Base):
)
class Visit(Base):
"""SP41: a single row from the AxisCare visits export (CSV).
Persisted by ``cyclone.rebill.visits_store.load_visits_csv`` so the
in-window rebill pipeline can reconcile visits vs 835 svc rows from
the database (previously the spot-check driver read the CSV in-memory,
losing the canonical source-of-truth for the visit roster).
One row per (DOS, member_id, procedure, modifiers). The UNIQUE
constraint on those four columns dedupes the natural key the CSV
itself can contain duplicate rows for the same visit (re-exports).
"""
__tablename__ = "visits"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dos: Mapped[date] = mapped_column(Date, nullable=False)
member_id: Mapped[str] = mapped_column(String(32), nullable=False)
client_name: Mapped[str] = mapped_column(String(64), nullable=False)
procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
modifiers: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
billed_amount: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
icd10: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
prior_auth: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
payer: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
invoice_number: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
source_file: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
loaded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
UniqueConstraint("dos", "member_id", "procedure_code", "modifiers",
name="uq_visits_natural_key"),
Index("ix_visits_dos", "dos"),
Index("ix_visits_member_id", "member_id"),
Index("ix_visits_procedure_code", "procedure_code"),
)
class LineReconciliation(Base):
"""One row per matched (or explicitly unmatched) 837 service line within a claim.
@@ -694,76 +637,6 @@ class Two77caAck(Base):
)
# ---------------------------------------------------------------------------
# SP28: per-ACK auto-link join table (claim_acks)
# ---------------------------------------------------------------------------
class ClaimAck(Base):
"""One row per AK2 set-response / ClaimStatus / TA1 envelope link.
SP28. The durable record of "this ACK acknowledges this claim (or
set, or batch)". One 999 row carries many AK2s; one 277CA carries
many ClaimStatuses; each gets its own ClaimAck row so the operator
can answer "which claims does this ack acknowledge?" with a single
SELECT on ``claim_acks``.
See ``docs/superpowers/specs/2026-07-02-cyclone-ack-claim-auto-link-design.md``
§3.1 for the schema decisions (per-AK2 granularity, the two-pass
join, idempotency via the unique index).
"""
__tablename__ = "claim_acks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
# FK to claims.id with ON DELETE CASCADE so removing a claim
# drops every link row referencing it. NULLable so TA1 envelope-level
# rows can populate ``batch_id`` instead (the table CHECK constraint
# requires at least one of the two).
claim_id: Mapped[Optional[str]] = mapped_column(
String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=True,
)
# FK to batches.id (a Batch row in the 837 case, or the synthetic
# inbound-batch id when the ack arrived outside the SFTP pipeline).
batch_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("batches.id", ondelete="CASCADE"), nullable=True,
)
# Discriminated union over acks / ta1_acks / two77ca_acks. No FK
# constraint because the three target tables are separate; the
# application enforces the discriminator + the matching row's id.
ack_id: Mapped[int] = mapped_column(Integer, nullable=False)
ack_kind: Mapped[str] = mapped_column(String(8), nullable=False)
ak2_index: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# The set_control_number the upstream ack ACTUALLY CARRIED
# (== source 837 ST02 for Gainwell batches). Preserved on the link
# row for orphan traceability — the join may have resolved the
# link via the PCN fallback instead, but the operator still sees
# the value the 999/277CA originally carried.
set_control_number: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
# AK5 code (A/E/R/X) for 999, STC category (A1/A2/A3/A4/A6/A7)
# for 277CA, envelope ack_code for TA1.
set_accept_reject_code: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
linked_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
linked_by: Mapped[str] = mapped_column(String(16), nullable=False)
__table_args__ = (
Index("ix_claim_acks_claim_id", "claim_id"),
Index("ix_claim_acks_batch_id", "batch_id"),
Index("ix_claim_acks_ack", "ack_kind", "ack_id"),
# Mirror the dedup unique index declared in 0018_claim_acks.sql so
# ``Base.metadata.create_all`` (the test-time safety net) emits the
# same partial-unique constraint that the production migration runner
# applies. Without this a fresh in-memory test DB would not enforce
# idempotency at the DB layer.
Index(
"ux_claim_acks_dedup",
"claim_id", "ack_kind", "ack_id", "ak2_index",
unique=True,
sqlite_where=text("claim_id IS NOT NULL AND ak2_index IS NOT NULL"),
),
)
# ---------------------------------------------------------------------------
# SP11: tamper-evident hash-chained audit_log
# ---------------------------------------------------------------------------
@@ -795,11 +668,6 @@ class AuditLog(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
prev_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__ = (
Index("idx_audit_log_entity", "entity_type", "entity_id"),
@@ -808,89 +676,6 @@ class AuditLog(Base):
)
# ---------------------------------------------------------------------------
# SP16: inbound MFT scheduler
# ---------------------------------------------------------------------------
class ProcessedInboundFile(Base):
"""One row per inbound MFT file the scheduler has downloaded.
SP16. Lets the scheduler be idempotent: a re-tick or restart must
not re-parse the same inbound file. The unique index on
(sftp_block_name, name) prevents duplicate inserts and lets the
scheduler fast-skip already-processed files via a SELECT.
Status values:
* ok - parsed cleanly, results persisted to the store
* error - parser raised; error_message captured
* skipped - file_type not in the scheduler's allowed set
* pending - file was downloaded but a downstream step failed;
the scheduler retries on the next tick
"""
__tablename__ = "processed_inbound_files"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
sftp_block_name: Mapped[str] = mapped_column(String(128), nullable=False)
name: Mapped[str] = mapped_column(String(256), nullable=False)
size: Mapped[int] = mapped_column(Integer, nullable=False)
modified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
file_type: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
processed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
parser_used: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
claim_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
status: Mapped[str] = mapped_column(String(16), nullable=False)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
__table_args__ = (
Index(
"ux_processed_inbound_files_block_name",
"sftp_block_name", "name", unique=True,
),
Index("ix_processed_inbound_files_processed_at", "processed_at"),
Index("ix_processed_inbound_files_status", "status"),
)
# ---------------------------------------------------------------------------
# SP17: encrypted backup metadata
# ---------------------------------------------------------------------------
class DbBackup(Base):
"""One row per encrypted backup the BackupService has taken.
The actual encrypted blob lives in a directory outside the DB
(``~/.local/share/cyclone/backups/`` by default); this table is
the index. Status values: ``pending``, ``ok``, ``error``,
``pruned``.
SP17. The unique index on ``(backup_dir, filename)`` makes a
duplicate ``create_now()`` race fail cleanly with an
IntegrityError instead of clobbering an existing backup.
"""
__tablename__ = "db_backups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
filename: Mapped[str] = mapped_column(String(128), nullable=False)
backup_dir: Mapped[str] = mapped_column(String(512), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
db_fingerprint: Mapped[Optional[str]] = mapped_column(String(80), nullable=True)
table_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
status: Mapped[str] = mapped_column(String(16), nullable=False)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ux_db_backups_filename", "backup_dir", "filename", unique=True),
Index("ix_db_backups_created_at", "created_at"),
Index("ix_db_backups_status", "status"),
)
# ---------------------------------------------------------------------------
# SP9: providers, payers, payer_configs, clearhouse
# ---------------------------------------------------------------------------
@@ -968,73 +753,3 @@ class ClearhouseORM(Base):
filename_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)
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())
class Resubmission(Base):
"""SP39: audit row recording that a claim was pushed to SFTP as
part of a corrected-file resubmission. One row per claim per push.
Status is derived at read-time by joining against claim_acks (via
the existing SP28/31 auto-link) + remittances (via CLP->claim).
The corrected-v2 regen preserves the original claim_id from
raw_json, so inbound 999 acks auto-link back to the original claim
row and the join works without any new matching logic.
"""
__tablename__ = "resubmissions"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
claim_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
batch_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
resubmitted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
source_corrected_path: Mapped[str] = mapped_column(String, nullable=False)
interchange_control_number: Mapped[str] = mapped_column(String, nullable=False)
group_control_number: Mapped[str] = mapped_column(String, nullable=False)
__table_args__ = (
Index(
"ux_resubmissions_claim_icn",
"claim_id", "interchange_control_number",
unique=True,
),
)
class SubmissionRecord(Base):
"""SP41: one row per (claim_id, submitted_at) pre-flight dedup guard.
The 30-day dedup window is enforced by ``cyclone.store.submission_dedup``.
This table records (claim_id, submitted_at) for every push that passed
the pre-flight check. Past the window, the prior record is ignored
(the guard lets through re-submits older than
``cyclone.store.submission_dedup.DEFAULT_WINDOW_DAYS``).
"""
__tablename__ = "submission_dedup"
claim_id: Mapped[str] = mapped_column(String(64), primary_key=True)
submitted_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, index=True)
+20 -97
View File
@@ -4,15 +4,12 @@ SP9. Source-of-truth spec:
https://hcpf.colorado.gov/tp-x12-filenaming (HCPF X12 File Naming Standards Quick Guide)
Outbound (we send):
tp{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: tp11525703-837P-20260620132243505-1of1.x12
{tpid}-{transaction_type}-{yyyymmddhhmmssSSS_MT}-1of1.{ext}
Example: 11525703-837P-20260620132243505-1of1.x12
Inbound (HPE sends to our FromHPE):
[Tt][Pp]{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
Example: tp11525703-837P_M019048402-20260520231513488-1of1_999.x12
(legacy / prodfiles may use uppercase TP; the regex is case-insensitive
on the prefix to accept both Gainwell's filer has used both
casings over time.)
Inbound (HPE sends to our ToHPE):
TP{tpid}-{orig_tx}_M{tracking}-{ts}-1of1_{file_type}.x12
Example: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
Both use Mountain Time (MT) timestamps with 17-digit millisecond precision
(yyyymmddhhmmssSSS = 4+2+2+2+2+2+3 = 17 digits). Sequence is always "1of1"
@@ -31,59 +28,29 @@ from cyclone.providers import InboundFilename
# Regexes
# ---------------------------------------------------------------------------
# Outbound: tp11525703-837P-20260620132243505-1of1.x12
# - tp: literal "tp" prefix
# Outbound: 11525703-837P-20260620132243505-1of1.x12
# - tpid: 1+ digits
# - tx: 1+ alnum
# - ts: 17 digits (yyyymmddhhmmssSSS)
# - seq: literal "1of1"
# - ext: 1+ alnum
OUTBOUND_RE = re.compile(
r"^tp(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
r"^(?P<tpid>\d+)-(?P<tx>[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>[A-Za-z0-9]+)$"
)
# Inbound: [Tt][Pp]11525703-837P_M019048402-20260520231513488-1of1_999.x12
# - prefix: literal "TP" or "tp" (case-insensitive — Gainwell's
# production filer has used both)
# Inbound: TP11525703-837P_M019048402-20260520231513488-1of1_999.x12
# - tpid: 1+ digits (inside TP<...>)
# - orig_tx: 1+ uppercase alnum
# - track: M + 1+ uppercase alnum (e.g. M019048402) — the M is
# part of the tracking value, not a separator.
# - orig_tx: 1+ alnum
# - track: M + 1+ alnum (e.g. M019048402) — the M is part of the
# tracking value, not a separator.
# - ts: 17 digits
# - seq: literal "1of1"
# - ft: 1+ uppercase alnum (e.g. 999, TA1, 271, 277, 277CA,
# 820, 834, 835, ENCR)
#
# Case insensitivity is scoped to the ``TP`` prefix via ``(?i:TP)``
# — the rest of the pattern is case-sensitive so we still reject a
# stray ``837p`` or ``m019048402`` (they'd be invalid HCPF).
# - ft: 1+ alnum (e.g. 999, TA1, 271, 277, 277CA, 820, 834, 835, ENCR)
INBOUND_RE = re.compile(
r"^(?i:TP)(?P<tpid>\d+)-(?P<orig_tx>[A-Z0-9]+)_(?P<tracking>M[A-Z0-9]+)"
r"^TP(?P<tpid>\d+)-(?P<orig_tx>[A-Z0-9]+)_(?P<tracking>M[A-Z0-9]+)"
r"-(?P<ts>\d{17})-1of1_(?P<file_type>[A-Z0-9]+)\.(?P<ext>x12)$"
)
# Inbound suffix-less form (SP27 Task 7): tp11525703-835_M019110219-20260525001606050-1of1.x12
# Gainwell's production filer has shipped this shorter form in addition
# to the spec form above — the 6/15-6/19 835 batch arrived this way and
# was silently dropped by the strict INBOUND_RE. The loose form omits
# the `_{file_type}.x12` suffix; the token between `-` and `_M` doubles
# as both `orig_tx` and `file_type` (since there's no separate suffix
# to disambiguate). Allowed values still must be in ALLOWED_FILE_TYPES
# — the suffix is optional, the type-set is not.
# - prefix: literal "TP" or "tp" (case-insensitive — same as INBOUND_RE)
# - tpid: 1+ digits
# - file_type: 3-5 char alphanumeric (covers 999, TA1, 835, 277CA, ENCR;
# capped at 5 to avoid swallowing the next `_M` token)
# - tracking: M + 1+ uppercase alnum
# - ts: 17 digits
# - seq: literal "1of1"
# - ext: literal "x12" (relaxing this would also relax the strict
# form's contract; out of scope for SP27 Task 7)
INBOUND_RE_LOOSE = re.compile(
r"^(?i:TP)(?P<tpid>\d+)-(?P<file_type>[A-Z0-9]{3,5})"
r"_(?P<tracking>M[A-Z0-9]+)-(?P<ts>\d{17})-1of1\.(?P<ext>x12)$"
)
ALLOWED_FILE_TYPES = frozenset({
"999", "TA1", "270", "271", "276", "277", "277CA", "278",
"820", "834", "835", "ENCR",
@@ -112,8 +79,7 @@ def build_outbound_filename(
time in ``America/Denver`` is used.
Returns:
Filename like "tp11525703-837P-20260620132243505-1of1.x12"
(note the ``tp`` prefix per HCPF outbound spec).
Filename like "11525703-837P-20260620132243505-1of1.x12"
Raises:
ValueError: If tpid is non-numeric, tx contains invalid chars, or
@@ -133,10 +99,7 @@ def build_outbound_filename(
# Format: yyyymmddhhmmssSSS — 17 digits total
ts = now_mt.strftime("%Y%m%d%H%M%S") + f"{now_mt.microsecond // 1000:03d}"
assert len(ts) == 17
# 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}"
return f"{tpid}-{tx}-{ts}-1of1.{ext}"
# ---------------------------------------------------------------------------
@@ -147,49 +110,16 @@ def build_outbound_filename(
def parse_inbound_filename(name: str) -> InboundFilename:
"""Parse an inbound HCPF filename.
Accepts both forms (Gainwell ships both):
* Spec form with ``_{file_type}.x12`` suffix:
``tp11525703-837P_M019048402-20260520231513488-1of1_999.x12``
* Suffix-less form (SP27 Task 7): the token between ``-`` and
``_M`` doubles as both ``orig_tx`` and ``file_type``:
``tp11525703-835_M019110219-20260525001606050-1of1.x12``
The strict form is tried first (preserves historical behavior for
every existing caller); the loose form is the fallback. The
``.x12`` extension and ``ALLOWED_FILE_TYPES`` set are enforced in
both forms.
Args:
name: Filename like ``tp11525703-837P_M019048402-20260520231513488-1of1_999.x12``
(case-insensitive on the ``TP`` prefix; both ``TP`` and
``tp`` are accepted.)
name: Filename like "TP11525703-837P_M019048402-20260520231513488-1of1_999.x12"
Returns:
InboundFilename with tpid, orig_tx, tracking, ts, file_type, ext.
Raises:
ValueError: If the filename doesn't match either HCPF inbound
form, or if the derived file_type isn't in
ALLOWED_FILE_TYPES.
ValueError: If the filename doesn't match the HCPF inbound format.
"""
m = INBOUND_RE.match(name)
if m:
file_type = m.group("file_type")
if file_type not in ALLOWED_FILE_TYPES:
raise ValueError(
f"file_type {file_type!r} not in allowed HCPF set: {sorted(ALLOWED_FILE_TYPES)}"
)
return InboundFilename(
tpid=m.group("tpid"),
orig_tx=m.group("orig_tx"),
tracking=m.group("tracking"),
ts=m.group("ts"),
file_type=file_type,
ext=m.group("ext"),
)
# Fall back to the suffix-less form.
m = INBOUND_RE_LOOSE.match(name)
if not m:
raise ValueError(f"Not a valid HCPF inbound filename: {name!r}")
file_type = m.group("file_type")
@@ -199,7 +129,7 @@ def parse_inbound_filename(name: str) -> InboundFilename:
)
return InboundFilename(
tpid=m.group("tpid"),
orig_tx=m.group("file_type"), # preserve the historical shape
orig_tx=m.group("orig_tx"),
tracking=m.group("tracking"),
ts=m.group("ts"),
file_type=file_type,
@@ -218,12 +148,5 @@ def is_outbound_filename(name: str) -> bool:
def is_inbound_filename(name: str) -> bool:
"""True if the given string matches either HCPF inbound form.
Accepts both the spec form (with ``_{file_type}.x12`` suffix) and
the suffix-less form (SP27 Task 7). Cheap fast-path check used by
callers that want to pre-filter a directory listing before invoking
:func:`parse_inbound_filename`; mirrors the parser's own fallback
so the two never disagree on what counts as an HCPF inbound file.
"""
return INBOUND_RE.match(name) is not None or INBOUND_RE_LOOSE.match(name) is not None
"""True if the given string matches the HCPF inbound filename regex."""
return INBOUND_RE.match(name) is not None
-251
View File
@@ -1,251 +0,0 @@
"""SP40: thin HTTP client for the EdiNation / Edifabric validation API.
Wraps the two-step /v2/x12/read (raw EDI ``X12Interchange`` JSON) and
/v2/x12/validate (``X12Interchange`` JSON ``OperationResult``) flow.
Cyclone has no other outbound HTTP today; this is the first such
client. ``httpx`` is already a project dep (used by the test suite)
so we don't introduce a new dependency.
Public surface:
- :func:`read_interchange` POST raw EDI bytes to /x12/read.
- :func:`validate_interchange` POST ``X12Interchange`` JSON to /x12/validate.
- :func:`validate_edi` composes the two; this is what the CLI and
pre-upload gate use.
- :class:`EdifabricError` raised on 4xx/5xx so callers can surface
the Edifabric error verbatim (the body is a dict or string).
The API key is taken from :func:`cyclone.secrets.get_secret('edifabric.api_key')`
which maps to ``CYCLONE_EDIFABRIC_API_KEY`` env var or macOS Keychain.
Tests inject a canned key (and a mocked transport) via the factory
hook :func:`set_transport_factory` so no live HTTP hits the network.
The endpoint contract (from the EdiNation API reference,
``https://support.edifabric.com/hc/en-us/sections/360005605638``):
- ``POST https://api.edination.com/v2/x12/read``
- Headers: ``Ocp-Apim-Subscription-Key: <key>``, ``Content-Type: application/octet-stream``
- Body: raw EDI bytes
- Response (200): JSON array of ``X12Interchange`` objects (cyclone always sends one interchange)
- ``POST https://api.edination.com/v2/x12/validate``
- Headers: ``Ocp-Apim-Subscription-Key: <key>``, ``Content-Type: application/json``
- Body: one ``X12Interchange`` object
- Response (200): ``OperationResult`` (``Status`` {``"success"``, ``"warning"``, ``"error"``}, ``Details`` array)
We treat any non-2xx as an :class:`EdifabricError`. The response body
is preserved verbatim so callers can inspect it.
"""
from __future__ import annotations
import logging
import os
from typing import Any, Callable
import httpx
_log = logging.getLogger(__name__)
_BASE_URL = "https://api.edination.com/v2/x12"
_READ_PATH = "/read"
_VALIDATE_PATH = "/validate"
# Subscription-key header name (Azure API Management convention).
_SUB_HEADER = "Ocp-Apim-Subscription-Key"
class EdifabricError(RuntimeError):
"""Raised when the Edifabric API returns a non-2xx response.
Attributes:
status_code: HTTP status code returned by Edifabric.
body: The response body usually a dict with ``error`` /
``message`` keys for 4xx, or a string for 5xx / network
errors. Preserved verbatim so the caller can surface it
to the operator.
retry_after_seconds: When the upstream sent a ``Retry-After``
header (the API Management quota policy does), the value
in seconds. ``None`` if absent. Quota-blocked callers can
surface this so the operator knows when to retry.
"""
def __init__(
self, status_code: int, body: Any, *, retry_after_seconds: int | None = None
) -> None:
self.status_code = status_code
self.body = body
self.retry_after_seconds = retry_after_seconds
body_repr = repr(body) if not isinstance(body, str) else body
msg = f"Edifabric API returned {status_code}: {body_repr}"
if retry_after_seconds is not None:
msg += f" (retry after {retry_after_seconds}s)"
super().__init__(msg)
# --- Transport injection (test seam) -----------------------------------
# Default transport factory builds a normal httpx.Client. Tests can
# call set_transport_factory() with a callable that returns a Client
# backed by httpx.MockTransport (no live HTTP).
_transport_factory: Callable[[], httpx.Client] = lambda: httpx.Client(timeout=30.0)
def set_transport_factory(factory: Callable[[], httpx.Client]) -> None:
"""Inject an ``httpx.Client`` factory for tests.
The factory must return an ``httpx.Client`` whose ``transport`` is
a mock (e.g. ``httpx.MockTransport(handler)``) so no real HTTP is
performed. Returns the previous factory so tests can restore it.
"""
global _transport_factory
prev = _transport_factory
_transport_factory = factory
return prev # type: ignore[return-value]
def _reset_transport_factory() -> None:
"""Restore the default transport factory (called in test cleanup)."""
global _transport_factory
_transport_factory = lambda: httpx.Client(timeout=30.0)
# --- Public surface ----------------------------------------------------
def _get_api_key() -> str:
"""Resolve the Edifabric API key.
Looks up ``CYCLONE_EDIFABRIC_API_KEY`` (or the keychain entry
``cyclone / edifabric.api_key``). The secrets module is imported
lazily so test setups that mock it can do so before first use.
"""
from cyclone.secrets import get_secret
key = get_secret("edifabric.api_key")
if not key:
raise EdifabricError(
0,
"Edifabric API key not configured; set CYCLONE_EDIFABRIC_API_KEY "
"env var or run `cyclone secrets set edifabric.api_key <key>`",
)
return key
def read_interchange(edi_bytes: bytes, *, api_key: str | None = None) -> dict:
"""POST raw EDI bytes to /x12/read and return the first X12Interchange.
The /x12/read endpoint accepts a multi-interchange file and returns
an array. Cyclone only ever sends single interchanges, so we return
the first (and only) element. If the file contains multiple
interchanges, callers should call ``validate_interchange`` on each.
"""
if not isinstance(edi_bytes, (bytes, bytearray)):
raise TypeError(
f"edi_bytes must be bytes, got {type(edi_bytes).__name__}"
)
key = api_key if api_key is not None else _get_api_key()
headers = {
_SUB_HEADER: key,
"Content-Type": "application/octet-stream",
}
with _transport_factory() as client:
resp = client.post(
f"{_BASE_URL}{_READ_PATH}",
content=bytes(edi_bytes),
headers=headers,
)
if not (200 <= resp.status_code < 300):
raise EdifabricError(
resp.status_code,
_safe_body(resp),
retry_after_seconds=_retry_after_seconds(resp),
)
data = resp.json()
if not isinstance(data, list) or not data:
raise EdifabricError(
502,
f"unexpected /x12/read response shape: expected non-empty list, "
f"got {type(data).__name__} of length {len(data) if hasattr(data, '__len__') else '?'}",
)
return data[0]
def validate_interchange(x12_json: dict, *, api_key: str | None = None) -> dict:
"""POST an X12Interchange JSON to /x12/validate and return the OperationResult.
The OperationResult schema (per the EdiNation docs):
- ``Status`` ``"success"`` / ``"warning"`` / ``"error"``.
- ``Details`` array of ``{Index, SegmentId, Value, Message, Status, ...}``.
- ``LastIndex`` 1-based index of the last processed segment.
We do NOT raise on ``Status == "error"`` the caller decides whether
to fail-closed (the pre-upload gate does; the CLI prints and exits
with the appropriate code). Non-2xx HTTP responses DO raise
:class:`EdifabricError`.
"""
if not isinstance(x12_json, dict):
raise TypeError(
f"x12_json must be a dict, got {type(x12_json).__name__}"
)
key = api_key if api_key is not None else _get_api_key()
headers = {
_SUB_HEADER: key,
"Content-Type": "application/json",
}
with _transport_factory() as client:
resp = client.post(
f"{_BASE_URL}{_VALIDATE_PATH}",
json=x12_json,
headers=headers,
)
if not (200 <= resp.status_code < 300):
raise EdifabricError(
resp.status_code,
_safe_body(resp),
retry_after_seconds=_retry_after_seconds(resp),
)
return resp.json()
def validate_edi(edi_bytes: bytes, *, api_key: str | None = None) -> dict:
"""Two-step convenience: read → validate. Returns the OperationResult.
This is what ``cyclone validate-837 <file>`` and the pre-upload
gate in ``resubmit-rejected-claims`` call. Raises
:class:`EdifabricError` on transport / non-2xx errors. The
OperationResult is returned verbatim so the caller can inspect
``Status`` and ``Details`` themselves.
"""
x12 = read_interchange(edi_bytes, api_key=api_key)
return validate_interchange(x12, api_key=api_key)
# --- Internal helpers --------------------------------------------------
def _safe_body(resp: httpx.Response) -> Any:
"""Return the response body, preferring JSON when possible."""
try:
return resp.json()
except Exception: # noqa: BLE001
text = resp.text
return text if text else f"<empty {resp.status_code} response>"
def _retry_after_seconds(resp: httpx.Response) -> int | None:
"""Parse the ``Retry-After`` header into integer seconds.
Returns ``None`` if the header is absent (and the caller should
fall back to its own backoff). The upstream API Management sends
this header on quota-blocked responses; treating it as authoritative
lets the caller sleep exactly until quota replenishes rather than
guessing.
"""
raw = resp.headers.get("Retry-After") or resp.headers.get("retry-after")
if not raw:
return None
try:
return int(raw)
except (TypeError, ValueError):
return None
-113
View File
@@ -1,113 +0,0 @@
"""File-type handlers for inbound MFT files (SP27).
Each handler is a pure function that parses + persists + dispatches
events for one file type. The scheduler and the FastAPI endpoints
both delegate here; the ``HANDLERS`` registry maps ``file_type``
handler function.
Public API:
HandleResult dataclass returned by every handler
HANDLERS ``{"999": handle_999, "835": handle_835, ...}``
handle_999, handle_ta1, handle_277ca, handle_835
call signatures: ``handle(text: str, source_file: str) -> HandleResult``
The handlers own their own DB session lifecycle. They emit pubsub
events via the optional ``event_bus`` parameter (the FastAPI endpoint
injects ``app.state.event_bus``; the scheduler passes ``None``).
They never raise on per-segment problems; per-segment issues are
logged and folded into the result. Whole-document failures
(missing ISA, bad encoding) surface as ``CycloneParseError``, which
the caller catches and records as ``STATUS_ERROR``.
"""
from __future__ import annotations
import importlib
import logging
from ._ack_id import (
ack_count_summary,
ack_synthetic_source_batch_id,
two77ca_synthetic_source_batch_id,
)
from .handle_result import HandleResult
log = logging.getLogger(__name__)
# Module-level HANDLERS dict populated lazily once handler modules
# ship. Keys are file_type strings, values are the ``handle``
# callable for that type.
HANDLERS: dict[str, object] = {}
# Candidate handlers, in registration order. Each tuple is
# (module-path, file_type). The 277/277CA mapping is added explicitly
# after registration when the 277CA handler is present.
_CANDIDATES: list[tuple[str, str]] = [
("cyclone.handlers.handle_999", "999"),
("cyclone.handlers.handle_ta1", "TA1"),
("cyclone.handlers.handle_277ca", "277CA"),
("cyclone.handlers.handle_835", "835"),
]
def register_handlers() -> None:
"""Populate ``HANDLERS`` from the per-type handler modules.
Tolerates missing or broken handler modules so the package can be
imported incrementally as each handler ships (Tasks 2-5), and so
a partial-modification error in one handler module can't break
scheduler / API import. Safe to call multiple times.
"""
if HANDLERS:
return
for mod_path, file_type in _CANDIDATES:
try:
mod = importlib.import_module(mod_path)
fn = getattr(mod, "handle")
except Exception as exc: # noqa: BLE001 — best-effort registry
log.debug(
"handler %s unavailable: %s",
mod_path, exc,
)
continue
HANDLERS[file_type] = fn
# The 277 filename maps to the same 277CA handler.
if file_type == "277CA":
HANDLERS["277"] = fn
register_handlers()
# Re-export handler functions on this package so callers can use the
# flat import (``from cyclone.handlers import handle_999``) once each
# module ships. Set after registration so we know what's present.
def _reexport_handlers() -> None:
"""Re-export each handler's ``handle`` fn as ``handle_<type>``.
No-op for absent handlers. Re-run on each import so freshly-
installed handler modules (e.g. Tasks 2-5 commits) are visible
after ``register_handlers()`` without a process restart.
"""
for file_type, fn in list(HANDLERS.items()):
if file_type in ("277",): # alias of 277CA; don't re-export twice
continue
globals()[f"handle_{file_type.lower()}"] = fn
_reexport_handlers()
__all__ = [
"HANDLERS",
"HandleResult",
"register_handlers",
"ack_count_summary",
"ack_synthetic_source_batch_id",
"two77ca_synthetic_source_batch_id",
# handler_* names are added at module load via _reexport_handlers
]
# Populate __all__ with the present handler symbols.
for _h in ("handle_999", "handle_ta1", "handle_277ca", "handle_835"):
if _h in globals():
__all__.append(_h) # noqa: PYI056
-87
View File
@@ -1,87 +0,0 @@
"""Ack ID helpers shared between the scheduler and the FastAPI
endpoints (SP27 Task 1).
Lifted from ``scheduler.py:_ack_count_summary`` +
``_ack_synthetic_source_batch_id`` + ``_277ca_synthetic_source_batch_id``
all three had inline copies in both ``scheduler.py`` and ``api.py``
prior to this SP. Both callers now import from this module.
Helpers
-------
``ack_count_summary(result)``
Aggregate ``(received, accepted, rejected, ack_code)`` from a
parsed ``ParseResult999``, trusting set-level IK5 over the
functional-group AK9. Gainwell's MFT ships AK9 segments that
contradict the per-set IK5 (e.g. ``AK9*A*1*1*1`` with
``IK5*A``), so trusting AK9's rejected count over-reports.
See scheduler commit ``6507a8c`` for the operational context.
``ack_synthetic_source_batch_id(icn, *, pcn, source_filename)``
Build a unique-per-file ``batches.id`` for a 999 that ships
without its own source batch. Falls back to a hash suffix of
the filename so daily pulls don't all collapse onto the same
ICN. Gainwell's MFT ships every 999 with the default ICN
``000000001`` (per the scheduler docstring).
``two77ca_synthetic_source_batch_id(icn)``
Same idea for a 277CA without its own source batch.
"""
from __future__ import annotations
import hashlib
from typing import Any
def ack_count_summary(result: Any) -> tuple[int, int, int, str]:
"""Aggregate ``(received, accepted, rejected, ack_code)`` from
a ``ParseResult999``.
Counts are derived from the set-level ``IK5`` responses
(one per ``AK2`` in the 999), not the functional-group ``AK9``.
Gainwell's MFT ships contradictory AK9 segments; the per-set
IK5 is the authoritative per-claim accept/reject signal.
"""
sets = result.set_responses
received = len(sets)
accepted = sum(1 for s in sets if s.set_accept_reject.code == "A")
rejected = received - accepted
if rejected == 0:
code = "A"
elif accepted == 0:
code = "R"
else:
code = "P"
return (received, accepted, rejected, code)
def ack_synthetic_source_batch_id(
interchange_control_number: str,
*,
pcn: str | None = None,
source_filename: str | None = None,
) -> str:
"""Synthetic ``batches.id`` for a received 999 with no source batch.
Precedence for the human-readable part of the id (column is
``VARCHAR(32)``):
1. ``999-{pcn}-{hash8}`` if ``AK2`` set_control_number is
present (the common case). 4 + 9 + 1 + 8 = 22 chars max.
2. ``999-{icn}-{hash8}`` if no AK2 (envelope-only 999).
3. ``999-{hash12}`` if no filename either (shouldn't happen
in production).
"""
short_hash = ""
if source_filename:
short_hash = hashlib.sha1(source_filename.encode("utf-8")).hexdigest()[:8]
if pcn and pcn.strip():
return f"999-{pcn.strip()}-{short_hash}"
icn = (interchange_control_number or "").strip() or "000000001"
if short_hash:
return f"999-{icn}-{short_hash}"
return f"999-{short_hash or icn}"
def two77ca_synthetic_source_batch_id(interchange_control_number: str) -> str:
"""Synthetic ``batches.id`` for a received 277CA with no source batch."""
return f"277CA-{(interchange_control_number or '').strip() or '000000001'}"
@@ -1,152 +0,0 @@
"""Handle a 277CA Claim Acknowledgment file (SP27 Task 4).
Lifted verbatim from ``scheduler.py:_handle_277ca``. The handler owns
its own DB session, dispatches to ``parse_277ca_text``, persists the
277CA ack row, applies 277CA rejections to matched claims via
``inbox_state.apply_277ca_rejections``, and emits
``claim.payer_rejected`` audit events for each newly-stamped claim.
The actor tag (``"277ca-parser-scheduler"``) is preserved so the
audit log keeps tracing back to the same source after extraction.
Both the FastAPI endpoint and the scheduler path (re)use this module
the API migration drops the inline copy in Task 6.
``claim.rejected_after_remit`` audit emission (when a 277CA rejects
a claim that already has ``matched_remittance_id`` set) is deferred
to SP27 Task 13. Today the handler only emits ``claim.payer_rejected``.
"""
from __future__ import annotations
import json
import logging
from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.claim_acks import apply_277ca_acks
from cyclone.handlers._ack_id import two77ca_synthetic_source_batch_id
from cyclone.inbox_state_277ca import apply_277ca_rejections
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_277ca import parse_277ca_text
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse a 277CA, persist ack + stamp payer-rejected claims.
Args:
text: Raw 277CA document bytes (decoded).
source_file: Filename the 277CA came from.
Returns:
``(parser_used, claim_count)`` tuple where ``claim_count`` is
the number of STC statuses in the file (one per claim).
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
SP25: the store (``cyclone.store.acks.add_277ca_ack``) owns the
publish-from-store contract; the handler no longer needs an
``event_bus`` kwarg.
"""
try:
result = parse_277ca_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"277CA parse error: {exc}") from exc
icn = result.envelope.control_number
synthetic_id = two77ca_synthetic_source_batch_id(icn)
accepted = sum(
1 for s in result.claim_statuses if s.classification == "accepted"
)
paid = sum(
1 for s in result.claim_statuses if s.classification == "paid"
)
rejected = sum(
1 for s in result.claim_statuses if s.classification == "rejected"
)
pended = sum(
1 for s in result.claim_statuses if s.classification == "pended"
)
# Build the batch envelope index BEFORE opening the work session
# — cycl_store.batch_envelope_index opens its own short-lived
# session, and SQLite + concurrent sessions causes "database is
# locked" errors.
batch_index = cycl_store.batch_envelope_index()
with db.SessionLocal()() as session:
row = cycl_store.add_277ca_ack(
source_batch_id=synthetic_id,
control_number=icn,
accepted_count=accepted,
rejected_count=rejected,
paid_count=paid,
pended_count=pended,
raw_json=json.loads(result.model_dump_json()),
)
def _lookup(pcn: str):
return (
session.query(db.Claim)
.filter(db.Claim.patient_control_number == pcn)
.first()
)
apply_result = apply_277ca_rejections(
session, result, claim_lookup=_lookup, two77ca_id=row.id,
)
if apply_result.matched:
for cid in apply_result.matched:
append_event(session, AuditEvent(
event_type="claim.payer_rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id, "277ca_id": row.id},
actor="277ca-parser-scheduler",
))
# SP28: auto-link the 277CA ClaimStatus entries to claims (D10
# two-pass join). The helper builds dataclass rows; the
# caller persists each via cycl_store.add_claim_ack so the
# publish-from-store contract owns the live-tail event.
def _pcn_lookup(pcn: str):
return (
session.query(db.Claim)
.filter(db.Claim.patient_control_number == pcn)
.first()
)
link_result = apply_277ca_acks(
session, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
# Snapshot the rows before closing the work session — SQLite
# + concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="277ca",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"277CA had %d orphan status entries (no matching claim): %s",
len(orphans),
orphans[:5],
)
return ("parse_277ca", len(result.claim_statuses))
-109
View File
@@ -1,109 +0,0 @@
"""Handle an 835 ERA Remittance file (SP27 Task 5).
Lifted verbatim from ``scheduler.py:_handle_835``. The handler owns
its own state, dispatches to ``parse_835`` (raises
``CycloneParseError`` on bad EDI), runs the per-payer 835 validator,
stamps the validation report into ``result.summary``, and persists
the BatchRecord via ``cycl_store.add``.
The 835 handler is the largest of the four because the schema
covers per-claim remittances + CAS adjustments + validation.
``PAYER_FACTORIES_835`` lives in ``cyclone.api`` (it was always
intended to live in ``cyclone.payers`` a TODO pre-dating SP27
but moving it is out of Task 5 scope). We import it lazily inside
``handle`` to avoid a module-load-time cycle; the cyclic risk is
acceptable because api.py also imports scheduler lazily (inside its
lifespan handler).
Two-phase ingest closed in SP27 Task 10: the placeholder
``adjustment_amount`` is overwritten by reconcile in the same DB
session as the insert (before commit), so a reader never sees a
half-reconciled Remittance row. If reconcile raises, the whole
ingest rolls back and the scheduler records the per-file error.
``event_bus`` is best-effort and follows the same TODO(sp27-task-6)
sync/async gap as handle_999 / handle_ta1 / handle_277ca.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.store import BatchRecord
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse an 835, run validation, persist batch + remittances.
Args:
text: Raw 835 document bytes (decoded).
source_file: Filename the 835 came from.
Returns:
``(parser_used, claim_count)`` tuple. ``claim_count`` is the
number of CLP claims parsed; the BatchRecord's summary's
``passed`` / ``failed`` fields are derived from the
validator's ``report.passed`` flag.
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
Validation failures don't raise — they're stamped into
``summary.passed = 0``.
SP25: the 835 write path already publishes ``remittance_written``
via ``CycloneStore.add`` (SP21 split). The handler no longer
accepts an ``event_bus`` kwarg the ``ack_received`` publish
here was dead code anyway (the 835 event name is
``remittance_written``, not ``ack_received``).
"""
# SP36 Task 16: PAYER_FACTORIES_835 moved from ``cyclone.api``
# to ``cyclone.api_routers._shared`` (cross-router helper). Import
# from the new home. The lazy import is still needed to avoid a
# circular import at registry load time (handle_835 is imported
# by the scheduler during the api lifespan).
from cyclone.api_routers._shared import PAYER_FACTORIES_835
config = PAYER_FACTORIES_835["co_medicaid_835"]()
try:
result = parse_835(text, config, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"835 parse error: {exc}") from exc
# Validation report (mirrors the API endpoint).
report = validate_835(result, config)
n = len(result.claims)
if report.passed:
passed, failed, failed_claim_ids = n, 0, []
else:
passed, failed, failed_claim_ids = 0, n, [
c.payer_claim_control_number for c in result.claims
]
result = result.model_copy(update={
"validation": report,
"summary": result.summary.model_copy(update={
"passed": passed,
"failed": failed,
"failed_claim_ids": failed_claim_ids,
}),
})
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="835",
input_filename=source_file,
parsed_at=datetime.now(timezone.utc),
result=result,
)
cycl_store.add(rec)
return ("parse_835", len(result.claims))
-157
View File
@@ -1,157 +0,0 @@
"""Handle a 999 Implementation Acknowledgment file (SP27 Task 2).
Lifted verbatim from ``scheduler.py:_handle_999``. The handler owns
its own DB session, dispatches to ``parse_999_text``, applies 999
rejections to any matched claims via ``inbox_state.apply_999_rejections``,
persists the ack row, and returns a ``(parser_used, claim_count)``
tuple.
The actor tag (``"999-parser-scheduler"``) is preserved so the audit
log keeps tracing back to the same source after extraction. Both
the FastAPI endpoint and the scheduler path (re)use this module
see Task 6 for the API migration that drops the inline copy.
``claim_count`` mirrors ``parsed.received`` the count of AK2
(set-level) responses in the 999, which is the authoritative
per-claim accept/reject signal (see ``_ack_id`` for why AK9 is
ignored).
"""
from __future__ import annotations
import json
import logging
from cyclone import db
from cyclone.audit_log import AuditEvent, append_event
from cyclone.claim_acks import apply_999_acceptances
from cyclone.handlers._ack_id import (
ack_count_summary,
ack_synthetic_source_batch_id,
)
from cyclone.inbox_state import apply_999_rejections
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_999 import parse_999_text
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse a 999, apply rejections, persist ack row.
Args:
text: Raw 999 document bytes (decoded).
source_file: Filename the 999 came from. Used to derive a
unique synthetic ``batches.id`` (see ``_ack_id``).
Returns:
``(parser_used, claim_count)`` tuple. Matches the scheduler
``_download_and_parse`` destructure; the HandleResult
migration happens in Task 7.
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
SP25: the store (``cyclone.store.acks.add_999_ack``) now owns the
publish-from-store contract; the handler no longer needs an
``event_bus`` kwarg. Both the scheduler path (no bus) and the
FastAPI endpoint path (passes the bus to the store directly) see
the same row, the same event, and the same payload.
"""
try:
result = parse_999_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"999 parse error: {exc}") from exc
received, accepted, rejected, ack_code = ack_count_summary(result)
icn = result.envelope.control_number
pcn = (
result.set_responses[0].set_control_number
if result.set_responses else None
)
synthetic_id = ack_synthetic_source_batch_id(
icn, pcn=pcn, source_filename=source_file,
)
# Build the batch envelope index BEFORE opening the work session
# — cycl_store.batch_envelope_index opens its own short-lived
# session, and SQLite + concurrent sessions causes "database is
# locked" errors.
batch_index = cycl_store.batch_envelope_index()
with db.SessionLocal()() as session:
def _lookup(pcn: str):
return (
session.query(db.Claim)
.filter_by(patient_control_number=pcn)
.first()
)
rejection_result = apply_999_rejections(
session, result,
claim_lookup=_lookup,
batch_envelope_index=batch_index,
)
if rejection_result.matched:
for cid in rejection_result.matched:
append_event(session, AuditEvent(
event_type="claim.rejected",
entity_type="claim",
entity_id=cid,
payload={"source_batch_id": synthetic_id},
actor="999-parser-scheduler",
))
row = cycl_store.add_ack(
source_batch_id=synthetic_id,
accepted_count=accepted,
rejected_count=rejected,
received_count=received,
ack_code=ack_code,
raw_json=json.loads(result.model_dump_json()),
)
# SP28: auto-link the 999 AK2 set-responses to claims (D10 two-pass
# join). The PCN fallback only fires when the ST02 lookup misses
# (rare for Gainwell batches). Each created ClaimAck row is
# persisted via cycl_store.add_claim_ack so the publish-from-store
# contract owns the live-tail event.
def _pcn_lookup(pcn: str):
return (
session.query(db.Claim)
.filter_by(patient_control_number=pcn)
.first()
)
link_result = apply_999_acceptances(
session, result, ack_id=row.id,
batch_envelope_index=batch_index,
pc_claim_lookup=_pcn_lookup,
)
# Snapshot the rows before closing the work session — SQLite
# + concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=row.id,
ack_kind="999",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"999 had %d orphan set refs (no matching claim): %s",
len(orphans),
orphans[:5],
)
return ("parse_999", received)
@@ -1,37 +0,0 @@
"""Shared ``HandleResult`` dataclass for handlers (SP27).
Every per-file-type handler returns the same shape so the
scheduler's ``_handle_one`` and the FastAPI parse endpoints can
process them uniformly.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class HandleResult:
"""Outcome of one handler invocation.
Attributes:
parser_used: The parser name written to
``processed_inbound_files.parser_used`` (e.g. ``"parse_999"``)
and surfaced in the UI.
claim_count: The number of claim rows persisted (or batch
records, depending on the file type). For 999/TA1 this
is the receipt count; for 835 this is the per-claim
remittance count; for 277CA this is the per-claim
status count.
batch_id: The persisted batch id (when the handler creates a
row in ``batches``). ``None`` for handlers that persist
into per-file-type ack tables (999/TA1/277CA) rather
than into the unified ``batches`` table.
matched_count: For 835, the number of remits that were
matched to an existing claim by ``reconcile.match``.
Zero for other handlers.
"""
parser_used: str
claim_count: int
batch_id: str | None = None
matched_count: int = 0
-123
View File
@@ -1,123 +0,0 @@
"""Handle a TA1 Interchange Acknowledgment file (SP27 Task 3).
Lifted verbatim from ``scheduler.py:_handle_ta1``. The handler owns
its own DB session, dispatches to ``parse_ta1_text``, persists the
interchange ack row in ``ta1_acks``, and returns a
``(parser_used, claim_count)`` tuple.
Unlike the 999 handler, TA1 is envelope-only there is one TA1 per
ISA/IEA interchange, no set-level (AK2) or claim-level matching.
The claim_count is always 1 (one TA1 ack row per file).
The actor tag is implicit (no audit event here TA1 doesn't tag
anything in the activity log; it's an infrastructure-level ack).
"""
from __future__ import annotations
import json
import logging
from cyclone import db
from cyclone.claim_acks import apply_ta1_envelope_link
from cyclone.parsers.exceptions import CycloneParseError
from cyclone.parsers.parse_ta1 import parse_ta1_text
from cyclone.store import store as cycl_store
log = logging.getLogger(__name__)
def handle(
text: str,
source_file: str,
) -> tuple[str, int]:
"""Parse a TA1, persist the interchange ack row.
Args:
text: Raw TA1 document bytes (decoded).
source_file: Filename the TA1 came from. Used for audit
attribution; the ``batches.id`` for TA1 rows is derived
internally from the parsed envelope's control number
(``TA1-{ICN}``).
Returns:
``(parser_used, claim_count)`` tuple. TA1 always returns
``claim_count=1`` (one TA1 ack row per interchange file).
Raises:
ValueError: on parser-level failure (wraps CycloneParseError).
SP25: the store (``cyclone.store.acks.add_ta1_ack``) owns the
publish-from-store contract; the handler no longer needs an
``event_bus`` kwarg.
"""
try:
result = parse_ta1_text(text, input_file=source_file)
except CycloneParseError as exc:
raise ValueError(f"TA1 parse error: {exc}") from exc
with db.SessionLocal()() as session:
ta1_ack_row = cycl_store.add_ta1_ack(
source_batch_id=result.source_batch_id,
control_number=result.ta1.control_number,
interchange_date=result.ta1.interchange_date,
interchange_time=result.ta1.interchange_time,
ack_code=result.ta1.ack_code,
note_code=result.ta1.note_code,
ack_generated_date=result.ta1.ack_generated_date,
sender_id=result.envelope.sender_id,
receiver_id=result.envelope.receiver_id,
raw_json=json.loads(result.model_dump_json()),
)
# SP28: TA1 envelope-level link to the originating Batch. The
# closure here matches the most-recent Batch whose envelope
# sender_id/receiver_id matches the TA1 — see spec §D4.
def _batch_lookup(sender_id, receiver_id):
rows = (
session.query(db.Batch)
.filter(
db.Batch.kind == "837p",
db.Batch.raw_result_json.isnot(None),
)
.order_by(db.Batch.parsed_at.desc())
.all()
)
for row in rows:
env = (row.raw_result_json or {}).get("envelope") or {}
if (
env.get("sender_id") == sender_id
and env.get("receiver_id") == receiver_id
):
return row
return None
link_result = apply_ta1_envelope_link(
session, result, ack_id=ta1_ack_row.id,
batch_lookup=_batch_lookup,
)
# Snapshot rows before closing the work session — SQLite +
# concurrent sessions from the same thread cause "database
# is locked" errors when add_claim_ack opens its own session
# while this one is still open.
link_rows = list(link_result.linked)
orphans = list(link_result.orphans)
session.commit()
for link_row in link_rows:
cycl_store.add_claim_ack(
claim_id=link_row.claim_id,
batch_id=link_row.batch_id,
ack_id=ta1_ack_row.id,
ack_kind="ta1",
ak2_index=link_row.ak2_index,
set_control_number=link_row.set_control_number,
set_accept_reject_code=link_row.set_accept_reject_code,
linked_by="auto",
)
if orphans:
log.warning(
"TA1 had %d orphan envelope refs (no matching batch): %s",
len(orphans),
orphans[:5],
)
return ("parse_ta1", 1)
-87
View File
@@ -175,82 +175,6 @@ def _line_count_lookup(session: Session, claims: list[Claim]) -> tuple[dict, dic
return matched_counts, total_lines_by_claim
def _ack_summary_for_claims(
session: Session, claim_ids: list[str],
) -> dict[str, dict]:
"""Build a {claim_id: {total, rejected, items: [...]}} map for 999 acks.
SP29: the Inbox `rejected` lane needs to render AK2 evidence
inline per row, plus a per-row Resubmit button. The data lives
in ``claim_acks`` (SP28) we don't want to N+1 fetch per row,
so the whole rejected-claim set is summarized in one batched
query here.
Filters to ``ack_kind='999'`` because the rejected lane is the
999 envelope reject lane; the 277CA STC A4/A6/A7 evidence flows
through the ``payer_rejected_*`` fields on a separate lane and
isn't part of this scope (see SP29 spec D4 / scope).
Returns:
``{claim_id: {"total": int, "rejected": int, "items": [...]}, ...}``
Claims with zero linked 999 acks are NOT in the returned
dict the caller maps via ``.get(cid)`` and treats absence
as "no 999 acks linked" (renders as ``null`` in the
payload, ``999 not linked`` in the UI).
Args:
session: SQLAlchemy session the caller owns.
claim_ids: list of claim.id values to summarize. Typically
the rejected-lane claim ids. Empty list empty dict.
"""
if not claim_ids:
return {}
from cyclone.db import ClaimAck # late import — DB model registered
rows = (
session.query(
ClaimAck.claim_id,
ClaimAck.ack_id,
ClaimAck.set_control_number,
ClaimAck.set_accept_reject_code,
ClaimAck.ak2_index,
ClaimAck.linked_at,
)
.filter(
ClaimAck.claim_id.in_(claim_ids),
ClaimAck.ack_kind == "999",
)
.order_by(ClaimAck.linked_at.desc(), ClaimAck.id.desc())
.all()
)
grouped: dict[str, list[tuple]] = {}
for cid, aid, scn, code, ak2i, lat in rows:
grouped.setdefault(cid, []).append((aid, scn, code, ak2i, lat))
rejected_codes = {"R", "E", "X"}
out: dict[str, dict] = {}
for cid, items in grouped.items():
total = len(items)
rejected_count = sum(1 for it in items if (it[2] or "") in rejected_codes)
# Keep 5 most recent items for the chip column. The full count
# is in ``total`` so the UI can show ``+N more`` honestly.
trimmed = items[:5]
out[cid] = {
"total": total,
"rejected": rejected_count,
"items": [
{
"ack_id": aid,
"set_control_number": scn,
"set_accept_reject_code": code or "",
"ak2_index": ak2i,
"linked_at": _isoformat(lat),
}
for (aid, scn, code, ak2i, lat) in trimmed
],
}
return out
def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes:
lanes = Lanes()
dismissed = set(dismissed_pairs)
@@ -268,17 +192,6 @@ def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) ->
),
))
# SP29: attach the 999 ack-evidence summary (total / rejected /
# 5 most recent AK2 set_responses) to every rejected row so the
# Inbox can render AK2 chips inline + a per-row Resubmit button
# without an extra round-trip. One batched query, keyed off the
# rejected-claim id set.
rejected_ack_summary = _ack_summary_for_claims(
session, [r["id"] for r in lanes.rejected]
)
for row in lanes.rejected:
row["claim_acks"] = rejected_ack_summary.get(row["id"])
# --- Payer-Rejected (SP10) ---
# Distinct from the 999 envelope "rejected" lane above. A claim
# lands here when a 277CA STC category code is A4/A6/A7 (rejected
+16 -37
View File
@@ -33,63 +33,42 @@ def apply_999_rejections(
parsed_999,
*,
claim_lookup: Callable[[str], Claim | None],
batch_envelope_index: dict[str, list[str]] | None = None,
) -> Apply999Result:
"""For each set response with code R, E, or X, look up the matching claim and
"""For each set response with code R or E, look up the matching claim and
move it to REJECTED. Idempotent on already-rejected claims.
Args:
session: SQLAlchemy session.
parsed_999: a ParseResult999 (or any object with .set_responses).
claim_lookup: callable from patient_control_number Claim or None.
Legacy fallback; rarely hits when batch_envelope_index is present.
batch_envelope_index: SP33 mapping from SET control_number (the 837
envelope's ST02) to list of Claim.id for the claims in that SET.
Mirrors the SP28 fix in apply_999_acceptances so SET-level
rejections correctly cascade across every claim under the SET.
Returns:
Apply999Result with lists of matched claim ids and orphan PCNs.
"""
result = Apply999Result()
now = datetime.now(timezone.utc)
index = batch_envelope_index or {}
for sr in parsed_999.set_responses:
code = sr.set_accept_reject.code
if code not in ("R", "E", "X"):
continue
# SP33: prefer batch_envelope_index (SCN -> [claim_id]) so a SET-level
# rejection correctly flips every claim in the SET. Fall back to
# the legacy claim_lookup when the index is empty for this SCN.
candidate_ids = index.get(sr.set_control_number, []) or []
claims_to_reject: list[Claim] = []
if candidate_ids:
claims_to_reject = (
session.query(Claim)
.filter(Claim.id.in_(candidate_ids))
.all()
)
else:
legacy = claim_lookup(sr.set_control_number)
if legacy is not None:
claims_to_reject = [legacy]
else:
result.orphans.append(sr.set_control_number)
continue
claim = claim_lookup(sr.set_control_number)
if claim is None:
result.orphans.append(sr.set_control_number)
continue
for claim in claims_to_reject:
if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = _build_reason(
code, len(sr.segment_errors or [])
)
result.matched.append(claim.id)
if claim.state == ClaimState.REJECTED:
# Idempotent: don't double-mutate.
continue
claim.state = ClaimState.REJECTED
claim.state_changed_at = now
claim.rejected_at = now
claim.rejection_reason = _build_reason(
code, len(sr.segment_errors or [])
)
result.matched.append(claim.id)
if result.matched or result.orphans:
session.commit()
-379
View File
@@ -1,379 +0,0 @@
"""SP18 — Structured JSON logging.
Wraps Python's stdlib ``logging`` to emit newline-delimited JSON
(or a dev-friendly tabular format) and to scrub obvious PHI
patterns (NPIs, SSNs, DOBs, patient names) from the message +
extra fields.
Design choices
--------------
* **No third-party deps.** stdlib ``logging`` + ``json`` + ``re``
is enough. ``loguru`` / ``structlog`` were considered; both add
a dependency for marginal gain.
* **JSON by default.** Operators running Cyclone in production
almost certainly want logs in a format their aggregator
(Loki/ELK/Vector) can parse. The dev format (``CycloneDevFormatter``)
is the opt-out for ``tail -f`` in dev.
* **Conservative PII scrubber.** Redacts unambiguous PHI patterns
only. False positives are not free an operator's diagnostic
dump that says ``<redacted:npi>`` instead of the actual NPI
makes root-causing a parse failure harder. The scrubber can be
disabled with ``CYCLONE_LOG_NO_PII_SCRUB=1`` for tests /
forensic mode.
* **Idempotent setup.** :func:`setup_logging` can be called
multiple times (CLI re-invocation, FastAPI lifespan re-entry
under TestClient). Each call clears existing handlers on the
root logger before attaching fresh ones so the format toggle
actually takes effect on the second call.
"""
from __future__ import annotations
import json
import logging
import os
import re
import sys
from datetime import datetime, timezone
from logging.handlers import RotatingFileHandler
from typing import Any, Optional
# ---------------------------------------------------------------------------
# Formatters
# ---------------------------------------------------------------------------
# Stdlib LogRecord attributes we don't want to dump into the
# structured payload (they're noise for log consumers).
_RESERVED_LOGRECORD_ATTRS = frozenset({
"args", "asctime", "created", "exc_info", "exc_text", "filename",
"funcName", "levelname", "levelno", "lineno", "module", "msecs",
"message", "msg", "name", "pathname", "process", "processName",
"relativeCreated", "stack_info", "thread", "threadName",
"taskName",
})
class JsonFormatter(logging.Formatter):
"""Format a LogRecord as a single JSON line.
Fields:
ts ISO 8601 UTC timestamp with milliseconds.
level uppercase level name (INFO, WARNING, etc.).
logger the logger name (e.g. "cyclone.scheduler").
msg the formatted log message (after %-substitution).
extra dict of any non-reserved LogRecord attributes.
If ``exc_info`` is set, the formatter appends a ``traceback``
field with the formatted exception text (NOT a serialized
object just the stdlib-rendered string).
"""
def format(self, record: logging.LogRecord) -> str:
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
timespec="milliseconds",
)
payload: dict[str, Any] = {
"ts": ts,
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
# Collect user-provided extras.
extras = {
k: v
for k, v in record.__dict__.items()
if k not in _RESERVED_LOGRECORD_ATTRS and not k.startswith("_")
}
if extras:
payload["extra"] = extras
if record.exc_info:
payload["traceback"] = self.formatException(record.exc_info)
if record.stack_info:
payload["stack"] = self.formatStack(record.stack_info)
return json.dumps(payload, default=str, sort_keys=True)
class CycloneDevFormatter(logging.Formatter):
"""Dev-friendly tabular format.
Example:
2026-06-21T15:30:00.123Z INFO cyclone.scheduler Processed inbound foo.x12 parser=parse_999 claims=3
Same fields as ``JsonFormatter`` but human-readable. Useful for
``tail -f cyclone.log`` in dev.
"""
def format(self, record: logging.LogRecord) -> str:
ts = datetime.fromtimestamp(record.created, tz=timezone.utc).isoformat(
timespec="milliseconds",
)
extras = {
k: v
for k, v in record.__dict__.items()
if k not in _RESERVED_LOGRECORD_ATTRS and not k.startswith("_")
}
extra_str = ""
if extras:
pairs = " ".join(f"{k}={v!r}" for k, v in extras.items())
extra_str = " " + pairs
base = f"{ts} {record.levelname:<7s} {record.name} {record.getMessage()}{extra_str}"
if record.exc_info:
base += "\n" + self.formatException(record.exc_info)
return base
# ---------------------------------------------------------------------------
# PII scrubber
# ---------------------------------------------------------------------------
# Conservative PHI patterns. Each pattern is (label, compiled regex,
# replacement). Some patterns use a backreference so the field name
# (e.g. "dob=") is preserved and only the value is redacted — that
# keeps the surrounding context readable in the log line.
_PII_PATTERNS: tuple[tuple[str, "re.Pattern[str]", str], ...] = (
# 10-digit NPI. Word-boundary anchored so we don't redact, e.g.,
# the "10" in "10 claims processed".
("npi", re.compile(r"\b\d{10}\b"), "<redacted:npi>"),
# SSN: NNN-NN-NNNN or NNNNNNNNN.
(
"ssn",
re.compile(r"\b\d{3}-\d{2}-\d{4}\b|\b\d{9}\b(?=[\s,;)}])"),
"<redacted:ssn>",
),
# DOB: "dob=YYYY-MM-DD" / "date_of_birth=YYYY-MM-DD". Capture the
# field name + separator, redact only the date — keeps the
# surrounding sentence readable.
(
"dob",
re.compile(
r"(?i)(\b(?:dob|date[ _]?of[ _]?birth)[:=]\s*)\d{4}-\d{2}-\d{2}"
),
r"\1<redacted:dob>",
),
# Patient name: explicit field marker, redact the whole
# "patient_name=..." chunk so the value can't leak in a quoted form.
(
"patient_name",
re.compile(
r'(?i)\bpatient[_ ]?name[:=]\s*"?[^\",\s}]+',
),
"<redacted:patient_name>",
),
)
# Extra-field KEYS that we treat as PHI by themselves — if a log call
# passes an extra like ``extra={"date_of_birth": "1980-04-12"}`` we
# redact the value even though the value alone isn't PHI-shaped. The
# key is the signal. Matched case-insensitively against the full key
# (with underscores normalized to spaces for "date of birth").
_PHI_EXTRA_KEYS: dict[str, str] = {
"npi": "npi",
"provider_npi": "npi",
"rendering_npi": "npi",
"billing_npi": "npi",
"ssn": "ssn",
"dob": "dob",
"date_of_birth": "dob",
"patient_name": "patient_name",
"patient first name": "patient_name",
"patient last name": "patient_name",
}
# When an extra key matches one of these, redact any string value
# wholesale (don't try to parse it — just replace).
_PHI_EXTRA_WHOLE_VALUE = {"npi", "ssn", "dob", "patient_name"}
class PiiScrubber(logging.Filter):
"""Filter that redacts obvious PHI from log records.
Walks the formatted message + every ``extra`` field value (if
it's a string) and rewrites matches to ``<redacted:<name>``.
Non-string extras are left alone (we don't try to serialize and
re-scrub dicts too risky for false positives).
"""
def __init__(self, name: str = "pii_scrubber") -> None:
super().__init__(name)
self._enabled = True
def disable(self) -> None:
"""Disable scrubbing (for tests / forensic mode)."""
self._enabled = False
def enable(self) -> None:
self._enabled = True
def _scrub(self, text: str) -> str:
for label, pat, repl in _PII_PATTERNS:
text = pat.sub(repl, text)
return text
@staticmethod
def _normalize_extra_key(key: str) -> set[str]:
"""Return all candidate normalizations of a key.
``date_of_birth`` should match a lookup table that uses either
``date_of_birth`` or ``date of birth`` so return both. Same
for ``patient_name`` vs ``patient name``.
"""
norm = key.strip().lower()
spaced = norm.replace("_", " ")
return {norm, spaced}
def _redact_extra_value(self, key: str, value: Any) -> Any:
"""Redact a single extra field value if its key signals PHI."""
for norm in self._normalize_extra_key(key):
label = _PHI_EXTRA_KEYS.get(norm)
if label:
if not isinstance(value, str):
return value
return f"<redacted:{label}>"
return value
def filter(self, record: logging.LogRecord) -> bool:
if not self._enabled:
return True
# Scrub the formatted message.
try:
msg = record.getMessage()
scrubbed_msg = self._scrub(msg)
if scrubbed_msg != msg:
record.msg = scrubbed_msg
record.args = ()
except Exception: # noqa: BLE001
pass # never let the scrubber crash a log call
# Scrub string extras in place. We mutate the record's
# __dict__ directly so the formatter sees the scrubbed value.
for k, v in list(record.__dict__.items()):
if k in _RESERVED_LOGRECORD_ATTRS or k.startswith("_"):
continue
# First, key-based redaction (covers `extra={"dob": "..."}`).
redacted = self._redact_extra_value(k, v)
if redacted is not v:
record.__dict__[k] = redacted
continue
# Second, value-pattern redaction (covers `extra={"note":
# "patient_name=John Doe"}`).
if isinstance(v, str):
scrubbed = self._scrub(v)
if scrubbed != v:
record.__dict__[k] = scrubbed
return True
# Module-level singleton so tests / callers can disable it cleanly.
_scrubber = PiiScrubber()
def get_scrubber() -> PiiScrubber:
"""Return the module-level PII scrubber singleton."""
return _scrubber
# ---------------------------------------------------------------------------
# setup_logging entry point
# ---------------------------------------------------------------------------
def _resolve_level(level: str | int | None) -> int:
"""Resolve a level string/int, falling back to INFO."""
if level is None:
return logging.INFO
if isinstance(level, int):
return level
name = str(level).strip().upper()
return logging.getLevelNamesMapping().get(name, logging.INFO)
def setup_logging(
*,
level: str | int | None = None,
log_file: str | None = None,
json_format: bool = True,
scrub_pii: bool = True,
propagate_from: str | None = None,
) -> logging.Logger:
"""Configure the root logger + attach handlers.
Idempotent: re-calling clears existing handlers on the root
logger before attaching fresh ones. Safe to call from
``click.command`` invocations and the FastAPI lifespan.
Args:
level: ``"DEBUG"`` / ``"INFO"`` / etc. or an int. ``None``
means honor ``CYCLONE_LOG_LEVEL`` env var, then INFO.
log_file: Path to a rotating log file. ``None`` means
honor ``CYCLONE_LOG_FILE`` env var, then stderr.
json_format: Emit JSON lines (default). ``False`` uses
:class:`CycloneDevFormatter`.
scrub_pii: Apply the PII scrubber (default). Honored via
``CYCLONE_LOG_NO_PII_SCRUB=1`` to disable.
propagate_from: Optional logger name to attach the scrubber
to (defaults to root).
Returns:
The configured root logger.
"""
# Resolve env-var defaults.
if level is None:
level = os.environ.get("CYCLONE_LOG_LEVEL", "INFO")
if log_file is None:
log_file = os.environ.get("CYCLONE_LOG_FILE") or None
if not json_format and os.environ.get("CYCLONE_LOG_JSON", "").lower() in (
"false", "0", "no",
):
json_format = True
if os.environ.get("CYCLONE_LOG_NO_PII_SCRUB", "").lower() in ("1", "true", "yes"):
scrub_pii = False
root = logging.getLogger()
root.setLevel(_resolve_level(level))
# Clear existing handlers (idempotent re-setup).
for h in list(root.handlers):
root.removeHandler(h)
# Also clear our scrubber so we don't add duplicates.
target = logging.getLogger(propagate_from) if propagate_from else root
for flt in list(target.filters):
if isinstance(flt, PiiScrubber):
target.removeFilter(flt)
# Build the formatter.
fmt: logging.Formatter
if json_format:
fmt = JsonFormatter()
else:
fmt = CycloneDevFormatter()
# Build the handler.
if log_file:
handler: logging.Handler = RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024,
backupCount=5,
encoding="utf-8",
)
else:
handler = logging.StreamHandler(stream=sys.stderr)
handler.setFormatter(fmt)
root.addHandler(handler)
# Attach the scrubber.
if scrub_pii:
_scrubber.enable()
else:
_scrubber.disable()
target.addFilter(_scrubber)
# Quiet down noisy third-party libs.
for noisy in ("urllib3", "paramiko", "sqlalchemy.engine"):
logging.getLogger(noisy).setLevel(max(root.level, logging.WARNING))
return root
@@ -1,47 +0,0 @@
-- version: 11
-- SP16: Inbound MFT polling scheduler
--
-- Tracks every file the background scheduler has downloaded from
-- the Gainwell MFT inbound path so a re-tick (or a restart) does not
-- re-process the same file. Idempotency is required for production:
-- the scheduler polls every N seconds and a slow MFT server may hand
-- us the same file across two polls.
--
-- We key on (sftp_block_name, name) — the sftp_block_name disambiguates
-- multi-provider installations (SP9+SP-multi-NPI), name is the inbound
-- filename as it appears on the MFT server.
--
-- Status values:
-- * ok — parsed cleanly, results persisted to the store
-- * error — parser raised; error_message captured for the operator
-- * skipped — file_type not in the scheduler's allowed set
-- * pending — file was downloaded but a downstream step failed
-- (e.g. DB write); the scheduler retries on the next tick
--
-- claim_count is the number of claims/remittances/acks the parser
-- surfaced. Surfaced on /api/admin/scheduler/status so the operator can
-- see throughput without parsing logs.
--
-- Compliance: not part of the HIPAA audit chain (SP11). This is
-- operational metadata; an SFTP outage shouldn't pollute the audit log.
CREATE TABLE processed_inbound_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sftp_block_name TEXT NOT NULL,
name TEXT NOT NULL,
size INTEGER NOT NULL,
modified_at TEXT,
file_type TEXT,
processed_at TEXT NOT NULL,
parser_used TEXT,
claim_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL,
error_message TEXT
);
CREATE UNIQUE INDEX ux_processed_inbound_files_block_name
ON processed_inbound_files(sftp_block_name, name);
CREATE INDEX ix_processed_inbound_files_processed_at
ON processed_inbound_files(processed_at DESC);
CREATE INDEX ix_processed_inbound_files_status
ON processed_inbound_files(status);
@@ -1,29 +0,0 @@
-- version: 12
-- SP17: encrypted DB backup metadata
--
-- Tracks every backup the BackupService has taken. The actual
-- encrypted blob lives in a directory outside the DB (default
-- ~/.local/share/cyclone/backups/); this table is just the index
-- the operator queries via GET /api/admin/backup/list.
--
-- Status values:
-- pending - row inserted, .backup() in progress or crashed before commit
-- ok - encrypted blob + sidecar written successfully
-- error - creation failed; error_message populated
-- pruned - retention policy removed the file; row kept for audit
CREATE TABLE db_backups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
backup_dir TEXT NOT NULL,
size_bytes INTEGER NOT NULL DEFAULT 0,
db_fingerprint TEXT,
table_count INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
completed_at TEXT,
status TEXT NOT NULL,
error_message TEXT
);
CREATE UNIQUE INDEX ux_db_backups_filename ON db_backups(backup_dir, filename);
CREATE INDEX ix_db_backups_created_at ON db_backups(created_at DESC);
CREATE INDEX ix_db_backups_status ON db_backups(status);
@@ -1,31 +0,0 @@
-- 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);
@@ -1,10 +0,0 @@
-- 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);
@@ -1,74 +0,0 @@
-- 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;
@@ -1,15 +0,0 @@
-- version: 16
-- Add the missing index on claims.matched_remittance_id.
--
-- SP27 Task 11 added ``check_matched_pair_drift`` at startup, which
-- scans ``WHERE Claim.matched_remittance_id IS NOT NULL``. Without an
-- index this is a full-table scan; becomes a noticeable boot
-- latency cost past ~10k claims. The companion index on the
-- reverse side (``remittances.claim_id``) was added in 0007.
--
-- A plain index is enough — neither side is unique (reversals
-- re-reference the original PCN, and a claim without a match is
-- fine).
CREATE INDEX ix_claims_matched_remittance_id
ON claims(matched_remittance_id);
@@ -1,28 +0,0 @@
-- version: 17
-- Backfill claims.patient_control_number = claims.id.
--
-- SP27 Task 17 fixed the 837 ingest in store.py:_claim_837_row so
-- ``Claim.patient_control_number`` is populated from
-- ``claim.claim_id`` (CLM01) instead of ``claim.subscriber.member_id``
-- (the 2010BA NM109). The reconcile matcher joins on
-- ``Claim.patient_control_number == Remittance.payer_claim_control_number``
-- and the 835 echoes CLM01 in CLP01 per X12 spec, so the wrong field
-- silently broke every auto-match in production.
--
-- For rows written BEFORE the fix, the stored value is the member_id
-- (e.g. "W953474") which never matches any remit's CLP01. This
-- migration backfills those rows by aligning
-- ``patient_control_number`` with the row's own ``id`` (= CLM01).
-- After this, every claim row's PCN is consistent with the value the
-- 837 actually sent, and any newly-ingested 835 whose CLP01 echoes
-- that CLM01 will auto-pair.
--
-- Idempotent: only touches rows where the stored PCN doesn't already
-- match the row's id, so re-running on already-fixed rows is a no-op.
--
-- Reversal safety: the migration does NOT clear matched_remittance_id,
-- so any pre-existing manual_match pairs stay intact.
UPDATE claims
SET patient_control_number = id
WHERE patient_control_number IS DISTINCT FROM id;
@@ -1,52 +0,0 @@
-- version: 18
-- SP28: per-ACK auto-link join table.
--
-- Closes the operator gap where every inbound 999 / 277CA / TA1 ack was
-- persisted but never durably linked back to the claim it
-- acknowledges. One row per AK2 set-response for 999, per ClaimStatus
-- for 277CA, per TA1 envelope (with claim_id NULL + batch_id set).
--
-- Granularity (per-AK2) is preserved by ``ak2_index`` and the unique
-- index ``ux_claim_acks_dedup`` — an auto-link of
-- (claim, 999, ak2_index=3) is idempotent on re-ingest of the same
-- 999 file (the index enforces this at the DB layer; the helper
-- pre-checks to avoid IntegrityError log noise).
--
-- Notes:
-- * ``claim_id`` is nullable so TA1 envelope-level links to the
-- originating Batch can land here (FK to batches.id). The
-- CHECK constraint makes sure at least one of (claim_id,
-- batch_id) is set on every row — see spec §3.1.
-- * ``set_control_number`` records the value the upstream ACK
-- ACTUALLY CARRIED (== source 837 ST02 for Gainwell batches). It
-- is the orphan-traceability field — the link survives even when
-- the join had to fall back from ST02 to PCN matching.
-- * ``set_accept_reject_code`` carries the AK5 code (A/E/R/X) for
-- 999 or the STC category code (A1/A2/A3/A4/A6/A7 etc.) for 277CA.
-- TA1 stores the envelope-level ack code here (A/R/E).
-- * No FK constraint on ``(ack_kind, ack_id)`` — there are three
-- separate ack tables (``acks``, ``ta1_acks``, ``two77ca_acks``).
-- Application code enforces the discriminator.
CREATE TABLE claim_acks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id TEXT REFERENCES claims(id) ON DELETE CASCADE,
batch_id TEXT REFERENCES batches(id) ON DELETE CASCADE,
ack_id INTEGER NOT NULL,
ack_kind TEXT NOT NULL CHECK (ack_kind IN ('999', '277ca', 'ta1')),
ak2_index INTEGER,
set_control_number TEXT,
set_accept_reject_code TEXT,
linked_at DATETIME NOT NULL,
linked_by TEXT NOT NULL CHECK (linked_by IN ('auto', 'manual')),
CHECK ((claim_id IS NOT NULL) OR (batch_id IS NOT NULL))
);
CREATE INDEX ix_claim_acks_claim_id ON claim_acks(claim_id);
CREATE INDEX ix_claim_acks_batch_id ON claim_acks(batch_id);
CREATE INDEX ix_claim_acks_ack ON claim_acks(ack_kind, ack_id);
-- Dedup: an auto-link of (claim, 999, ak2_index=3) is idempotent on re-ingest.
CREATE UNIQUE INDEX ux_claim_acks_dedup
ON claim_acks(claim_id, ack_kind, ack_id, ak2_index)
WHERE claim_id IS NOT NULL AND ak2_index IS NOT NULL;
@@ -1,7 +0,0 @@
-- version: 19
-- SP32: render & service-provider NPI extraction.
-- Nullable: existing rows stay NULL until backfill runs.
-- No indexes (used for set-equality, not range queries; nullable).
ALTER TABLE claims ADD COLUMN rendering_provider_npi TEXT;
ALTER TABLE remittances ADD COLUMN rendering_provider_npi TEXT;
@@ -1,27 +0,0 @@
-- version: 20
-- SP37: Batch.transaction_set_control_number = parsed 837's ST02.
--
-- Today's 999 ack join (claim_acks.batch_envelope_index, Pass 1) matches
-- on ``Batch.envelope.control_number == 999's set_control_number``. That
-- never resolves in production because 999's set_control_number (AK201)
-- echoes the source 837's ST02 (transaction set control number), not the
-- ISA13 (interchange control number) that Envelope.control_number stores.
-- Result: every AK2 set-response against a dzinesco-generated 837 turns
-- into an orphan.
--
-- SP37 fixes this by adding a column populated from the parsed 837's
-- ST02 on every ``add_record`` write, then updating Pass 1 to match on
-- it (Task 2). This migration is the additive part: nullable, no
-- default, backfills from ``raw_result_json.envelope.transaction_set_control_number``
-- for any pre-existing batch rows that already carry the value.
--
-- No new index (column is a primary join key, not a range query; the
-- existing batches table is small enough for a full scan during the
-- 999 join — see SP37 §"Migration 0013").
ALTER TABLE batches ADD COLUMN transaction_set_control_number TEXT;
UPDATE batches
SET transaction_set_control_number = json_extract(raw_result_json, '$.envelope.transaction_set_control_number')
WHERE raw_result_json IS NOT NULL
AND json_extract(raw_result_json, '$.envelope.transaction_set_control_number') IS NOT NULL;
@@ -1,30 +0,0 @@
-- version: 21
-- SP39: resubmissions audit table for tracking corrected-file SFTP pushes.
--
-- One row per claim per push. Status (pending_999 / 999_accepted /
-- 999_rejected / 277ca_accepted / paid / denied_again) is derived at
-- read-time by joining against claim_acks (via the existing SP28/31
-- auto-link) + remittances (via CLP->claim). No denormalized status
-- column on this row — the existing auto-link data is the source of
-- truth and we want to avoid a write-coordination problem between
-- the SFTP push and the inbound ack ingestion.
--
-- Idempotency on (claim_id, interchange_control_number): the resubmit
-- CLI can be re-run safely without producing duplicate rows for the
-- same push.
CREATE TABLE resubmissions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claim_id TEXT NOT NULL,
batch_id TEXT NOT NULL,
resubmitted_at DATETIME NOT NULL,
source_corrected_path TEXT NOT NULL,
interchange_control_number TEXT NOT NULL,
group_control_number TEXT NOT NULL
);
CREATE INDEX ix_resubmissions_claim_id ON resubmissions(claim_id);
CREATE INDEX ix_resubmissions_batch_id ON resubmissions(batch_id);
CREATE UNIQUE INDEX ux_resubmissions_claim_icn
ON resubmissions(claim_id, interchange_control_number);
@@ -1,15 +0,0 @@
-- version: 22
-- SP41: claim-id dedup at SFTP pre-flight.
--
-- The 30-day dedup window is enforced by cyclone.store.submission_dedup.
-- This table records (claim_id, submitted_at) for every push that passed
-- the pre-flight check. Past the window, the prior record is ignored
-- (the guard lets through re-submits older than DEFAULT_WINDOW_DAYS).
CREATE TABLE submission_dedup (
claim_id TEXT PRIMARY KEY,
submitted_at DATETIME NOT NULL
);
CREATE INDEX submission_dedup_submitted_at_idx
ON submission_dedup (submitted_at);
@@ -1,30 +0,0 @@
-- version: 23
-- SP41: visits table — persists the AxisCare visits export (DOS 2026-01-01..06-27)
-- so the in-window rebill pipeline can reconcile visits vs 835 svc rows in
-- the database (previously the driver read the CSV in-memory, which is fine
-- for one-shot builds but loses the canonical source-of-truth for the
-- visit roster).
--
-- One row per (DOS, member_id, procedure) — the spot-check pipeline dedupes
-- on this key when reading back from the table.
CREATE TABLE visits (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dos DATE NOT NULL,
member_id TEXT NOT NULL,
client_name TEXT NOT NULL, -- "Last, First"
procedure_code TEXT NOT NULL,
modifiers TEXT, -- colon-joined (e.g. "KX:SC:U2")
billed_amount DECIMAL(10, 2) NOT NULL,
icd10 TEXT,
prior_auth TEXT,
payer TEXT, -- "CO Medicaid", "COHCPF", etc.
invoice_number TEXT,
source_file TEXT, -- which CSV this row came from
loaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(dos, member_id, procedure_code, modifiers)
);
CREATE INDEX visits_dos_idx ON visits (dos);
CREATE INDEX visits_member_id_idx ON visits (member_id);
CREATE INDEX visits_procedure_code_idx ON visits (procedure_code);
-159
View File
@@ -1,159 +0,0 @@
"""SP20 — NPI checksum + Tax ID format validation.
The National Provider Identifier (NPI) is a 10-digit number where
the last digit is a **Luhn checksum** over the 9 preceding digits
prefixed with the constant ``80840`` (the NPPES "healthcare
provider identifier" prefix). See CMS / HHS NPI Standard:
https://www.cms.gov/medicare/health-care-provider-identifier
The Tax ID (EIN) is a 9-digit number, optionally formatted with a
hyphen after the second digit (``XX-XXXXXXX``). We don't validate
against the IRS (that needs their e-file schema), but we *do* catch
the 99% typo case at parse time.
Everything in this module is local no NPPES, no network. Operators
who want real NPPES verification can wire it in later; this module
catches typos (an off-by-one in a 10-digit NPI, a letter in an EIN,
an extra digit, the all-zeros EIN prefix ``00`` / ``07``).
"""
from __future__ import annotations
import re
# NPPES prefix per the NPI Luhn algorithm. Prepended to the 9-digit
# NPI body before running the Luhn check.
_NPPES_PREFIX = "80840"
def npi_checksum(npi_body: str) -> int:
"""Compute the Luhn check digit for a 9-digit NPI body.
``npi_body`` must be exactly 9 digits; the caller is responsible
for length + character validation. Returns the check digit (09).
"""
if not npi_body.isdigit() or len(npi_body) != 9:
raise ValueError(f"npi_body must be 9 digits, got {npi_body!r}")
digits = _NPPES_PREFIX + npi_body
return _luhn_check_digit(digits)
def is_valid_npi(npi: str | None) -> bool:
"""True if ``npi`` is a well-formed 10-digit NPI with valid checksum.
Returns False for ``None`` / empty string / non-strings / wrong
length / non-digit characters / wrong Luhn check digit. Doesn't
call NPPES see module docstring for why.
>>> is_valid_npi("1234567893") # CMS-published example NPI
True
>>> is_valid_npi("1234567894") # last digit off by one
False
>>> is_valid_npi("1234567890") # passes digit but fails Luhn
False
>>> is_valid_npi("")
False
"""
if not isinstance(npi, str):
return False
if len(npi) != 10 or not npi.isdigit():
return False
return npi[-1] == str(npi_checksum(npi[:-1]))
# ---------------------------------------------------------------------------
# Tax ID (EIN)
# ---------------------------------------------------------------------------
# EIN prefix table (subset). The IRS publishes a full table; the
# common "this is obviously a typo" prefixes we reject are:
# 00 — reserved / never assigned
# 07 — campus prefixes reserved for future use
# 8X — formerly used by the IRS Pension Plan Branch
# Other 00-prefixed EINs (e.g., 000000000) are technically not
# assigned but we don't reject them here — the operator might have
# a deliberate placeholder.
_EIN_FORBIDDEN_PREFIXES = {"00", "07"}
_EIN_RESERVED_PREFIX_8X = re.compile(r"^8\d$")
# 9 digits, optionally formatted as XX-XXXXXXX.
_EIN_FORMATTED = re.compile(r"^\d{2}-\d{7}$")
_EIN_PLAIN = re.compile(r"^\d{9}$")
def normalize_tax_id(tax_id: str | None) -> str | None:
"""Return ``tax_id`` in 9-digit plain form, or None if it's malformed.
>>> normalize_tax_id("72-1587149")
'721587149'
>>> normalize_tax_id("721587149")
'721587149'
>>> normalize_tax_id("not-an-ein")
None
"""
if not isinstance(tax_id, str):
return None
s = tax_id.strip()
if _EIN_FORMATTED.match(s):
return s.replace("-", "")
if _EIN_PLAIN.match(s):
return s
return None
def is_valid_tax_id(tax_id: str | None) -> bool:
"""True if ``tax_id`` is a 9-digit EIN (formatted or plain) with
a non-reserved prefix.
>>> is_valid_tax_id("72-1587149") # Touch of Care
True
>>> is_valid_tax_id("00-1234567") # reserved prefix
False
>>> is_valid_tax_id("07-1234567") # reserved prefix
False
>>> is_valid_tax_id("not-an-ein")
False
"""
plain = normalize_tax_id(tax_id)
if plain is None:
return False
prefix = plain[:2]
if prefix in _EIN_FORBIDDEN_PREFIXES:
return False
if _EIN_RESERVED_PREFIX_8X.match(prefix):
return False
return True
# ---------------------------------------------------------------------------
# Luhn internals
# ---------------------------------------------------------------------------
def _luhn_check_digit(digits: str) -> int:
"""Return the Luhn check digit for ``digits``.
The Luhn algorithm doubles every second digit starting from the
RIGHTMOST position (i.e., the first digit doubled is the rightmost
character of ``digits``). If the doubled value exceeds 9, subtract
9. Sum all digits; the check digit is ``(10 - sum % 10) % 10``.
``digits`` here is the body WITHOUT the check digit for the NPI
case it's the 14-character ``80840`` + 9-digit NPI body. The
CMS-published example ``123456789`` (body) yields check digit
``3`` full NPI ``1234567893`` (verified against
https://www.cms.gov/.../NPIcheckdigit.pdf).
"""
total = 0
# The rightmost digit of ``digits`` is the FIRST one doubled (i=0
# in the reversed iteration). Per CMS, doubling starts at the
# rightmost and alternates leftward.
for i, ch in enumerate(reversed(digits)):
d = int(ch)
if i % 2 == 0: # rightmost, third-from-right, fifth-from-right, ...
d *= 2
if d > 9:
d -= 9
total += d
return (10 - total % 10) % 10
-17
View File
@@ -63,7 +63,6 @@ class ClaimHeader(_Base):
frequency_code: str | None = None
provider_signature: str | None = None
assignment: str | None = None
benefits_assignment_certification: str | None = None # CLM08 (Y/N)
release_of_info: str | None = None
prior_auth: str | None = None
@@ -88,14 +87,6 @@ class ServiceLine(_Base):
place_of_service: str | None = None
service_date: date | 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):
@@ -120,13 +111,6 @@ class Envelope(_Base):
implementation_guide: str | None = None
# SP3 P1 T2: BHT06 transaction type code (was: transaction_set_purpose_code, which is BHT02).
transaction_type_code: str | None = None
# SP37 Task 2: X12 ST02 (transaction set control number). Distinct
# from ``control_number`` above, which is the ISA13 interchange
# control number. 999 acks echo ST02 back as AK201, so this is the
# join key that lets ``add_record``'s batch row round-trip back to
# its source 837. Populated only by the 837P parser today; other
# parsers share this class but leave the field None.
transaction_set_control_number: str | None = None
class BatchSummary(_Base):
@@ -149,7 +133,6 @@ class ClaimOutput(_Base):
subscriber: Subscriber
payer: Payer
claim: ClaimHeader
rendering_provider_npi: str | None = None # NM1*82 NM109 (Loop 2420A)
diagnoses: list[Diagnosis] = Field(default_factory=list)
service_lines: list[ServiceLine] = Field(default_factory=list)
validation: ValidationReport
@@ -160,7 +160,6 @@ class ClaimPayment(_Base):
ref_benefit_plan: str | None = None # REF*CE per the CO guide
service_payments: list[ServicePayment] = Field(default_factory=list)
raw_segments: list[list[str]] = Field(default_factory=list)
service_provider_npi: str | None = None # NM1*1P NM109 (Loop 2100 service provider)
@model_validator(mode="before")
@classmethod
+3 -12
View File
@@ -376,7 +376,6 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
service_payments: list[ServicePayment] = []
ref_benefit_plan: str | None = None
service_provider_npi: str | None = None
per_diem: Decimal | None = None
status_label = claim_status_label(status)
@@ -423,16 +422,9 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
except ValueError:
per_diem = None
elif s[0] == "NM1":
# SP32: capture service-provider NPI from NM1*1P (Loop 2100).
# NM108 (idx 8) carries the ID qualifier (typically "XX");
# NM109 (idx 9) is the value. Some senders omit NM108; accept
# both forms but require a 10-digit ID.
if len(s) > 9 and s[1] == "1P":
if len(s) > 8 and s[8] == "XX" and s[9]:
if s[9].isdigit() and len(s[9]) == 10:
service_provider_npi = s[9]
elif s[9] and s[9].isdigit() and len(s[9]) == 10:
service_provider_npi = s[9]
# Patient (QC) / service-provider (1P) — captured in raw_segments.
# The 835 spec doesn't require a structured patient model in v1.
pass
elif s[0] == "DTM":
# Claim-level dates — captured in raw_segments.
pass
@@ -454,7 +446,6 @@ def _consume_claim_payment(segments: list[list[str]], idx: int) -> tuple[ClaimPa
ref_benefit_plan=ref_benefit_plan,
service_payments=service_payments,
raw_segments=raw,
service_provider_npi=service_provider_npi,
),
idx,
)
+1 -51
View File
@@ -83,12 +83,6 @@ def _build_envelope(segments: list[list[str]], input_file: str = "") -> tuple[En
except (IndexError, ValueError) as exc:
log.warning("Could not parse BHT date: %s", exc)
elif seg[0] == "ST" and envelope is not None:
# SP37 Task 2: capture ST02 (transaction set control number).
# 999 acks echo this back as AK201, so this is what makes the
# batch row joinable once the 999 ingests. Distinct from ISA13
# (which is already on ``control_number``).
if len(seg) > 2:
envelope = envelope.model_copy(update={"transaction_set_control_number": seg[2].strip()})
if len(seg) > 3:
envelope = envelope.model_copy(update={"implementation_guide": seg[3]})
return envelope, summary
@@ -125,30 +119,6 @@ def _consume_billing_provider(segments: list[list[str]], idx: int) -> tuple[Bill
return BillingProvider(name=name, npi=npi, tax_id=tax_id, address=addr), idx
def _consume_patient_loop(segments: list[list[str]], idx: int) -> int:
"""Skip over a 2000C patient loop (HL*3 → PAT → NM1*QC → N3 → N4 → DMG).
The 2000C loop is OPTIONAL in the X12 837P IG (only emitted when
Patient != Subscriber). The subscriber-level (2000B) parser must
skip past it to find the loop 2300 CLM. We do not extract the
patient demographics into the :class:`ClaimOutput` (the subscriber
doubles as patient in the self-pay CO-Medicaid case); we just
advance the cursor.
Returns the index of the first segment AFTER the patient loop
(the CLM, the next HL, or end-of-input).
"""
# Expect HL*3 as the first segment; if it isn't, don't consume.
if idx >= len(segments) or segments[idx][0] != "HL":
return idx
if len(segments[idx]) > 3 and segments[idx][3] != "23":
return idx
idx += 1 # consume HL*3
while idx < len(segments) and segments[idx][0] not in {"HL", "CLM"}:
idx += 1
return idx
def _consume_subscriber(segments: list[list[str]], idx: int) -> tuple[Subscriber, int]:
"""Read NM1*IL / N3 / N4 / DMG between ``idx`` and the next HL/CLM."""
first = ""
@@ -232,7 +202,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
frequency_code=freq or None,
provider_signature=clm[6] if len(clm) > 6 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,
)
@@ -240,7 +209,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
service_lines: list[ServiceLine] = []
raw: list[list[str]] = [seg]
prior_auth: str | None = None
rendering_provider_npi: str | None = None
idx += 1
while idx < len(segments) and segments[idx][0] not in {"HL", "CLM", "SE"}:
@@ -257,11 +225,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
for code in parts[1:]:
if code:
diagnoses.append(Diagnosis(code=code, qualifier=qualifier))
elif s[0] == "NM1" and len(s) > 1 and s[1] == "82":
# SP32: capture rendering provider NPI from Loop 2420A NM1*82.
# NM108 (idx 8) is the qualifier (typically "XX"), NM109 (idx 9) is the NPI.
if len(s) > 9 and s[8] == "XX" and len(s[9]) == 10 and s[9].isdigit():
rendering_provider_npi = s[9]
elif s[0] == "LX":
line_no = int(s[1]) if len(s) > 1 and s[1].isdigit() else len(service_lines) + 1
# LX is just a separator — the actual service line data is in the next SV1.
@@ -291,7 +254,6 @@ def _consume_claim(segments: list[list[str]], idx: int) -> tuple[ClaimOutput, in
subscriber=Subscriber(first_name="", last_name="", member_id="", address=Address(line1="", city="", state="", zip="")),
payer=Payer(name="", id=""),
claim=claim_header,
rendering_provider_npi=rendering_provider_npi,
diagnoses=diagnoses,
service_lines=service_lines,
validation=ValidationReport(passed=True, errors=[], warnings=[]),
@@ -323,17 +285,11 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
except Exception:
units = 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
provider_ref: str | None = None
idx += 1
while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE", "NM1"}:
while idx < len(segments) and segments[idx][0] not in {"LX", "HL", "CLM", "SE"}:
s = segments[idx]
raw.append(s)
if s[0] == "DTP" and len(s) > 2 and s[1] == "472":
@@ -355,7 +311,6 @@ def _consume_service_line(segments: list[list[str]], idx: int, line_no: int) ->
place_of_service=place_of_service,
service_date=service_date,
provider_reference=provider_ref,
dx_pointer=dx_pointer,
),
idx,
)
@@ -391,11 +346,6 @@ def parse(text: str, payer_config: PayerConfig, input_file: str = "") -> ParseRe
except Exception as exc: # pragma: no cover
log.warning("Payer parse failed at segment %d: %s", i, exc)
payer = Payer(name="", id="")
# SP41-fix: if a 2000C patient loop (HL*3) follows the 2010BB
# payer loop inside 2000B, skip past it so the inner CLM-harvest
# loop can find loop 2300. (Previously the NM1*PR lived AFTER
# the patient loop, so the parser saw CLM directly.)
i = _consume_patient_loop(segments, i)
# Consume all CLMs in this subscriber loop
while i < len(segments) and segments[i][0] != "HL":
if segments[i][0] == "CLM":
+2 -19
View File
@@ -8,12 +8,6 @@ Single-pass walker over the tokenized segment list:
- AK3 (Segment Context) + AK4 (Element Context) optional per-segment errors
- AK5 (Transaction Set Response Status) per-set accept/reject
- AK9 (Functional Group Response Status) per-group counts + ack code
- IK5 a non-standard synonym for ``AK5`` that Gainwell's MFT ships
in place of the spec-defined ``AK5``. The X12 005010X231A1 IG
treats the set-level response segment as ``AK5``; ``IK5`` is a
sender-specific deviation observed on Colorado Medicaid's Gainwell
MFT (verified against the live 999 files in the FromHPE inbound
path). We accept either.
- SE / GE / IEA
Errors at the file level raise :class:`CycloneParseError`. The parser
@@ -152,11 +146,6 @@ def _consume_ak3_ak4(segments: list[list[str]], idx: int) -> tuple[list[SegmentE
def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupResponse | None:
"""Read an AK2 + its child AK3*/AK4* + AK5 segments, return the SetResponse.
The set-level accept/reject segment is canonically ``AK5`` (see
X12 005010X231A1). We also accept ``IK5`` as a synonym because
Gainwell's MFT ships the segment under that id — see the file
header for the full rationale.
Returns None when called with a non-AK2 segment (defensive the
orchestrator only calls this when it sees AK2).
"""
@@ -175,11 +164,8 @@ def _consume_ak2(segments: list[list[str]], idx: int) -> SetFunctionalGroupRespo
if idx < len(segments) and segments[idx][0] == "AK3":
seg_errors, idx = _consume_ak3_ak4(segments, idx)
# AK5 (set accept/reject) — required by the spec; default to "R" if missing.
# Gainwell's MFT uses IK5 instead of AK5 (sender-specific segment id
# that means the same thing); accept either. The default of "R"
# matters: if the segment is missing entirely, the 999 is a reject.
accept_code = "R"
if idx < len(segments) and segments[idx][0] in ("AK5", "IK5"):
if idx < len(segments) and segments[idx][0] == "AK5":
ak5 = segments[idx]
if len(ak5) > 1 and ak5[1]:
accept_code = ak5[1]
@@ -270,11 +256,8 @@ def parse_999_text(text: str, *, input_file: str = "") -> ParseResult999:
set_responses.append(sr)
# Advance past the AK2 + AK3*/AK4*/AK5 cluster
# (re-walk from i+1 because _consume_ak2 doesn't return idx).
# ``IK5`` is the Gainwell-specific synonym for ``AK5``
# and must be in the consumed set here too (see
# _consume_ak2 for the full rationale).
i += 1
while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5", "IK5"}:
while i < len(segments) and segments[i][0] in {"AK3", "AK4", "AK5"}:
i += 1
else:
i += 1
+2 -2
View File
@@ -66,8 +66,8 @@ class PayerConfig(BaseModel):
# Lenient in v1 — see spec §9 R031.
require_ref_g1_for_adjustments=False,
allowed_bht06={"CH"},
payer_id="CO_TXIX",
payer_name="CO_TXIX",
payer_id="SKCO0",
payer_name="COHCPF",
no_patient_loop=True,
encounter_claim_in_same_batch=False,
allowed_facility_qualifiers={"B"},
+56 -457
View File
@@ -45,21 +45,11 @@ the prodfile parametrized smoke in
"""
from __future__ import annotations
import logging
from datetime import date, datetime
from decimal import Decimal
from types import SimpleNamespace
from cyclone.parsers.models import ClaimOutput
__all__ = [
"PATIENT_LOOP_DEFAULT_INCLUDED",
"SerializeError",
"serialize_837",
"serialize_837_for_resubmit",
"serialize_member_week_batch",
]
_SEG = "~"
_ELEM = "*"
_ISA_COMPONENT_SEPARATOR = ":"
@@ -72,22 +62,6 @@ class SerializeError(Exception):
"""Raised when a claim cannot be serialized."""
#: Default value for ``_build_subscriber_block(include_patient_loop=...)``.
#:
#: Per X12 005010X222A1, the 2000C Patient Hierarchical Level
#: (``HL*3 → PAT → NM1*QC``) is REQUIRED only when Patient != Subscriber
#: (i.e. ``SBR02 != "18"``). When ``SBR02 == "18"`` (Self-pay, the
#: CO-Medicaid IHSS workflow), the 2000C loop MUST be absent —
#: otherwise Edifabric / pyX12 reject the file with
#: ``2000C HL must be absent when 2000B SBR02 = "18"``.
#:
#: The regression test
#: ``tests/test_serialize_837.py::test_serialize_837_patient_loop_default_is_false``
#: pins this value to ``False``. Flipping it back to ``True`` requires
#: an explicit PR-level discussion (SP24 2026-07-08).
PATIENT_LOOP_DEFAULT_INCLUDED: bool = False
# ---------------------------------------------------------------------------
# Envelope helpers
# ---------------------------------------------------------------------------
@@ -138,10 +112,7 @@ def _build_gs(sender_id: str, receiver_id: str, group_control_number: str) -> st
_FUNCTIONAL_ID_HEALTH_CARE,
sender_id,
receiver_id,
# 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_yymmdd(),
_today_hhmm(),
group_control_number,
"X",
@@ -188,33 +159,7 @@ def _build_bht(
def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
id_code_qualifier: str | None, id_code: str | None) -> str:
"""Generic NM1 segment. entity_type is the 2nd element ('85', 'IL', 'PR', etc.).
For NM1*QC (patient) the X12 005010X222A1 IG marks NM108/NM109 as
"Not Used" the patient is identified by name only (NM103/NM104).
Pass ``id_code_qualifier=None`` and ``id_code=None`` for QC.
"""
# NM1*QC: skip NM108/NM109 entirely (X12 IG marks Not Used)
if entity_type == "QC":
names = (name or "").rsplit(" ", 1)
last = names[0] if names else ""
first = names[1] if len(names) > 1 else ""
parts = [
"NM1",
entity_type, # NM101
"1", # NM102 — person
last, # NM103 — name last
first, # NM104 — name first
"", # NM105 — name middle
"", # NM106 — name prefix
"", # NM107 — name suffix
# NM108/NM109 omitted (Not Used)
]
# Strip trailing empty elements to avoid trailing element separators
# (pyX12 flags "Segment contains trailing element terminators").
while parts and parts[-1] == "":
parts.pop()
return _ELEM.join(parts) + _SEG
"""Generic NM1 segment. entity_type is the 2nd element ('85', 'IL', 'PR', etc.)."""
parts = [
"NM1",
entity_type, # NM101 — entity identifier code
@@ -241,30 +186,17 @@ def _build_nm1(entity_id_qualifier: str, entity_type: str, name: str,
return _ELEM.join(parts) + _SEG
def _build_per(
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> str:
"""PER segment — submitter contact (Loop 1000A).
X12 005010X222A1 *requires* at least one PER segment in Loop 1000A
(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
def _build_per(contact_name: str | None, contact_phone: str | None) -> str:
"""PER segment — submitter contact. Returns empty when no contact info."""
if not contact_name and not contact_phone:
return ""
parts = [
"PER",
"IC", # PER01 — contact function code (Information Contact)
contact_name or "",
"TE", # PER03 — phone qualifier
contact_phone or "",
]
return _ELEM.join(parts) + _SEG
@@ -304,40 +236,26 @@ def _build_hl(hl_id: str, parent_id: str, level_code: str, child_code: str) -> s
return _ELEM.join(parts) + _SEG
def _build_sbr(
individual_relationship_code: str | None,
claim_filing_indicator_code: str | None,
) -> str:
def _build_sbr(relationship_code: str | None, member_id: str | None,
payer_name: str | None) -> str:
"""SBR segment — subscriber information.
Slot layout (X12 005010X222A1):
SBR01 Payer Responsibility Sequence Number Code. Default ``"P"``
(Patient = primary). The parser does not capture this
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.)
SBR01 (relationship code) defaults to ``"P"`` (Patient = self) which is
the most common case for professional claims; the parser does not store
this on the canonical Subscriber model so we cannot thread it through
without adding a model field.
"""
parts = [
"SBR",
"P", # SBR01 — primary
individual_relationship_code or "18", # SBR02 — self
"", # SBR03 — group number
"", # SBR04 — group name
"", # SBR05 — insurance type code
"", # SBR06 — coordination of benefits
"", # SBR07 — yes/no condition
"", # SBR08 — employment status code
claim_filing_indicator_code or "", # SBR09 — claim filing indicator
relationship_code or "P",
"", # SBR02 — group number
"", # SBR03 — group name
"", # SBR04 — claim filing indicator code
"", # SBR05 — sequence number code
payer_name or "", # SBR06 — claim filing indicator code (CO uses MC)
"", # SBR07
"", # SBR08
member_id or "", # SBR09 — claim submitter's id
]
return _ELEM.join(parts) + _SEG
@@ -380,13 +298,9 @@ def _build_clm(claim) -> str:
"", # CLM03 — non-institutional claim filing indicator
"", # CLM04 — non-institutional claim filing code
clm05, # CLM05 — composite POS:qualifier:frequency_code
claim.provider_signature or "Y", # CLM06 — Yes/No
claim.assignment or "A", # CLM07 — Assignment of Benefits (valid: A/B/C/P, NOT Y/N)
# 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.provider_signature or "Y", # CLM06
claim.assignment or "Y", # CLM07
"", # CLM08 — benefit assignment certification
claim.release_of_info or "Y", # CLM09
]
return _ELEM.join(parts) + _SEG
@@ -411,41 +325,21 @@ def _build_lx(line_number: int) -> str:
return _ELEM.join(["LX", str(line_number)]) + _SEG
def _build_sv1(line, *, dx_pointer: str | None = None) -> str:
"""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.
"""
def _build_sv1(line) -> str:
"""SV1 segment — professional service line."""
proc = line.procedure
code = proc.code if proc else ""
mods = proc.modifiers if proc else []
composite = "HC:" + code + "".join(f":{m}" for m in (mods or [])[:4])
charge = f"{Decimal(line.charge or 0):.2f}"
units = f"{Decimal(line.units):g}" if line.units is not None else "1"
sv1_07 = dx_pointer or ""
parts = [
"SV1",
composite, # SV1-01
charge, # SV1-02
line.unit_type or "UN", # SV1-03 — unit basis code
units, # SV1-04
line.place_of_service or "", # SV1-05
"", # SV1-06 — NOT USED in 837P
sv1_07, # SV1-07 — diagnosis pointer
composite,
charge,
line.unit_type or "UN",
units,
line.place_of_service or "",
]
return _ELEM.join(parts) + _SEG
@@ -463,36 +357,15 @@ def _build_dtp_472(service_date: date | None) -> str:
# ---------------------------------------------------------------------------
def _build_submitter_block(
sender_id: str,
submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None,
contact_email: str | None = None,
email_qual: str = "EM",
) -> list[str]:
# SP40: PER-02 (Name) and at least one PER-03/04 pair are required
# by Edifabric's x12/validate — emitting only PER-01 ("IC") makes
# the file invalid. Callers (the HTTP /api/claims/{id}/serialize-837
# endpoint, the bulk /api/batches/{id}/export-837 exporter, the
# regen-corrected-files sibling script, and the
# resubmit-rejected-claims CLI) MUST thread the real clearhouse
# submitter_contact_* values through. The placeholders below are a
# last-resort safety net so a developer running the serializer in
# isolation (e.g. a notebook) still gets a byte-clean file — Edifabric
# accepts the placeholder but the file is NOT production-ready, and
# the SP40 regen-test suite (``tests/test_api_serialize_837.py:
# test_endpoint_emits_real_submitter_and_receiver``) refuses to
# accept the placeholders leaking through any production code path.
if not any([contact_name, contact_phone, contact_email]):
contact_name = "CUSTOMER SERVICE"
contact_phone = "8005550100"
def _build_submitter_block(sender_id: str, submitter_name: str | None,
contact_name: str | None,
contact_phone: str | None) -> list[str]:
out = [
_build_nm1("41", "41", submitter_name or sender_id, "46", sender_id),
]
# PER is required by X12 (at least PER01). _build_per always emits
# the segment; the submitter block always has exactly one.
out.append(_build_per(contact_name, contact_phone, contact_email, email_qual))
per = _build_per(contact_name, contact_phone)
if per:
out.append(per)
return out
@@ -522,41 +395,11 @@ def _build_billing_provider_block(provider) -> list[str]:
return out
def _build_subscriber_block(
subscriber,
claim_filing_indicator_code: str | None,
payer=None,
include_patient_loop: bool = PATIENT_LOOP_DEFAULT_INCLUDED,
) -> list[str]:
"""Loop 2000B (HL*2) → SBR → 2010BA (NM1*IL) → 2010BB (NM1*PR).
Loop 2000B always contains the subscriber (2010BA) and payer
(2010BB) per X12 005010X222A1 the payer name (NM1*PR) belongs
INSIDE 2000B, after the subscriber's DMG, NOT after the patient
loop. pyX12 and Edifabric both reject "Mandatory loop 2010BB
missing" when NM1*PR is misplaced.
SP41 spot-check: optionally emits 2000C (HL*3 PAT NM1*QC) as a
child of 2000B so the verifier's literal ``grep NM1*QC`` succeeds.
Per the IG, 2000C is REQUIRED only when Patient != Subscriber. We
emit it unconditionally for the CO-Medicaid IHSS self-pay shape
(patient == subscriber) so the verifier's NM1*QC literal always
finds a match. HL*2 child count counts the number of 2000C (HL*3)
children, not the payer loop.
"""
# SP40: SBR-09 (Claim Filing Indicator Code) is required by
# Edifabric's x12/validate — emitting SBR*P*18******* (no SBR09)
# is invalid. Callers MUST thread the per-payer
# ``PayerConfig837.sbr09_claim_filing`` (or
# ``PayerConfigORM.config_json['sbr09_default']``) value through;
# "MC" is the CO-Medicaid-seeded default and a safe last-resort
# fallback for any trading partner on that code, but other payers
# (Medicare Part B "16", etc.) MUST override.
if not claim_filing_indicator_code:
claim_filing_indicator_code = "MC"
def _build_subscriber_block(subscriber, payer_name: str | None) -> list[str]:
"""HL*2 → SBR → NM1*IL → N3 → N4 → DMG. Subscriber has no children."""
out = [
_build_hl("2", "1", "22", "1" if include_patient_loop else "0"),
_build_sbr("18", claim_filing_indicator_code),
_build_hl("2", "1", "22", "0"), # HL*2 — subscriber, 0 children
_build_sbr("18", subscriber.member_id, payer_name),
_build_nm1(
"IL", "IL",
f"{subscriber.last_name} {subscriber.first_name}".strip(),
@@ -575,98 +418,21 @@ def _build_subscriber_block(
dmg = _build_dmg(subscriber.dob, subscriber.gender)
if dmg:
out.append(dmg)
# 2010BB — Payer Name. MUST come INSIDE 2000B (after subscriber
# DMG) and BEFORE 2000C (HL*3) per X12 005010X222A1. pyX12 and
# Edifabric both require 2010BB to be present and properly placed.
if payer is not None:
out.extend(_build_payer_block(payer))
# 2000C — Patient loop (HL*3 → PAT → NM1*QC → N3 → N4 → DMG).
# Always emitted in the SP41 self-pay shape (patient == subscriber).
# The PAT segment is required when 2000C is emitted. PAT01 codes:
# 01 = Self-pay (patient == subscriber)
# 02 = Spouse
# 03 = Child/dependent
# etc. We default to "01" (self-pay).
if include_patient_loop:
out.append(_build_hl("3", "2", "23", "0")) # HL*3 — patient, 0 children
out.append("PAT*01" + _SEG) # PAT — Patient Information (self-pay)
out.append(_build_nm1(
"QC", "QC",
f"{subscriber.last_name} {subscriber.first_name}".strip(),
None, # NM108 — Not Used for QC
None, # NM109 — Not Used for QC
))
if addr:
n3 = _build_n3(addr.line1, addr.line2)
n4 = _build_n4(addr.city, addr.state, addr.zip)
if n3:
out.append(n3)
if n4:
out.append(n4)
dmg = _build_dmg(subscriber.dob, subscriber.gender)
if dmg:
out.append(dmg)
return out
def _build_payer_block(payer) -> list[str]:
name, pid = _normalize_payer_id(payer)
return [
_build_nm1("PR", "PR", name, "PI", pid),
_build_nm1("PR", "PR", payer.name, "PI", payer.id),
]
# Payer ids that must be normalized to CO_TXIX for CO Medicaid submissions.
# SP39: defense-in-depth against legacy raw_json captures (SKCO0 from
# pre-SP33 batches, CO_BHA from prior behavioral-health configurations,
# empty from degenerate parses). Foreign payer IDs are emitted verbatim.
_NORMALIZE_TO_CO_TXIX_IDS = frozenset({"", "SKCO0", "CO_BHA"})
_NORMALIZE_TO_CO_TXIX_NAMES = frozenset({"", "COHCPF", "CO_BHA"})
_CO_TXIX = "CO_TXIX"
_log = logging.getLogger(__name__)
def _normalize_payer_id(payer) -> tuple[str, str]:
"""Return (name, id) normalized so CO Medicaid claims always emit CO_TXIX.
SP39. Substitutes empty/SKCO0/CO_BHA -> CO_TXIX in the id and
empty/COHCPF/CO_BHA -> CO_TXIX in the name. Foreign payer ids are
passed through verbatim (only the CO Medicaid-shape values are
normalized; the helper must not corrupt a non-CO submit). Emits a
WARNING log line on substitution (one per call; the serializer is
invoked once per claim so volume is bounded by batch size).
"""
raw_id = (getattr(payer, "id", None) or "").strip()
raw_name = (getattr(payer, "name", None) or "").strip()
new_id = _CO_TXIX if raw_id in _NORMALIZE_TO_CO_TXIX_IDS else raw_id
new_name = _CO_TXIX if raw_name in _NORMALIZE_TO_CO_TXIX_NAMES else raw_name
if new_id != raw_id or new_name != raw_name:
_log.warning(
"SP39 2010BB payer normalization: id %r -> %r, name %r -> %r",
raw_id, new_id, raw_name, new_name,
)
return new_name, new_id
def _build_service_lines_block(service_lines, *, has_diagnoses: bool = False) -> list[str]:
"""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.
"""
def _build_service_lines_block(service_lines) -> list[str]:
"""Per line: LX / SV1 / DTP*472 / REF*6R."""
out: list[str] = []
for idx, line in enumerate(service_lines or [], start=1):
out.append(_build_lx(idx))
# 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))
out.append(_build_sv1(line))
dtp = _build_dtp_472(line.service_date)
if dtp:
out.append(dtp)
@@ -684,10 +450,7 @@ def serialize_837(
submitter_name: str | None = None,
submitter_contact_name: 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,
claim_filing_indicator_code: str | None = None,
interchange_control_number: str = "000000001",
group_control_number: str = "1",
) -> str:
@@ -699,16 +462,6 @@ def serialize_837(
(``"CYCLONE"`` / ``"RECEIVER"``) but real deployments should pass
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
emitted from the canonical ``ClaimOutput`` fields, so post-parse
edits propagate to the output.
@@ -728,15 +481,12 @@ def serialize_837(
),
]
segments.extend(_build_submitter_block(
sender_id, submitter_name,
submitter_contact_name, submitter_contact_phone, submitter_contact_email,
submitter_contact_email_qual,
sender_id, submitter_name, submitter_contact_name, submitter_contact_phone,
))
segments.extend(_build_receiver_block(receiver_id, receiver_name))
segments.extend(_build_billing_provider_block(claim.billing_provider))
segments.extend(_build_subscriber_block(
claim.subscriber, claim_filing_indicator_code, claim.payer,
))
segments.extend(_build_subscriber_block(claim.subscriber, claim.payer.name))
segments.extend(_build_payer_block(claim.payer))
# Claim-level editable segments.
segments.append(_build_clm(claim.claim))
@@ -747,10 +497,7 @@ def serialize_837(
segments.append(_build_hi(claim.diagnoses))
# Service lines (LX / SV1 / DTP*472 / REF*6R).
segments.extend(_build_service_lines_block(
claim.service_lines,
has_diagnoses=bool(claim.diagnoses),
))
segments.extend(_build_service_lines_block(claim.service_lines))
# SE segment count includes ST (line 3, 1-based) through SE itself
# — i.e. the entire ST..SE block inclusive.
@@ -767,163 +514,15 @@ def serialize_837_for_resubmit(
claim: ClaimOutput,
*,
interchange_index: int,
**kwargs,
) -> str:
"""Like :func:`serialize_837` but assigns deterministic-but-unique
interchange + group control numbers for a bundle position.
Interchange number = ``f"{interchange_index:09d}"``.
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(
claim,
interchange_control_number=f"{interchange_index:09d}",
group_control_number=str(interchange_index),
**kwargs,
)
# ---------------------------------------------------------------------------
# Pipeline B overload: MemberWeekBatch → one 837P, one CLM per visit
# ---------------------------------------------------------------------------
def _build_member_week_claim(visit, claim_id: str) -> tuple[str, str, str]:
"""Build the CLM / SV1 / DTP*472 segments for a single MemberWeekBatch visit.
Returns a tuple of three segment strings (CLM, SV1, DTP*472) emitted in
document order. The visit is a :class:`cyclone.rebill.reconcile.VisitRow`
only ``date`` / ``member_id`` / ``procedure`` / ``billed`` are read.
"""
# Minimal stand-ins for the Pydantic models that ``_build_clm`` /
# ``_build_sv1`` expect. SimpleNamespace avoids constructing full
# ClaimOutput / ServiceLine objects just to drop the member-level
# context (subscriber address, billing provider NPI, etc.) that
# Pipeline B doesn't have on its input shape.
procedure = SimpleNamespace(qualifier="HC", code=visit.procedure, modifiers=[])
claim = SimpleNamespace(
claim_id=claim_id,
total_charge=visit.billed,
place_of_service="11",
facility_code_qualifier="B",
frequency_code="1",
provider_signature="Y",
assignment="A", # CLM07 — Assignment of Benefits (valid: A/B/C/P, NOT Y/N; fix for pyX12)
benefits_assignment_certification="Y",
release_of_info="Y",
)
line = SimpleNamespace(
procedure=procedure,
charge=visit.billed,
unit_type="UN",
units=Decimal("1"),
place_of_service="11",
dx_pointer=None,
)
return (
_build_clm(claim),
_build_sv1(line, dx_pointer=""),
_build_dtp_472(visit.date),
)
def serialize_member_week_batch(
batch: "MemberWeekBatch",
*,
payer_id: str = "CO_TXIX",
tpid: str = "11525703",
) -> bytes:
"""Emit a single 837P envelope containing one CLM per visit in the batch.
SP41 / Pipeline B. Each :class:`cyclone.rebill.reconcile.VisitRow` in
``batch.visits`` becomes its own CLM with one SV1 and one DTP*472
service-line date. The envelope wraps all of them under a single
ISA/GS/ST header and a single SE/GE/IEA footer, matching the
standard clearinghouse batch shape (one envelope, many claims).
Building blocks are reused from :func:`serialize_837` so segment
layout stays consistent: the per-visit CLM is built by
``_build_clm`` (with place_of_service ``"11"`` /
facility_code_qualifier ``"B"`` / frequency_code ``"1"`` the
canonical outpatient professional defaults), the per-visit SV1 is
built by ``_build_sv1`` (HC:<procedure>, 1 unit, no diagnosis
pointer), and the service date is built by ``_build_dtp_472``.
Args:
batch: A :class:`cyclone.rebill.pipeline_b.MemberWeekBatch`
one member × one ISO-week worth of rebillable visits.
payer_id: The receiver (NM1*40) identifier. Defaults to
``"CO_TXIX"`` for CO Medicaid.
tpid: The trading-partner / submitter (NM1*41) identifier.
Defaults to Gainwell's ``"11525703"``.
Returns:
The complete 837P document as ASCII bytes. The caller writes
it to disk with HCPF-spec filenames via
:func:`cyclone.edi.filenames.build_outbound_filename`.
"""
# Deterministic control numbers derived from (member, iso_year,
# iso_week). Two batches with the same key get the same control
# numbers — fine for serialization idempotency, and the
# post-emission filename is also deterministic so the operator
# sees the same outbound filename on retry. Control-number
# uniqueness across different batches isn't required (the 837P
# ISA13 / GS06 are regenerated per-transmission by the SFTP
# submitter downstream).
control = f"{batch.member_id}{batch.iso_year:04d}{batch.iso_week:02d}"
interchange_control_number = control[:9].rjust(9, "0")
group_control_number = control[:9].lstrip("0") or "1"
st_control_number = control[:9].rjust(4, "0")[-4:]
segments: list[str] = [
_build_isa(tpid, payer_id, interchange_control_number),
_build_gs(tpid, payer_id, group_control_number),
_build_st(st_control_number),
_build_bht(
transaction_type_code="CH",
reference_id=f"MW-{batch.member_id}-W{batch.iso_week:02d}",
transaction_date=None,
transaction_time=None,
),
# Submitter block (Loop 1000A) — minimal but spec-valid.
# Member-week batches are emitted by the rebill pipeline, not
# the operator-facing single-claim download path, so the
# production clearhouse contact is not threaded through here
# (Task 12's orchestrator can wrap this overload with the
# clearhouse config if needed). PER*IC with a placeholder
# contact keeps the envelope byte-clean for the SP41 test
# suite without coupling this overload to the live Clearhouse
# ORM row.
_build_nm1("41", "41", tpid, "46", tpid),
_build_per("CUSTOMER SERVICE", "8005550100"),
# Receiver block (Loop 1000B).
_build_nm1("40", "40", payer_id, "46", payer_id),
]
for idx, visit in enumerate(batch.visits, start=1):
svc_date = visit.date
claim_id = f"MW-{batch.member_id}-{svc_date.isoformat()}-{idx:02d}"
clm, sv1, dtp = _build_member_week_claim(visit, claim_id)
segments.append(clm)
segments.append(sv1)
if dtp:
segments.append(dtp)
# SE segment count = ST (1) + everything between ST and SE inclusive.
# The existing serialize_837 computes `len(segments) - 2 + 1` because
# it subtracts ISA/GS and adds 1 for SE. That math reduces to
# `len(segments) - 1` at SE-emit time (since ISA/GS are in the
# list at that point and SE has not been added yet).
seg_count = len(segments) - 1
segments.append(_build_se(seg_count, st_control_number))
segments.append(f"GE*1*{group_control_number}{_SEG}")
segments.append(f"IEA*1*{interchange_control_number}{_SEG}")
return "".join(segments).encode("ascii")
-31
View File
@@ -35,36 +35,6 @@ def _r020_npi_format(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationI
yield ValidationIssue(rule="R020_npi_format", severity="error", message=f"Billing provider NPI must be 10 digits, got {claim.billing_provider.npi!r}")
def _r021_npi_checksum(claim: ClaimOutput, _: PayerConfig) -> Iterable[ValidationIssue]:
"""SP20: validate the billing-provider NPI's Luhn check digit.
A 10-digit NPI whose body passes R020's format check can still have
a bad Luhn check digit (a typo at the end). Yielded as a WARNING
not an error because operators sometimes ingest test fixtures with
placeholder NPIs (e.g. all-same-digit) and we don't want to block
that path. Local-only check, no NPPES round-trip.
"""
npi = claim.billing_provider.npi
if not npi:
return
# Skip silently if R020 already flagged the format — we don't want to
# duplicate the operator's screen with a second issue about the same NPI.
if not NPI_RE.match(npi):
return
# Lazy import keeps the validator module importable even if
# ``cyclone.npi`` is unavailable (e.g. in some legacy test setups).
try:
from cyclone.npi import is_valid_npi
except ImportError: # pragma: no cover — defensive
return
if not is_valid_npi(npi):
yield ValidationIssue(
rule="R021_npi_checksum",
severity="warning",
message=f"Billing provider NPI {npi!r} fails Luhn checksum (likely typo)",
)
def _r030_frequency_allowed(claim: ClaimOutput, cfg: PayerConfig) -> Iterable[ValidationIssue]:
if not claim.claim.frequency_code:
return
@@ -422,7 +392,6 @@ _RULES: list[Rule] = [
_r010_clm01_present,
_r011_total_charge_positive,
_r020_npi_format,
_r021_npi_checksum,
_r030_frequency_allowed,
_r031_ref_g1_optional,
_r034_ref_g1_required,
-13
View File
@@ -100,19 +100,6 @@ class EventBus:
yield await queue.get()
queue.task_done()
def stats(self) -> dict[str, int]:
"""Snapshot of subscriber counts per kind.
Used by ``/api/health`` (SP19) and the admin diagnostics page.
Returns ``{kind: count}`` for every kind with at least one
subscriber; kinds with zero subscribers are omitted.
"""
return {
kind: len(subs)
for kind, subs in self._subscribers.items()
if subs
}
def get_event_bus() -> EventBus:
"""Return the process-wide EventBus attached to the FastAPI app state.

Some files were not shown because too many files have changed in this diff Show More