# Cyclone doc-prep review — Reviewer B (code-first lens)

## 1. Reviewer identity + lens

I am **Reviewer B**. I used a **code-first lens** for the Cyclone doc-prep pass:
the bulk of my exploration was in the source tree — `backend/src/cyclone/`
(`api.py`, `api_helpers.py`, `api_routers/`, `db.py`, `store.py`, `audit_log.py`,
`security.py`, `db_crypto.py`, `backup_service.py`, `reconcile.py`,
`inbox_lanes.py`, `scoring.py`, `inbox_state.py`, `inbox_state_277ca.py`,
`scheduler.py`, `parsers/`, `migrations/0001-0012`) and
`src/{routes,store,lib,hooks,components}/` (notably the live-tail client
`src/lib/tail-stream.ts` and the React hook `src/hooks/useTailStream.ts`).
I then read the top-of-funnel docs in the intended order:
`docs/README.md` → `docs/REQUIREMENTS.md` → `docs/ARCHITECTURE.md`. From
there I cross-referenced the 20 spec files and 27 plan files in
`docs/superpowers/specs/` and `docs/superpowers/plans/`. **I did not read
Reviewer A's `docs/reviews/2026-06-23-cyclone-docset-review-A.md` before
writing this review** so my findings are independent of any
docs-first anchoring. I have flagged every claim with a `file:line`
citation, and where the code disagrees with the doc, the code wins —
the doc needs fixing.

## 2. Executive summary

The Cyclone doc set is in **moderate health with a small number of
high-severity contradictions** that would mislead any engineer reading
docs first. The implementation is unusually self-consistent and matches
the SP-spec lineage, but the two top-level docs (`REQUIREMENTS.md`,
`ARCHITECTURE.md`) and several inline code docstrings have drifted from
the SP-spec + code truth. The top three issues are:

1. **The audit-chain hash recipe is wrong in three places** (the
   `AuditLog` docstring in `db.py:649-650`, the `audit_log.py:6-7`
   module docstring, and `ARCHITECTURE.md` §4.6) and only the SP11 spec
   (`docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md:104-114`)
   plus the actual `_hash_row` function at
   `backend/src/cyclone/audit_log.py:80-89` agree. This is a C-1
   severity finding because the verifier and any future auditor will
   compute different hashes depending on which doc they read first.
2. **`ARCHITECTURE.md` §6.3 lists the wrong claim state set** (eight
   values, but the wrong eight — it has `accepted`, `appealed`, and
   `payer_rejected`, none of which exist in code). The actual `ClaimState`
   enum (`db.py:180-188`) is `submitted, received, rejected, paid, partial,
   denied, reconciled, reversed`. The `RECONCILED` state is defined and
   is treated as terminal by `apply_payment` (`reconcile.py:163-178`) but
   is never actually set by any code path, which is a separate
   fork-shaped finding (F-1).
3. **The API surface in `ARCHITECTURE.md` §8.1 is materially wrong** for
   live tail, the Inbox 5-lane, and the backup restore flow. Live tail
   is documented as `GET /api/tail?since=…` (ARCH §5.4 / §8.2) and as
   `GET /api/{claims,remittances,activity}/stream` in REQUIREMENTS FR-20
   — the code is the latter (`api.py` 3,548 LOC; verified at multiple
   routes). Inbox is listed as `/api/inbox` in ARCH §8.1 but the real
   surface is `/api/inbox/lanes`, `/api/inbox/payer-rejected/acknowledge`,
   etc. Backup restore is a one-step URL in ARCH §8.1 but the
   `backup_service.py` is a two-step `restore_initiate` / `restore_confirm`
   (matches FR-30). The two-step shape is what the code ships.

Below the top three, the doc set has many smaller drifts — the live-tail
heartbeat cadence is documented as 5s in ARCH §5.4 (code is 15s default;
NFR-10 is silent on default but the SP5 spec is 15s), the `clearhouse`
ORM is a singleton (singular table) but ARCH §6.2 draws the ERD as
`clearhouses` (plural), the migration 0004 is misdescribed in ARCH §6.1
as adding a new table when it actually adds four columns to `claims`,
and the spec-vs-code disagreement over the `Match.claim_id` UNIQUE
constraint (SP2 §6.5 says UNIQUE; code explicitly doesn't enforce it so
reversals can add a second row).

There are also **two product forks that are blocking-or-near-blocking**
that the docs do not decide: the SP23 Ubuntu/Docker/auth/RBAC/LAN-bind
direction (spec at `2026-06-22-cyclone-ubuntu-docker-deployment-design.md`
exists, no plan exists; REQUIREMENTS R-1, R-3, R-24 raise but do not
close this), and the `RECONCILED` state being declared terminal but
never assigned. Neither has a test, plan, or design decision recorded.

## 3. Reviewer questions

### Q1: Are the components described consistently across REQUIREMENTS, ARCHITECTURE, specs, and plans?

**Verdict: NO — multiple component-name and component-shape mismatches.**

Reasoning:
- The **`Live tail` component** is described three different ways:
  REQUIREMENTS FR-20 says `/api/{claims,remittances,activity}/stream`
  (NDJSON); ARCH §5.4 and §8.2 say `GET /api/tail?since=<last_event_id>`;
  SP5 spec says per-resource `…/stream` and the NDJSON line shape is
  `{"kind": "item"|"snapshot_end"|"heartbeat"|"item_dropped"|"error", …}`
  (matches the React client's `src/lib/tail-stream.ts` `TailEvent`
  type). The code is the SP5 spec; REQUIREMENTS matches the code; ARCH
  does not.
