docs: surface SP14 (Payer-Rejected UI + acknowledge) + SP15 (key rotation) #2
@@ -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/{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/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/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. |
|
| GET | `/api/inbox/export.csv?lane=<lane>` | Streams CSV of the lane's rows. |
|
||||||
|
|
||||||
## Outbound 837 Serializer
|
## 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 `audit_log` table is the canonical record of every state transition
|
||||||
the system has ever observed — claim lifecycle, reconciliation
|
the system has ever observed — claim lifecycle, reconciliation
|
||||||
decisions, config reloads, SFTP submissions, 277CA rejects. SP11 made
|
decisions, config reloads, SFTP submissions, 277CA rejects, Payer-Rejected
|
||||||
it tamper-evident: every row carries a SHA-256 hash of
|
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.
|
`(prev_hash || row_payload)`, forming a chain back to a genesis row.
|
||||||
Any `INSERT`, `UPDATE`, or `DELETE` that breaks the chain is detectable
|
Any `INSERT`, `UPDATE`, or `DELETE` that breaks the chain is detectable
|
||||||
in a single walk.
|
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)
|
for the one-time setup recipe and the HIPAA Security Rule §164.312(a)(2)(iv)
|
||||||
mapping.
|
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)
|
## SFTP Wire-Up (paramiko)
|
||||||
|
|
||||||
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
|
The `clearhouse.submit` endpoint uses `paramiko` to push a batch of
|
||||||
@@ -415,8 +451,12 @@ backup API).
|
|||||||
.
|
.
|
||||||
├── backend/
|
├── backend/
|
||||||
│ ├── src/cyclone/
|
│ ├── 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_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)
|
│ │ ├── pubsub.py # in-process EventBus (drop-oldest, per-kind fan-out)
|
||||||
│ │ ├── store.py # CycloneStore, mappers, publish-on-write
|
│ │ ├── store.py # CycloneStore, mappers, publish-on-write
|
||||||
│ │ ├── db.py # SQLAlchemy engine, session factory, ORM models
|
│ │ ├── db.py # SQLAlchemy engine, session factory, ORM models
|
||||||
@@ -473,7 +513,7 @@ backup API).
|
|||||||
|
|
||||||
## Roadmap
|
## 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
|
review](docs/reviews/2026-06-20-cyclone-completeness-review.md) for
|
||||||
the honest gap analysis against the industry definition of a HIPAA
|
the honest gap analysis against the industry definition of a HIPAA
|
||||||
clearinghouse — the short version is that the local-only,
|
clearinghouse — the short version is that the local-only,
|
||||||
@@ -484,6 +524,19 @@ scope.
|
|||||||
|
|
||||||
Shipped sub-projects (most recent first):
|
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
|
- **Sub-project 13 (shipped) — SFTP wire-up.** `paramiko`-backed
|
||||||
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
|
`SftpClient` replaces the SP9 stub. The clearhouse.submit endpoint
|
||||||
actually pushes to
|
actually pushes to
|
||||||
@@ -752,6 +805,34 @@ the one-time setup recipe.
|
|||||||
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
|
`mft.gainwelltechnologies.com:/CO XIX/PROD/coxix_prod_11525703/FromHPE`.
|
||||||
SFTP credentials are fetched from the macOS Keychain at call time.
|
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
|
## License
|
||||||
|
|
||||||
No license file yet; this is internal-use software. Add a `LICENSE` file
|
No license file yet; this is internal-use software. Add a `LICENSE` file
|
||||||
|
|||||||
Reference in New Issue
Block a user