From d25f00ac5848857623fa5829226bfd7f245da6f9 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 21 Jun 2026 01:07:44 -0600 Subject: [PATCH] docs: surface SP14 (Payer-Rejected UI + acknowledge) + SP15 (key rotation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ccdbf04..a456b8f 100644 --- a/README.md +++ b/README.md @@ -155,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=` | Streams CSV of the lane's rows. | ## Outbound 837 Serializer @@ -321,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. @@ -357,6 +359,40 @@ 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 + +The DB encryption key is rotated in place via `POST /api/admin/db/rotate-key`. +The handler: + +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`. + +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. + +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. + +| 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 @@ -415,8 +451,12 @@ backup API). . ├── backend/ │ ├── src/cyclone/ -│ │ ├── api.py # FastAPI app, GET + parse routes, /api/{resource}/stream +│ │ ├── api.py # FastAPI app + parse routes; 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} │ │ ├── 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 @@ -473,7 +513,7 @@ backup API). ## Roadmap -Sub-projects 2 through 13 are **shipped**. 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 local-only, @@ -484,6 +524,19 @@ scope. Shipped sub-projects (most recent first): +- **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 + thread-affine under FastAPI's per-request threadpool. Writes a + `db.key_rotated` audit event with old + new key fingerprints and + post-rotation `table_count`. See + [Encryption at Rest — Key rotation](#key-rotation). +- **Sub-project 14 (shipped) — 5-lane Inbox UI.** The Payer-Rejected + lane is now rendered in the Inbox alongside Rejected / Candidates / + Unmatched / Done today. New bulk action + `POST /api/inbox/payer-rejected/acknowledge` drops claims from the + working surface without erasing the original 277CA rejection event + (audit log stays intact, SP11). - **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed `SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint actually pushes to @@ -752,6 +805,34 @@ the one-time setup recipe. `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