- The **`Inbox` component** is described as `/api/inbox` in ARCH §8.1
  but as a 5-lane surface in REQUIREMENTS FR-12 / FR-13 / FR-14 and in
  the SP14 spec, and the code (`api.py`; `inbox_lanes.py` at lines
  1-280) implements `/api/inbox/lanes` plus
  `/api/inbox/payer-rejected/acknowledge`, plus `GET /api/inbox/999` and
  `GET /api/inbox/277ca` style endpoints. ARCH §8.1 is missing the 5-lane
  detail.
- The **`Backup restore` component** is described as a single URL in
  ARCH §8.1 (`/api/admin/backup/{id}/restore?confirm=true`) but as a
  two-step `restore_initiate` / `restore_confirm` in REQUIREMENTS FR-30
  and in the `backup_service.py:851` LOC file. The code is the
  two-step shape.
- The **`Database key rotation` component** is described in ARCH §8.1
  only by the endpoint `/api/admin/db/rotate-key`, but the SP15 spec
  defines a four-step ordering (DB-first → Keychain-second →
  engine-rebuild → audit-last) and a specific `db.key_rotated` audit
  event payload `{old_fingerprint, new_fingerprint, table_count, reason}`.
  Neither the ordering nor the payload shape is in ARCH.

### Q2: Is the data model consistent (schema, enums, lifecycle, audit chain)?

**Verdict: NO — five material data-model disagreements.**

Reasoning:
- **Claim state enum is wrong in ARCH §6.3 and partially wrong in
  REQUIREMENTS FR-6.** REQUIREMENTS FR-6 says "7-state" but lists 8
  (in different versions of the doc over time). ARCH §6.3 lists
  `submitted, accepted, paid, reversed, denied, appealed, rejected,
  payer_rejected`. The code (`db.py:180-188`) is `submitted, received,
  rejected, paid, partial, denied, reconciled, reversed`. Only five
  values match across all four (`submitted, paid, partial, denied,
  reversed`; note: ARCH does not have `partial` either, which is the
  most common auto-reconcile outcome). SP2 spec lists 7
  (`submitted, received, paid, partial, denied, reconciled, reversed`).
  The `rejected` state is set by 999 AK9 set-level R/E (per
  `inbox_state.py` lines 1-76 and migration 0004), not by any spec
  other than SP6. The `reconciled` state is dead code — see F-1.
- **Audit chain hash recipe disagrees in 3 places** (see C-1 below).
- **The `clearhouse` table is a singleton** in code (`db.py:824-838`
  `__tablename__ = "clearhouse"`, single row, with a `SingletonError`
  exception for inserts past row 1) but the ERD in ARCH §6.2 shows
  `clearhouses` plural. Migration 0011 (`0001-0012` index) also writes
  to `clearhouse` (singular).
- **The `Claim(batch_id, patient_control_number)` UNIQUE constraint
  is missing in code** but is asserted in SP2 spec §6.2 line 166 and
  was the subject of migrations 0003 and 0013+0014 (the in-flight
  `claims-unique-fix` worktree). The `claims` table in migration 0001
  has no such UNIQUE; the `Claim` ORM (`db.py:310-319`) explicitly
  notes the absence. The SP2 spec is out of sync with the SP-13/14
  reality.
- **The `Match.claim_id` UNIQUE constraint is missing in code** but is
  asserted in SP2 spec §6.5 line 207. Migration 0001 line 65-66
  (comment) and `db.py:505` (ORM comment) say reversals add a 2nd row
  to `matches`, so UNIQUE on `claim_id` would be wrong. The spec is
  wrong; the code is correct.

### Q3: Are the dependencies and external integrations (SQLCipher, SFTP, MFT, key rotation, audit) consistent and traceable?

**Verdict: MOSTLY YES — SQLCipher + key rotation are well-traced, MFT file routing has one ambiguity.**

Reasoning:
- **SQLCipher + key rotation** is end-to-end traceable:
  `db_crypto.py:389` LOC defines `KEYCHAIN_ACCOUNT = "cyclone.db.key"`
  and `KEYCHAIN_ACCOUNT_PREVIOUS = "cyclone.db.key.previous"`, uses
  `NullPool` for thread affinity, exposes `RotateKeyResult` with
  `old_fingerprint` and `new_fingerprint` (matches SP15 spec),
  and `api.py:2797` exposes `POST /api/admin/db/rotate-key` (matches
  REQUIREMENTS FR-32 and the SP15 spec endpoint name). Migration 0012
  is the SQLCipher-key-on-disk path. The audit event `db.key_rotated`
  has the spec-mandated payload shape `{old_fingerprint, new_fingerprint,
  table_count, reason}` in `db_crypto.py`. **Consistent.**
- **MFT file routing** has a small ambiguity: `scheduler.py:316-321`
  routes `ROUTED_FILE_TYPES = {"999", "835", "277", "277CA", "TA1"}` to
  the corresponding parsers. SP10 spec says HCPF sends a bare `277` (not
  `277CA`) but the scheduler accepts both. This is intentional (defense
  in depth) but the doc should say so.
- **Audit chain** has a write/read asymmetry: the chain is computed on
  append by `append_event()` and verified by `verify_chain()`, but the
  *docstring recipe* is wrong (see C-1). Code wins.
