Files
cyclone/docs/superpowers/specs/2026-07-07-cyclone-orphan-data-recovery-design.md
T

63 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Sub-project 25 — Orphan Data Recovery: Design Spec
**Date:** 2026-07-07
**Status:** Draft, awaiting user sign-off
**Branch:** `sp25-orphan-data-recovery`
**Aesthetic direction:** No new UI; one CLI command + a small read-side defensive fallback.
## 1. Scope
This increment fixes three concrete data-tracking gaps in the production DB discovered during the 2026-07-07 dashboard postmortem:
1. **A — Defensive rehydration of synthetic `Batch` rows.** Four rows in the `batches` table (created by `CycloneStore.reconcile_orphan_st02s()`, kind `837p`, `input_filename='<synthetic:orphan-reconcile>'`, SCN 991102986989) were inserted with `raw_result_json=NULL`. Any read-side path that deserializes a `BatchRecord` (currently `_row_to_record()` in `store/batches.py`) fails them with the Pydantic error `summary: Field required`, so `GET /api/batches?limit=5` returns HTTP 500. Fix: `_row_to_record` returns a typed stub `BatchRecord` with empty `claims` / `remittances` lists and a minimal `BatchSummary` when `raw_result_json` is None or empty. The 999 ACK tracking these rows anchor remains intact — `Batch.transaction_set_control_number` and the `acks.source_batch_id` index are unchanged.
2. **B — Recovery of stranded source files in `ingest/`.** Four `tp11525703-837P-20260701...txt` claim files (338 claims, $57,986 billed, SCN 991102991994) and one `tp11525703-835_M019771179...x12` remittance file (1,148 payments) were never loaded into the DB. The 837Ps were SFTP-shipped to Gainwell but not recorded (because `parse-837` CLI only writes JSON, and `submit-batch` was never called). The 835 came back from the payer and was dropped into `ingest/` manually. New capability: `cyclone recover-ingest` CLI that parses each file, runs the canonical store write path (`CycloneStore.add()``Batch` + `Claim` for 837P; `Batch` + `Remittance` + `service_line_payments` for 835), and records the file in `processed_inbound_files` for idempotency. Does NOT SFTP-upload (files have already left this host).
3. **C — Auto line-level reconciliation.** Running B's 835 load already invokes `reconcile.run()` (per the `submit-batch` / store-add contract), which auto-matches the 1,148 service-line payments to their corresponding claims by patient_control_number ↔ payer_claim_control_number. No new code — verification step confirms the line-payment match count after B finishes.
### Out of scope
- SFTP-upload path stays unchanged. `recover-ingest` records what was already sent. Re-submitting these 837Ps to the payer would create duplicate billings; the operator explicitly does NOT want that.
- 999 ACK ingestion for SCN 991102991994. Those ACK files never arrived in `ingest/` (likely lost between Gainwell and the operator's SFTP client, or never generated because the envelopes never reached the payer — dashboard text "999 AK5=R … ak2=0" suggests Gainwell pre-rejected at the interchange level and never emitted AK2/AK5 segments). Recovery is the source 837 + the back 835; the missing 999s explain why the dashboard "0/N accepted" look is wrong even after B.
- Re-rendering the Dashboard tiles: a `pending` 837 creates a `SUBMITTED` claim, which IS counted in `_DASHBOARD_PENDING_STATES` (`{"submitted", "rejected"}`) — so the "Pending AR" tile will light up automatically. The "Billed" tile depends on `service_date_from`, which the parser already populates.
- Audit-log replay or hash-chain repair. The 999 acks in `acks` for SCN 991102986989 were recorded by `pull-inbound` after `reconcile_orphan_st02s()` seeded the synthetic batches, so their audit chain is intact post-fix.
## 2. Threats
The auth boundary is HTTP (login required, bcrypt + HttpOnly session cookie); file-system threats remain the local-only threat model (SQLCipher at rest, macOS Keychain). This increment is data-recovery only — no new HTTP endpoints, no new auth surface.
The CLI `cyclone recover-ingest` runs in the operator's shell with the live DB. Risks: writing duplicate batches if dedup is bypassed; recording claims that weren't actually billed. Mitigation: dedup via `processed_inbound_files` (block name `manual-recover`, filename basename) so a re-run is a no-op. The dedup index is `ux_processed_inbound_files_block_name UNIQUE (sftp_block_name, name)`.
The `cyclone serve` daemon is unaffected; this increment does not restart it. `_row_to_record` is read-path-only.
## 3. Decisions
- **D1.** The stub `BatchRecord` for synthetic rows carries `result.summary.total_claims=0`, `passed=0`, `failed=0`, `control_number=<ST02>`, `transaction_date=parsed_at.date()`. UI surfaces see "0 claims, 0 charged" which is correct — these batches deliberately have no claim rows.
- **D2.** `cyclone recover-ingest` parses locally, calls `store.add(record, event_bus=app.state.event_bus)` after FastAPI bootstrap so events reach the live-tail pages immediately. Outside of FastAPI: constructs a fresh `EventBus()` and runs `_publish_events_sync` against the local subscriber list (same pattern `submit-batch` uses).
- **D3.** Exit codes: 0 = at least one file ingested OK; 2 = a file failed to parse or DB-write; 1 = unexpected exception. Mirrors `submit-batch` convention.
- **D4.** Operator passes explicit `--file PATH` (repeatable) rather than scanning `ingest/`. The recovery targets known files; auto-scanning would risk pulling in 999 acks that have already been processed.
- **D5.** `recover-ingest` does NOT inject a synthetic batch row even if the input is a 999 ACK file without a matching source — that's `reconcile-orphan-st02s`'s job and it ran successfully on 7/7 for the SCNs it had visibility into.
## 4. Test impact
- Backend: `backend/tests/test_store_batches_synthetic.py` — assert `_row_to_record` tolerates None/empty `raw_result_json` and returns a stub `BatchRecord` with empty `claims[]` and the SCN preserved on `result.summary.control_number`. Same shape for `BatchRecord835`.
- Backend: `backend/tests/test_api_batches_synthetic.py` — assert `GET /api/batches?limit=5` returns 200 when the table has the 4 synthetic rows.
- Backend: `backend/tests/test_cli_recover_ingest.py` — three cases: ingest a synthetic 837P fixture, ingest the real `tp11525703-835_M019771179-20260706005516577-1of1.x12` fixture (copied to `backend/tests/fixtures/`), idempotency (re-run is a no-op).
- Recovery verification: a small inline script under `backend/scripts/verify_dashboard_recovery.py` that hits `/api/dashboard/kpis` after running the recovery and asserts `count=339+ (≥338), billed ≥ $57,986, received ≥ <post-reconcile-paid>`. Runs once during operator verification; not part of pytest.
## 5. Doc impact
`docs/superpowers/specs/2026-07-07-cyclone-dashboard-mess-postmortem.md` is the operator-facing writeup of the deep-dive (separate from this design spec). Its purpose is to record what was wrong with the dashboard on 2026-07-07, what was done to fix it, and what to watch for in future runs.
## 6. Risks
- **R1.** Replaying an 837P that was already billed could re-submit if the operator later runs `submit-batch` on it. Mitigation: `recover-ingest` does NOT upload; the file remains unmodified in `ingest/`.
- **R2.** Auto-reconciliation may not match every 835 line if the underlying claim's `patient_control_number` is empty or differs between the 837P CLM01 and the 835's CLP01. The verifier script reports `matches=0` separately so the operator can decide whether to manually pair.
- **R3.** The "999 AK5=R" dashboard badge for SCN 991102994 will still display as 0/N — there is no 999 to ingest. That's correct behavior, but the operator should expect a dashboard question.
## 7. Non-goals
- Replaying audit log events for the recovered batches. Audit entries are best-effort; the recovery increments log new `claim_submitted` / `remittance_received` events with the recovery time.
- Rebuilding the top-providers tile. With 4 NPIs still in providers and 338 claims about to land, the tile will recompute on the next KPI call.
- Adjusting `cyclone serve`'s in-memory SFTP block. The recovery is offline.