docs: surface SP14 (Payer-Rejected UI + acknowledge) + SP15 (key rotation) #3

Closed
tyler wants to merge 14 commits from docs/sp14-15-readme-sync into main
Owner

What

Surface SP14 (5-lane Inbox UI + Payer-Rejected acknowledge action) and SP15 (SQLCipher key rotation) in the README. Both are merged into local main but were not yet documented in the README; the previous doc pass covered up to SP13.

The previous doc PR (#1) was effectively merged via d4f6fdd and is closed.

Important: branch divergence

The docs/sp14-15-readme-sync branch is based on the local main tip (1267a34), which is ahead of origin/main (8a65baa) by 7 commits — the SP15 feature, the SP15 merge, the api_helpers refactor, the api_routers split, the SP14 doc commit, and the trailing-newlines refactor.

The PR diff against origin/main therefore shows all 12 files in those 7 commits + this README commit, not just the README. The README change itself is one file / 85 + / 4 - (commit d25f00a).

Two ways to land this cleanly:

  1. Push main to origin/main first (it's the SP15 + refactor work that's already in local main). The PR will then show just the README change.
  2. Merge as-is — the diff will include the 7 unmerged local-main commits. This works but isn't what the PR title suggests.

Changes (README-only)

Inbox

  • Endpoint table now includes POST /api/inbox/payer-rejected/acknowledge (idempotent, audit-logged, returns transitioned / already_acked / not_found / not_rejected counts).

Encryption at Rest

  • New ### Key rotation sub-section explaining the POST /api/admin/db/rotate-key handler, the NullPool choice (SQLCipher thread affinity), the module-level threading.Lock, the error code mapping (409 / 400 / 503).
  • New endpoint row in the SP12 endpoint table.

Tamper-Evident Audit Log

  • Lead paragraph now mentions Payer-Rejected acknowledgements and SQLCipher key rotations as part of the audit-logged event set.

Roadmap

  • Bumped "shipped 2-13" to "shipped 2-15".
  • Added SP14 and SP15 entries to the shipped list at the top.
  • Added ### SP14 endpoints and ### SP15 endpoints reference sections at the end.

Project layout

  • Added api_routers/ (health.py, acks.py, ta1_acks.py) — FastAPI APIRouter subpackage extracted from api.py.
  • Updated api.py line to note it now mounts api_routers/*.

Verification

  • pytest --collect-only in backend/ collects 759 tests (up from 733 at the last doc pass).
  • git diff --check clean.
  • No code changes in the doc commit; no tests touched.
## What Surface SP14 (5-lane Inbox UI + Payer-Rejected acknowledge action) and SP15 (SQLCipher key rotation) in the README. Both are merged into local `main` but were not yet documented in the README; the previous doc pass covered up to SP13. The previous doc PR (#1) was effectively merged via `d4f6fdd` and is closed. ## Important: branch divergence The `docs/sp14-15-readme-sync` branch is based on the local `main` tip (`1267a34`), which is **ahead of `origin/main` (`8a65baa`) by 7 commits** — the SP15 feature, the SP15 merge, the api_helpers refactor, the api_routers split, the SP14 doc commit, and the trailing-newlines refactor. The PR diff against `origin/main` therefore shows all 12 files in those 7 commits + this README commit, not just the README. **The README change itself is one file / 85 + / 4 -** (commit `d25f00a`). Two ways to land this cleanly: 1. **Push `main` to `origin/main` first** (it's the SP15 + refactor work that's already in local main). The PR will then show just the README change. 2. **Merge as-is** — the diff will include the 7 unmerged local-main commits. This works but isn't what the PR title suggests. ## Changes (README-only) **Inbox** - Endpoint table now includes `POST /api/inbox/payer-rejected/acknowledge` (idempotent, audit-logged, returns `transitioned` / `already_acked` / `not_found` / `not_rejected` counts). **Encryption at Rest** - New `### Key rotation` sub-section explaining the `POST /api/admin/db/rotate-key` handler, the `NullPool` choice (SQLCipher thread affinity), the module-level `threading.Lock`, the error code mapping (`409` / `400` / `503`). - New endpoint row in the SP12 endpoint table. **Tamper-Evident Audit Log** - Lead paragraph now mentions Payer-Rejected acknowledgements and SQLCipher key rotations as part of the audit-logged event set. **Roadmap** - Bumped "shipped 2-13" to "shipped 2-15". - Added SP14 and SP15 entries to the shipped list at the top. - Added `### SP14 endpoints` and `### SP15 endpoints` reference sections at the end. **Project layout** - Added `api_routers/` (health.py, acks.py, ta1_acks.py) — FastAPI APIRouter subpackage extracted from `api.py`. - Updated `api.py` line to note it now mounts `api_routers/*`. ## Verification - `pytest --collect-only` in `backend/` collects **759 tests** (up from 733 at the last doc pass). - `git diff --check` clean. - No code changes in the doc commit; no tests touched.
tyler added 7 commits 2026-06-21 01:10:44 -06:00
First checkpoint of the architecture satisfaction loop. Cyclone's api.py
was a 2281-line god-module with 14 cross-cutting helpers inlined next
to the @app route declarations. This commit moves them to a dedicated
cyclone.api_helpers module:

- NDJSON wire format: ndjson_line, ndjson_stream_837, ndjson_stream_835,
  ndjson_stream_list.
- Content negotiation: client_wants_json, wants_ndjson.
- Strict / raw_segments rewrites: strict_rewrite_837, strict_rewrite_835,
  drop_raw_segments_837, drop_raw_segments_835.
- Validation probes: has_claim_validation_errors, has_835_validation_errors.
- Live-tail generator: tail_events, heartbeat_seconds, utcnow.

api.py re-imports them under the original underscore-prefixed names so
every route call site stays unchanged. claims_stream, remittances_stream,
and activity_stream remain exposed at cyclone.api (test_api_stream_live
imports them directly).

Verifies byte-identical NDJSON wire format, content negotiation rules,
and the tail_events async-generator semantics (deliberately polls the
EventBus queue rather than awaiting its async iterator, so heartbeats
don't poison the bus subscription).

Live-tested: GET /api/health, /api/claims, /api/remittances,
/api/activity, /api/acks, /api/providers, /api/inbox/lanes,
/api/inbox/payer-rejected/acknowledge, and the /api/claims/stream
NDJSON tail all return expected codes / payload.

Backend pytest: 29 failures identical to baseline (pre-existing
secrets env, serialize_837, db_crypto env, prodfile env failures),
700 passed. Frontend npm test: 357/357 passing.

See /tmp/refactor-cyclone.md for the full checkpoint log and the plan
for the next step (splitting api.py routes into FastAPI APIRouters).

Autoreview: /tmp/grok-review-local.md (0 bugs, 1 suggestion, 4 nits —
all addressed: dead asyncio/os imports removed, dead Any import
removed, duplicate utcnow import dropped, trailing newline added).
Adds in-place key rotation for the encrypted DB at rest (HIPAA
sec.164.308(a)(4) - periodic key rotation).

- db_crypto.rotate_db_key(): opens with old key, issues PRAGMA rekey,
  reopens with new key, verifies schema survived (table-count sanity).
- db_crypto.generate_db_key(): fresh 256-bit CSPRNG hex key.
- db_crypto.fingerprint(): SHA-256[:8] of a key, for the operator to
  compare across rotations.
- db.dispose_engine() + db.reinit_engine(): SP15 plumbing. The
  rotation endpoint disposes the pooled connections (SQLCipher
  refuses to rekey while another connection holds the file), runs
  the rekey, then rebuilds the engine with the new key from the
  Keychain.
- API: POST /api/admin/db/rotate-key with module-level threading.Lock
  to serialize rotations. 400 when encryption not enabled, 409 when
  a rotation is already in flight, 503 on rekey or Keychain failure
  with a reason that tells the operator what to do next.
- Engine uses NullPool when SQLCipher is enabled: the default
  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.
- Audit event db.key_rotated with old/new fingerprints and
  table_count, written after the engine is rebuilt so the new key
  proves it can take new writes.
- previous key is stashed to a second Keychain account so the
  operator can roll back if the new key turns out to be broken.

Tests:
- test_db_crypto.py: 12 new tests for generate/fingerprint/rekey
  mechanics (5 require SQLCipher at runtime; skipped otherwise).
- test_api_rotate_key.py: 6 new tests for endpoint wiring
  (encryption-required, Keychain update, audit event, rekey-failure
  rollback, Keychain-write-failure 503, concurrent-rotation 409).
Step 2 (first half) of the architecture satisfaction loop. api.py
shrank from 2595 to 2452 lines (-143) by extracting three read-only
resource groups into cyclone.api_routers:

- health.py: GET /api/health (1 endpoint)
- acks.py: GET /api/acks, GET /api/acks/{ack_id} (2 endpoints)
- ta1_acks.py: GET /api/ta1-acks, GET /api/ta1-acks/{ack_id} (2 endpoints)

Each router owns its endpoint bodies + the small UI-shape helper that
goes with them (_ack_to_ui, _ta1_to_ui, _serialize_ta1_from_row). The
helpers stay in the router file rather than being shared because each
is only used by its own endpoints.

api.py now ends the app-wiring section with three include_router()
calls. The new package is named cyclone.api_routers (not
cyclone.api.routers) to avoid the Python package-vs-same-named-module
ambiguity that would shadow the existing cyclone.api module.

Verifies: 41 targeted tests (test_acks, test_health, test_api_gets)
pass, full pytest is 8 failed / 735 passed / 16 skipped — identical
to clean main baseline. Live curl against the running server:
GET /api/health -> 200, GET /api/acks -> 200, GET /api/ta1-acks -> 200.

See /tmp/refactor-cyclone.md for the full plan.
Self-review nits from the router-split commit:
- All four new files lacked a trailing newline (PEP 8 / POSIX).
- acks.py was lazily importing ParseResult999 / serialize_999 inside
  get_ack_endpoint. Hoist to module-level — there's no import cycle
  (acks.py does not import from cyclone.api), so the imports are safe
  to do once.

No behavior change. Targeted tests (test_acks + test_health + test_api_gets)
still pass 41/41.
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.
tyler added 1 commit 2026-06-21 02:07:34 -06:00
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.
tyler added 5 commits 2026-06-21 03:06:44 -06:00
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.
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.
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.
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.
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.
tyler added 2 commits 2026-06-21 05:05:28 -06:00
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).
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.
tyler added 2 commits 2026-06-21 07:06:16 -06:00
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).
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.
tyler added 3 commits 2026-06-21 09:16:20 -06:00
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).
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.
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)
Author
Owner

Documentation pass (Jun 21 09:18 UTC): added SP16 Inbound MFT Scheduler section between SFTP Wire-Up and Batches, listing all 5 control-surface endpoints (start / stop / status / tick / processed-files). Forward-compat note flags that cyclone.scheduler and cyclone.db.ProcessedInboundFile are still in flight.

Also expanded Project layout tree: api.py comment corrected to "app + middleware" (no inline route bodies), all 14 routers now listed including the 4 parse_*.py files.

Head bumped to 81aebf5. 47/47 endpoints documented across README + backend README. 735 tests pass; 8 pre-existing failures (sqlcipher/keychain/paramiko missing in dev env, plus a .DS_Store polluting test_prodfile_cross_pipeline_reconciles on macOS) are unrelated to doc changes.

Documentation pass (Jun 21 09:18 UTC): added **SP16 Inbound MFT Scheduler** section between SFTP Wire-Up and Batches, listing all 5 control-surface endpoints (`start` / `stop` / `status` / `tick` / `processed-files`). Forward-compat note flags that `cyclone.scheduler` and `cyclone.db.ProcessedInboundFile` are still in flight. Also expanded Project layout tree: api.py comment corrected to "app + middleware" (no inline route bodies), all 14 routers now listed including the 4 `parse_*.py` files. Head bumped to 81aebf5. 47/47 endpoints documented across README + backend README. 735 tests pass; 8 pre-existing failures (sqlcipher/keychain/paramiko missing in dev env, plus a `.DS_Store` polluting `test_prodfile_cross_pipeline_reconciles` on macOS) are unrelated to doc changes.
tyler closed this pull request 2026-06-23 16:28:47 -06:00
tyler deleted branch docs/sp14-15-readme-sync 2026-06-23 16:28:47 -06:00

Pull request closed

Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tyler/cyclone#3