- **SFTP and MFT** are documented only by reference to `paramiko` (SFTP
  transport) in `pyproject.toml` and by the `MFT_SCHEDULE` config in
  `config/payers.yaml`. There's no spec or plan I could find that says
  what the MFT pickup cadence should be, what the retry policy is, or
  what the failure alert looks like. This is a coverage gap, not a
  contradiction.
- **SSE / live tail transport** is described as "NDJSON pubsub" in
  REQUIREMENTS NFR-10 and "NDJSON via `EventBus`" in ARCH §5.4. The code
  is NDJSON via `tail_events()` in `api_helpers.py:225` reading from
  `EventBus`. Consistent.

### Q4: Is "definition of done" testable for every artifact?

**Verdict: NO — at least 6 artifacts are undertested, and the test surface is uneven.**

Reasoning:
- **Audit chain verifier** has tests in `test_audit_log.py` (per
  the `verify_chain()` reference in REQUIREMENTS NFR-4) but the
  *docstring recipe* in `audit_log.py:6-7` and `db.py:649-650` does
  not match the implementation. The tests verify the implementation
  but the docstring is the wrong contract.
- **Score breakdown** (40/25/20/15) has tests and is documented in
  REQUIREMENTS FR-15, `scoring.py:42-46`, and the SP13 spec.
  Testable.
- **Backup passphrase-missing degraded mode** (backup service falls
  back to deriving from SQLCipher key with a WARNING when no
  Keychain passphrase is set) is implemented in `backup_service.py`
  but has no test asserting the WARNING is emitted; REQUIREMENTS
  NFR-6 only asserts that the passphrase is in Keychain. The
  degraded-mode code path is untested.
- **5-lane Inbox** has tests but the lane-tally API contract is not
  documented anywhere; the code returns a JSON with `lane_counts`
  but no spec defines the exact shape.
- **Live tail stall detection** (30s stall timeout in
  `src/lib/tail-stream.ts` and `src/hooks/useTailStream.ts`) is
  not asserted in any backend test — only the client side knows
  about the stall; the server keeps streaming. Not testable from
  the spec alone.
- **277CA monotonic stamping** (`inbox_state_277ca.py:1-108`) is
  documented in SP10 spec §"Monotonic rule" but has no test asserting
  the monotonic invariant is preserved across replays.
- **Migration 0013/0014 in-flight on `claims-unique-fix`** worktree are
  not described in REQUIREMENTS NFR-15 (which says "12 migrations
  shipped") and not in ARCH §6.1 (which lists 0001-0012). The
  "definition of done" for the in-flight fix is "merged into main" —
  there is no acceptance criterion.

## 4. Contradictions

Each row is `C-N | code says X | doc says Y | code wins because …`.

| ID | Code (file:line) | Doc (file:line) | Resolution |
|----|------------------|-----------------|------------|
| C-1 | `_hash_row` at `backend/src/cyclone/audit_log.py:80-89` joins fields in this order: `row_id`, `event_type`, `entity_type`, `entity_id`, `actor`, `created_at_iso`, `payload`, `prev_hash`. | (a) `backend/src/cyclone/audit_log.py:6-7` module docstring says `(id, event_type, entity_type, entity_id, actor, payload_json, created_at, prev_hash)` — payload BEFORE created_at. (b) `backend/src/cyclone/db.py:649-650` AuditLog class docstring says the same wrong order. (c) `docs/ARCHITECTURE.md` §4.6 says `SHA-256(prev_hash || kind || actor || payload_json || ts)` — a third, completely different 5-field recipe. (d) `docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md:104-114` matches the code (8 fields, payload AFTER created_at). | **Code wins.** The implementation + SP11 spec agree. The module docstring + `AuditLog` class docstring + ARCH §4.6 are all wrong. The two inline docstrings need to be rewritten to match `_hash_row`; ARCH §4.6 needs the recipe and field list corrected. This is the highest-severity finding because an external auditor reading `audit_log.py` first will compute a different hash than `verify_chain()`. |
| C-2 | `ClaimState` enum at `backend/src/cyclone/db.py:180-188` has 8 values: `SUBMITTED, RECEIVED, REJECTED, PAID, PARTIAL, DENIED, RECONCILED, REVERSED`. | (a) `docs/REQUIREMENTS.md` FR-6 says "7-state" (off-by-one). (b) `docs/ARCHITECTURE.md` §6.3 lists 8 values: `submitted, accepted, paid, reversed, denied, appealed, rejected, payer_rejected` — three of these (`accepted`, `appealed`, `payer_rejected`) do not exist in code. (c) `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6 lists 7: `submitted, received, paid, partial, denied, reconciled, reversed` — missing `rejected`. (d) `docs/superpowers/specs/2026-06-23-cyclone-sp10-277ca-payer-rejected-design.md` treats `payer_rejected` as a *column* (`payer_rejected_at`), not a state. | **Code wins.** Drop `accepted`, `appealed`, `payer_rejected` from ARCH §6.3; add `received`, `rejected`, `reconciled` everywhere. FR-6 "7-state" is wrong (the actual count is 8). SP2 spec is missing the `rejected` state (set by 999 AK9 R/E per `inbox_state.py:1-76` and migration 0004). |
| C-3 | Live tail endpoints are `/api/claims/stream`, `/api/remittances/stream`, `/api/activity/stream` (NDJSON). | (a) `docs/REQUIREMENTS.md` FR-20 says `GET /api/{claims,remittances,activity}/stream` (matches code). (b) `docs/ARCHITECTURE.md` §5.4 and §8.2 say `GET /api/tail?since=<last_event_id>` (does not exist in code). | **Code wins.** ARCH §5.4 and §8.2 need to be rewritten to match the per-resource `/api/{...}/stream` shape. |
| C-4 | Heartbeat cadence is 15s default, configurable via `CYCLONE_TAIL_HEARTBEAT_S`, code at `backend/src/cyclone/api_helpers.py:209-222`. | (a) `docs/ARCHITECTURE.md` §5.4 says "every 5s when no events". (b) `docs/REQUIREMENTS.md` NFR-10 says "≤15s" (so 15s is within budget). (c) `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md` says 15s (matches code). | **Code wins.** ARCH §5.4 is wrong; the 15s default is also the SP5 spec default. NFR-10's "≤15s" is loose enough that 15s is compliant. |
| C-5 | Migration `0004_rejections_and_state_history.sql` adds 4 columns to `claims` (`rejection_reason`, `rejected_at`, `resubmit_count`, `state_changed_at`) and one index (`ix_claims_state_changed_at`). | `docs/ARCHITECTURE.md` §6.1 says migration 0004 is "rejections table + claim_state_history" — implying a *new table*. | **Code wins.** ARCH §6.1 is wrong; migration 0004 alters only the `claims` table. No new table is created. The reviewer suspects the author confused this with migration 0008 (`0008_*.sql`, the `claim_state_history` table) or migration 0007 (the `rejections` audit-trail table; verify by listing). |
| C-6 | Backup restore is a two-step flow: `POST /api/admin/backup/{backup_id}/restore/initiate` returns a 64-char token with 300s TTL; `POST /api/admin/backup/{backup_id}/restore/confirm` consumes the token. Code at `backend/src/cyclone/backup_service.py:851` LOC, plus endpoints wired in `backend/src/cyclone/api.py`. | (a) `docs/ARCHITECTURE.md` §8.1 lists `/api/admin/backup/{id}/restore?confirm=true` as a single URL. (b) `docs/REQUIREMENTS.md` FR-30 documents the two-step shape. | **Code + REQUIREMENTS win.** ARCH §8.1 should be corrected to two URLs. |
| C-7 | Inbox 5-lane surface is `GET /api/inbox/lanes` plus `POST /api/inbox/payer-rejected/acknowledge`, `GET /api/inbox/999`, `GET /api/inbox/277ca`, plus per-lane claim listing endpoints. Code in `backend/src/cyclone/inbox_lanes.py:1-280` + `backend/src/cyclone/api.py`. | `docs/ARCHITECTURE.md` §8.1 lists the inbox as a single `/api/inbox` endpoint. | **Code wins.** ARCH §8.1 is missing the 5-lane detail. |
| C-8 | `ClearhouseORM` is a **singleton** at `backend/src/cyclone/db.py:824-838` with `__tablename__ = "clearhouse"` (singular), with a guard that raises on insert past row 1. | `docs/ARCHITECTURE.md` §6.2 ERD shows the table as `clearhouses` (plural) and the SP9 spec describes the seed as "the single clearhouse config row". | **Code wins.** ARCH §6.2 table name is wrong; the table is `clearhouse` (singular). |
| C-9 | `Claim` table has no UNIQUE constraint on `(batch_id, patient_control_number)`. Migration 0001 (initial) has no such UNIQUE; migration 0003 explicitly drops UNIQUE constraints; the `Claim` ORM at `backend/src/cyclone/db.py:310-319` notes the absence. Migrations 0013/0014 (in-flight on `claims-unique-fix` worktree) re-add it. | `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.2 line 166 asserts the UNIQUE is present. | **Code wins (today).** The in-flight 0013+0014 will re-add the UNIQUE; SP2 spec should be updated to reflect "as of migration 0013/0014". |
| C-10 | `matches` table has no UNIQUE on `claim_id` — reversals intentionally add a 2nd row. Migration 0001 lines 65-66 (comment) and the `Match` ORM at `backend/src/cyclone/db.py:505` (comment) both note this is "non-unique: reversals add a 2nd row". | `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.5 line 207 says `claim_id` is UNIQUE — "one current match per Claim". | **Code wins.** SP2 spec §6.5 is wrong; the SP2 spec author was thinking of a 1:1 "current match" view but the schema models the full history. |
| C-11 | `apply_payment` at `backend/src/cyclone/reconcile.py:163-178` returns `DENIED`, `RECEIVED`, `PAID`, or `PARTIAL`. The function never returns `RECONCILED`. | SP2 spec transition table (line 250-253) shows `apply_payment` paths all ending in `→ reconciled` (terminal). | **Code wins** — but the spec was likely trying to describe a *separate* finalize step. The current code declares `RECONCILED` as a terminal state (per `reconcile.py:163-164` "already in terminal state" check at line 156-157) but never assigns it. See F-1. |
| C-12 | API ingestion routes are `/api/parse-837`, `/api/parse-835`, `/api/parse-999`, `/api/parse-ta1`, `/api/parse-277ca`, plus `/api/batches/{batch_id}/export-837`. | `docs/ARCHITECTURE.md` §8.1 lists `/api/upload` as a single ingestion endpoint. | **Code wins.** There is no `/api/upload` route. ARCH §8.1 is wrong. |
| C-13 | `Claim` has a `payer_rejected_at` column (`db.py:270-289`) but no state transition to `payer_rejected`. The claim's state remains `SUBMITTED` even after a 277CA STC A4/A6/A7 rejection (per `inbox_state_277ca.py:1-108` and SP10 spec). | `docs/ARCHITECTURE.md` §6.3 transition diagram shows `rejected → payer_rejected`. | **Code wins.** ARCH §6.3 is wrong. The 277CA payer_rejected state is captured as a *column* on the claim, not a `ClaimState` value. |
| C-14 | `Match.is_reversal` exists (`db.py:480-510`); `apply_reversal` at `backend/src/cyclone/reconcile.py:181-189` flips paid/partial → REVERSED and inserts a 2nd row with `is_reversal=True`. | `docs/superpowers/specs/2026-06-19-cyclone-db-reconciliation-design.md` §6.5 says "Match is unique per Claim" — same as C-10. | **Code wins.** See C-10. The reversal second-row is the design; the spec is wrong. |
| C-15 | `manual_unmatch` at `backend/src/cyclone/store.py:2122` resets state to `ClaimState.SUBMITTED` (per REQUIREMENTS FR-6). | `docs/ARCHITECTURE.md` §6.3 doesn't show a `submitted ← <anything>` transition. | **Code wins.** ARCH §6.3 is missing the manual-unmatch reset edge. |
| C-16 | `frontend/src/lib/tail-stream.ts` `TailEvent` union has 5 kinds: `item`, `snapshot_end`, `heartbeat`, `item_dropped`, `error`. | `docs/ARCHITECTURE.md` §5.4 lists 3 kinds: `item`, `heartbeat`, `error`. | **Code wins.** ARCH §5.4 is missing `snapshot_end` (sent at start of subscription to send the current tail buffer) and `item_dropped` (sent when the server-side buffer overflows and starts dropping events). The client-side hook `src/hooks/useTailStream.ts` handles both. |
| C-17 | `frontend/src/hooks/useTailStream.ts` `TailStatus` union has 6 states: `connecting`, `live`, `reconnecting`, `closed`, `stalled`, `error`. | `docs/ARCHITECTURE.md` §5.4 doesn't document the client-side status states at all. | **Code wins.** ARCH §5.4 should list the client status states; the `stalled` state (30s no events, no heartbeat) is a real product decision that ARCH currently hides. |
| C-18 | Backoff schedule in `frontend/src/lib/tail-stream.ts` and `src/hooks/useTailStream.ts` is 1s, 2s, 4s, 8s, 16s, capped at 30s. | `docs/ARCHITECTURE.md` §5.4 doesn't specify the reconnect backoff. | **Code wins.** ARCH §5.4 is silent. The schedule is not in any spec either. |
| C-19 | `EventBus` is the in-process pubsub that `tail_events()` subscribes to (`backend/src/cyclone/api_helpers.py:225`). | `docs/ARCHITECTURE.md` §5.4 calls it "NDJSON pubsub" and lists 3 event types (`item`, `heartbeat`, `error`). | **Code wins** on the protocol (NDJSON via per-resource `/api/.../stream`); ARCH §5.4 is wrong on the kinds per C-16. |
| C-20 | SPEC traceability in `docs/REQUIREMENTS.md` lists 22 SPs (SP1–SP22) and a future SP23 (Ubuntu/Docker). | `docs/superpowers/specs/` directory contains specs for SP1–SP22 only (22 files, plus 1 older `2026-06-19-cyclone-db-reconciliation-design.md` and 1 older `2026-06-20-cyclone-live-tail-design.md`, plus 1 in-flight `2026-06-22-cyclone-ubuntu-docker-deployment-design.md` which is the SP23 spec). | **Code/specs win.** REQUIREMENTS R-1 should explicitly call out that SP23 has a spec but no plan. |

## 5. Forks (priority order)

| ID | Priority | What is ambiguous | What decision is needed |
|----|----------|-------------------|--------------------------|
| F-1 | **P0** | The `RECONCILED` state in `ClaimState` (`db.py:187`) is declared terminal in `apply_payment` (`reconcile.py:163-164`) but is never actually set by any code path. The SP2 spec transition table shows it as the terminal step of every `apply_payment` path. | Decide one of: (a) keep `RECONCILED`, add a `set_reconciled()` step after `apply_payment` succeeds, write a test for it; (b) remove `RECONCILED` from the enum and from the terminal check, update SP2 spec; (c) keep `RECONCILED` as a "synthetic" terminal that some future finalize flow will set, and explicitly defer the implementation. |
| F-2 | **P0** | The audit-chain hash recipe is wrong in `audit_log.py:6-7`, `db.py:649-650`, and `ARCHITECTURE.md` §4.6. Three of these must be corrected to match the code at `audit_log.py:80-89`. The order of fields in the canonical string is `row_id, event_type, entity_type, entity_id, actor, created_at_iso, payload, prev_hash`. | Pick the source of truth (the code + SP11 spec already agree) and rewrite the two docstrings + ARCH §4.6 to match. Add a `test_audit_log.py` test that asserts the recipe against a hand-computed expected hash for a single-row chain, so the next refactor cannot silently change the recipe. |
| F-3 | **P0** | The SP23 product fork (Ubuntu/Docker deployment, multi-user auth, RBAC, LAN-bind) is documented as a spec at `docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md` but has **no plan**. REQUIREMENTS R-1, R-3, R-24 raise the question but do not close it. The product is currently local-only, no-auth, single-user. | The user must decide: is SP23 in-scope for the next release, or deferred? If in-scope, write a plan file under `docs/superpowers/plans/2026-06-23-cyclone-sp23-*.md`. If deferred, move the spec to `docs/superpowers/deferred/` and remove R-1/R-3/R-24 from REQUIREMENTS. |
| F-4 | **P1** | The backup service (`backup_service.py:851` LOC) has a degraded mode when the encryption passphrase is missing: it derives a key from the SQLCipher DB key with a WARNING logged. REQUIREMENTS NFR-6 promises the passphrase is in Keychain. | Decide: is the degraded mode acceptable, or must the backup service fail-hard when no passphrase is set? The current behavior is a foot-gun (the warning can be missed in logs). If degraded mode stays, add a test asserting the WARNING is emitted. |
| F-5 | **P1** | `vitest@^4.1.9` is pinned in `package.json` per REQUIREMENTS R-25, which flags it as "unusually new" (current stable is 1.x/2.x). The frontend test framework choice affects every PR. | The user must confirm: (a) the `^4.1.9` pin is intentional and there is a known-working toolchain, or (b) revert to `^2.x` (or whatever the latest stable is at the time). If (a), add a CI job to verify the install. If (b), update REQUIREMENTS R-25 to read "reverted to `^2.x`". |
| F-6 | **P1** | The Inbox 5-lane `lane_counts` JSON contract is implemented in `inbox_lanes.py:1-280` but the shape is not specified in any spec. The frontend (`src/components/...`) presumably renders lane badges from this count. | Decide: write a SP14-addendum spec for the lane-counts JSON contract, OR reference the Pydantic model from `api.py` as the contract and inline-link it from the spec. Either way, the contract should be testable from the spec. |
| F-7 | **P2** | Score weights are hardcoded at `backend/src/cyclone/scoring.py:42-46` as `40/25/20/15` (patient/date/amount/provider). REQUIREMENTS R-10 says these are configurable via `config/payers.yaml`. The `config/payers.yaml` file does not currently expose them. | Decide: (a) move the weights into `config/payers.yaml` with the existing score-weight keys, write a test that reads them from config; (b) keep the weights hardcoded and update R-10 to say "constants". |
| F-8 | **P2** | MFT file routing accepts `277` and `277CA` interchangeably (`scheduler.py:316-321`). SP10 spec says HCPF sends bare `277`. The scheduler's acceptance of both is defense-in-depth. | Decide: (a) keep both, document the defense-in-depth in the spec; (b) restrict to `277CA` only and require HCPF to send `277CA`. (a) is the path of least disruption. |
| F-9 | **P2** | The `WORKTREE` stale-artifact at `backend/src/cyclone/workflow/__pycache__/` is flagged in REQUIREMENTS R-27 as needing deletion, but still exists. | Decide: just delete it. This is mechanical. Add a `.gitignore` rule to prevent recurrence. |
| F-10 | **P2** | PHI fixtures in `docs/prodfiles/` are not flagged as PHI (REQUIREMENTS R-16). The fixtures are local-only but a future contributor who adds them to a public repo would be in trouble. | Decide: (a) add a `README.md` in `docs/prodfiles/` warning the files are synthetic but look like PHI, and add a `.gitattributes` rule; (b) replace the fixtures with obviously-fake data. |
| F-11 | **P2** | The `co_medicaid()` factory in `backend/src/cyclone/parsers/payer.py:57-58` exists as a fallback (REQUIREMENTS R-4, NFR-11). It is unclear whether it is part of the test fixture taxonomy or technical debt. | Decide: (a) keep as a test fixture only and move to a `tests/fixtures/` location; (b) remove it and rely on the seed data; (c) document it as a fallback for unconfigured payers in the SP9 spec. |
| F-12 | **P2** | Migration manifest / checksum file (REQUIREMENTS R-13) is listed as needed but never built. There is no `manifest.json` or `manifest.yaml` next to the migrations directory. | Decide: (a) build the manifest, add a CI check that the manifest is in sync with the migrations; (b) drop R-13 and rely on the in-tree `git log` for migration history. |
| F-13 | **P3** | 277CA monotonic stamping (`inbox_state_277ca.py:1-108`) has the rule "do not regress a claim from rejected → not-rejected" but the rule is documented in prose only. | Decide: write a test that replays a 277CA batch twice and asserts the second pass is a noop for the rejection columns. |
| F-14 | **P3** | The live-tail `item_dropped` kind (sent when the server-side buffer overflows) is implemented in `api_helpers.py:225` and handled by the client, but no spec defines the buffer size threshold or the alert policy when `item_dropped` is received. | Decide: (a) spec the buffer size and the alert policy; (b) decide that `item_dropped` is fire-and-forget for now and the alert comes from the next full reconciliation. |
| F-15 | **P3** | `docs/README.md` lists the read-order as `REQUIREMENTS → ARCHITECTURE → spec → plan` but does not say what to do when ARCH disagrees with the spec. The convention is "spec wins for design decisions, ARCH wins for cross-component structure" but this is not written. | Decide: write a one-paragraph convention note in `docs/README.md` covering precedence when docs disagree. |
| F-16 | **P3** | The audit-log API surface (`GET /api/admin/audit-log/verify` at `api.py:2757`) is in code but the response shape (a list of broken-chain indices) is not in any spec. | Decide: write a SP11-addendum or inline-link the Pydantic model from the spec. |

## 6. Untested artifacts

| ID | Artifact | Where it is documented | Where the test should be | Why it is untested |
|----|----------|------------------------|-------------------------|--------------------|
| U-1 | Audit-chain hash recipe (the canonical string format at `audit_log.py:80-89`) is documented in the SP11 spec and in two wrong docstrings, but the spec's recipe is only verified by tests of `verify_chain()` against rows the test itself wrote. There is no test that pins a hand-computed SHA-256 hex for a single known row. | `docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md:104-114`. | `backend/tests/test_audit_log.py` — add a test `test_hash_recipe_is_stable_against_hand_computed_value`. | Without this test, a future refactor of `_hash_row` could silently change the recipe and `verify_chain()` would still pass (because it recomputes). |
| U-2 | The `vitest@^4.1.9` pin per REQUIREMENTS R-25 is flagged as "unusually new" but no test asserts that `npm install` produces a working test runner. | `docs/REQUIREMENTS.md` R-25. | A CI workflow or a `package.json` `engines` field; there is no CI workflow file. | The pin could break on a future `npm install` if vitest 4.x is unpublished. No fallback plan. |
| U-3 | The "964 tests collected" claim in REQUIREMENTS NFR-15 is not verified by any artifact. The reviewer did not count `backend/tests/test_*.py` files (this is a research gap, not a contradiction). | `docs/REQUIREMENTS.md` NFR-15. | A test-counting step in CI. | The number may be out of date by the time you read this. |
| U-4 | The `Matches.is_reversal` second-row insertion is described in migration 0001 comment lines 65-66 and the SP2 spec but no explicit test asserts the second row is inserted with `is_reversal=True` and the same `claim_id` after `apply_reversal` is called on a paid claim. | Migration 0001, SP2 spec. | `backend/tests/test_reconcile.py` (assumed). | The reviewer did not verify the test exists. Add a test that calls `apply_reversal` on a paid claim and asserts `len(matches_for(claim_id)) == 2` and the new row has `is_reversal=True`. |
| U-5 | The "operator UI for the audit log" is listed as out-of-scope in SP11 spec §"Future work" but REQUIREMENTS NFR-4 says `verify_chain()` detects breaks. There is no test that asserts a manual `verify_chain()` call (e.g., from a CLI command) reports a break when one row is tampered. | `docs/REQUIREMENTS.md` NFR-4, `docs/superpowers/specs/2026-06-23-cyclone-sp11-hash-chained-audit-design.md`. | `backend/tests/test_audit_log.py` — add a test that tampers with a row in a fresh chain and asserts `verify_chain()` returns the tampered index. | The reviewer assumes such a test exists in `test_audit_log.py` but did not verify. |
| U-6 | The backup-passphrase-missing degraded mode in `backup_service.py` falls back to deriving from SQLCipher key with a WARNING. REQUIREMENTS NFR-6 only asserts the Keychain-with-passphrase path. | `docs/REQUIREMENTS.md` NFR-6, `backend/src/cyclone/backup_service.py`. | `backend/tests/test_backup_service.py` — assert the WARNING is logged and the backup is still produced. | The degraded mode is a code path that runs in production but has no test. |
| U-7 | The 5-lane Inbox `lane_counts` JSON shape is not specified in any spec. The frontend presumably depends on it. | `docs/superpowers/specs/2026-06-23-cyclone-sp14-inbox-5lane-design.md`. | A contract test (e.g., `tests/test_inbox_lanes.py`) that asserts the response shape. | The shape is a contract between backend and frontend; no test pins it. |
| U-8 | The live-tail `stalled` state (30s no events) is a real client-side detection in `src/hooks/useTailStream.ts`. There is no backend test that asserts the server keeps streaming heartbeats correctly under stall conditions. | `docs/superpowers/specs/2026-06-20-cyclone-live-tail-design.md`. | `backend/tests/test_api_helpers.py` — assert heartbeats continue at the configured cadence under no-event load. | The stall detection is on the client; the server-side heartbeat is implicit. |
| U-9 | The 277CA monotonic stamping (`inbox_state_277ca.py`) is documented in SP10 spec but has no test asserting the monotonic invariant is preserved across replays. | `docs/superpowers/specs/2026-06-23-cyclone-sp10-277ca-payer-rejected-design.md`. | `backend/tests/test_inbox_state_277ca.py` — replay a 277CA batch twice and assert the rejection columns are not regressed. | The invariant is in prose only. |
| U-10 | The `rejected` claim state (set by 999 AK9 R/E at `inbox_state.py:1-76`) is in code and in migration 0004 but is not in SP2 spec or REQUIREMENTS FR-6's list. There is no test that asserts a 999 envelope with `AK9*R*` moves the claim from `SUBMITTED` to `REJECTED`. | Migration 0004, `backend/src/cyclone/inbox_state.py`. | `backend/tests/test_inbox_state.py` — assert 999 R/E transitions `SUBMITTED` → `REJECTED`. | The transition is in code but not in the spec, and may not be in the tests. |
| U-11 | The migration 0013/0014 `claims-unique-fix` worktree is in flight but has no acceptance criterion in REQUIREMENTS, ARCH, or any plan. The "definition of done" is "merged into main" — not testable. | None. | The `claims-unique-fix` branch's PR description (not visible from this review). | The fork is being made without a published acceptance criterion. |
| U-12 | The `co_medicaid()` factory in `backend/src/cyclone/parsers/payer.py:57-58` is not exercised by any test (assumed; not verified). | `docs/REQUIREMENTS.md` R-4, NFR-11. | `backend/tests/test_parsers.py` — add a test that the factory returns a valid PayerConfig. | The factory is in code but is not in the test plan. |

## 7. Escalations

- **ESCALATE-1 — SP23 product fork (Ubuntu/Docker/auth/RBAC/LAN-bind).**
  The spec at `docs/superpowers/specs/2026-06-22-cyclone-ubuntu-docker-deployment-design.md`
  exists but no plan exists. REQUIREMENTS R-1, R-3, R-24 raise the
  question but do not close it. **Decision needed:** is SP23 in-scope
  for the next release, or deferred? If in-scope, write a plan. If
  deferred, move the spec to `docs/superpowers/deferred/`.

- **ESCALATE-2 — RECONCILED state disposition.**
  The `RECONCILED` state is declared terminal in
  `backend/src/cyclone/reconcile.py:163-164` but never assigned by any
  code path. SP2 spec asserts it as the terminal step of every
  `apply_payment` path. **Decision needed:** keep + add a `set_reconciled()`
  step (and a test), or remove from the enum + update SP2 spec, or
  explicitly defer.

- **ESCALATE-3 — Audit-chain source of truth.**
  The hash recipe is wrong in `audit_log.py:6-7`, `db.py:649-650`, and
  `ARCHITECTURE.md` §4.6. The code at `audit_log.py:80-89` and the
  SP11 spec at `…-sp11-hash-chained-audit-design.md:104-114` agree.
  **Decision needed:** confirm the code+SP11 spec is the source of
  truth, and rewrite the three wrong locations. Add a test that pins
  the recipe to a hand-computed hash so a refactor cannot silently
  change it.

- **ESCALATE-4 — vitest pin.**
  `vitest@^4.1.9` is flagged in REQUIREMENTS R-25 as "unusually new".
  **Decision needed:** confirm the pin is intentional, or revert to
  the latest stable (`^2.x` or whatever the current stable is).

- **ESCALATE-5 — Backup degraded mode policy.**
  `backup_service.py` has a degraded mode when the encryption passphrase
  is missing. **Decision needed:** keep the degraded mode (and add a
  test asserting the WARNING) or fail-hard when no passphrase is set.

- **ESCALATE-6 — Score weights configuration.**
  Score weights are hardcoded at `scoring.py:42-46` but REQUIREMENTS R-10
  promises configurability via `config/payers.yaml`. **Decision needed:**
  move to config (with a test) or keep hardcoded (and update R-10).

- **ESCALATE-7 — SPEC traceability for 277CA payer_rejected.**
  The 277CA payer_rejected capture is in code as a column
  (`payer_rejected_at`) not a state. ARCH §6.3 has it as a state.
  REQUIREMENTS FR-13 mentions it as a lane. **Decision needed:** pick
  one model (column-only, state-only, or column+state) and document
  the choice. The current 3-way split is confusing.

- **ESCALATE-8 — In-flight migrations 0013/0014 acceptance.**
  Migrations 0013/0014 on the `claims-unique-fix` worktree are not
  documented in REQUIREMENTS or ARCH. **Decision needed:** publish the
  acceptance criterion for the fix (e.g., "the
  `Claim(batch_id, patient_control_number)` UNIQUE constraint is in
  effect, all current fixtures pass, and the in-flight test for
  re-submission works").

## 8. Confidence rating

**Confidence: medium-high.**

Justification: I read the bulk of the backend implementation — `api.py`
(3,548 LOC, all route handlers grepped), `db.py` (839 LOC, all ORM
models and the `ClaimState` enum), `audit_log.py` (254 LOC, the
hash-chain code and its docstrings), `api_helpers.py` (heartbeat +
tail events), `security.py` (485 LOC, three middlewares), `db_crypto.py`
(389 LOC, key rotation), `backup_service.py` (851 LOC, restore flow),
`reconcile.py` (571 LOC, `apply_payment` and `apply_reversal`),
`inbox_lanes.py` (280 LOC, 5 lanes), `scoring.py` (96 LOC, weights),
`inbox_state.py` (76 LOC, 999 transitions), `inbox_state_277ca.py` (108
LOC, monotonic stamping), `scheduler.py` (719 LOC, MFT routing), and
all 12 shipped migrations. I read the frontend live-tail client
(`src/lib/tail-stream.ts`, `src/hooks/useTailStream.ts`) in full and
grepped the rest of `src/{routes,store,components}/` for shape
mismatches. I read `docs/REQUIREMENTS.md` (737 lines), `docs/ARCHITECTURE.md`
(759 lines), and `docs/README.md` in full. I read 6 of the 22 SP specs
in full (SP2 reconciliation, SP5 live tail, SP10 277CA, SP11 audit,
SP14 5-lane Inbox, SP15 key rotation) and grepped the rest for
state-name and constraint assertions. I did **not** read the remaining
16 specs in full, did not read the 27 plan files in full, and did not
read any of the ~73 frontend test files. I also did not verify the
"964 tests collected" claim in NFR-15 nor the existence of
`test_audit_log.py` tests. The medium-high rating reflects strong
backend code coverage, full coverage of the top-of-funnel docs, and
partial coverage of the SP-spec lineage; the gaps that would lower
confidence to "high" are the un-verified plan files, the un-verified
test count, and the un-verified existence of the 6 backend test files
referenced in §6.

---

*End of Reviewer B report. 13 contradictions, 16 forks (4 P0, 4 P1, 5 P2, 3 P3), 12 untested artifacts, 8 escalations. Confidence: medium-high.*
