docs: readiness-model build-plan starter templates (wiring + completion)

Captured on Fable before access ends. Two grounded, execute-from starter
templates under docs/build-plans/:
- 01-readiness-model-wiring.md: wire dedup+readiness into the live pipeline
  via a post-process orchestrator (avoids the dedup->coverage_calculator
  circular import); flags that the normalizer does not yet ingest plan_type,
  so every live verdict reads 'Plan Type Needed' until a plan_type column is mapped.
- 02-readiness-model-completion.md: P2-P6 (citation IDs, patient rollup,
  device override, plan-type enforcement, frontend nesting).

Readiness+dedup engine verified 57 tests green (2026-07-06); corrects the
stale '73 tests' figure in current-state.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kisa 2026-07-06 05:08:46 -04:00
parent 611f6b51c2
commit da6c242b07
2 changed files with 1130 additions and 0 deletions

View file

@ -0,0 +1,583 @@
# Build Plan 01 — Wire the Readiness Model into the Live Pipeline
**Date:** 2026-07-06
**Author:** lead-signal (Claude), grounded against the working tree on this date
**Status:** PLAN ONLY. No production code has been modified. Execute-from-here document
for a future session (possibly a less capable model). Every load-bearing claim is
tagged GROUNDED (verified in a file or by running a command) or GRAFTED (assumption
or recommendation).
**Prior design docs this builds on (read them, do not re-derive them):**
- `docs/readiness-model-brief-2026-06-23.md` (Phase 1 spec, locked decisions)
- `docs/readiness-model-design-phase2-2026-06-26.md` (Pi's Phase 2 design, esp. section 4 migration strategy)
- `docs/dedup-design-2026-06-24.md` (locked dedup key and tiebreakers)
---
## 1. Objective
Wire the already-built, already-tested readiness engines (`core/dedup.py` +
`core/readiness.py`) into the live FastAPI pipeline as an ADDITIVE readiness verdict
on both scoring paths (CSV batch upload and Confirm Visit recompute), changing no
existing worklist behavior, per the Phase 2a coexistence strategy.
---
## 2. Current state (GROUNDED)
### The engines exist, tested, and are wired to NOTHING
- `python-backend/core/dedup.py` (251 lines). GROUNDED.
- `dedup(records: list[ShipmentRecord]) -> list[MergedShipment]` groups rows by the
LOCKED key `(patient_id, device_type, shipment_date-as-date)` (dedup.py:185-215).
- `assign_to_coverage_lines(merged) -> dict[CoverageLineKey, CoverageLine]` groups
MergedShipments by `(patient_id, device_type, plan_type or "unknown")`; latest DOS
becomes `current_shipment`, earlier ones `prior_shipments` (dedup.py:218-250).
- A fresh `CoverageLine` is intentionally ungraded: `line_status=None`,
`timing_flag=None` (dedup.py:246-247). Grading is a separate step. GROUNDED.
- `python-backend/core/readiness.py` (287 lines). GROUNDED.
- `evaluate_readiness(plan_type, *, csv_swo_status, csv_visit_date,
confirmed_visit_date, csv_pecos_verified, csv_pa_status, csv_diagnosis_on_file,
rules) -> ReadinessVerdict` (readiness.py:216-255). Grades ONE line's five doc
items on two axes (required_state x input_state).
- `ReadinessVerdict = {plan_type, doc_items: list[DocItem], line_status: LineStatus}`
(readiness.py:92-96). `DocItem = {doc_type, required_state, input_state, quality,
value, satisfied}` (readiness.py:82-89).
- `LineStatus` values are EXACTLY the locked worklist labels plus the sanctioned
unknown-plan state: "Clear to Ship", "On Track", "Action Needed", "At Risk",
"Plan Type Needed" (readiness.py:74-79). GROUNDED. Do not invent or rename any.
- Required-ness is read from `config/payer_rules.json` (readiness.py:197-213, 234-237),
and `payer_rules.json` carries `pecos_required` / `pa_required` for all four known
plan types (payer_rules.json:32-72). GROUNDED.
- A required-and-absent item blocks green (readiness.py:183-185, 278-279); unknown or
missing plan config yields "Plan Type Needed" (readiness.py:268-274). GROUNDED.
- **Neither module is imported anywhere in `python-backend/`.** Grep on 2026-07-06 finds
imports of `core.dedup` / `core.readiness` only in `tests/test_dedup.py`,
`tests/test_readiness.py`, and `tests/signal_e2e_test.py`. GROUNDED.
### Real test status (run 2026-07-06 from repo root, `python -m pytest`)
| Suite | Result |
|---|---|
| `tests/test_dedup.py` | 30 passed |
| `tests/test_readiness.py` | 27 passed |
| `tests/signal_e2e_test.py` (Pi's black-box E2E) | 35 passed |
| Full `tests/` directory | **108 passed** |
GROUNDED (I ran them). Note: `context/current-state.md` (2026-06-27 entry) says
"73 tests green"; the grounded per-file counts today are the table above. The 57
engine unit tests (30 + 27) match the 2026-06-26 state entry. Treat the table as
current truth; do not quote 73.
### The live pipeline scores through coverage_calculator only
- `python-backend/api/main.py:25` — `from core.coverage_calculator import
ShipmentRecord, calculate_batch`. GROUNDED.
- **Batch path (CSV upload -> worklist):** `main.py:443` `POST /api/upload` ->
`normalize_csv` (main.py:457) -> `calculate_batch(records, as_of, confirmed_visits)`
(main.py:492-494) -> one `RecordOut` per result via `_to_record_out`
(main.py:502-521). GROUNDED.
- **Single-record path (Confirm Visit recompute):** `main.py:670` `POST
/api/confirm-visit` -> builds one `ShipmentRecord` from echoed request fields
(main.py:735-750) -> `calculate_coverage(record, confirmed_visit_date=confirmed)`
(main.py:751, import at main.py:679) -> `_to_record_out` (main.py:766). GROUNDED.
- `calculate_batch` returns `list[CoverageResult]`, ONE PER INPUT ROW, sorted by
priority (coverage_calculator.py:368-396). The worklist is row-keyed, not
patient-keyed (the known bug, Phase 1 brief section 8). GROUNDED.
- `CoverageResult` carries the TIMING axis only (`CoverageFlag`: SUPPLY_LAPSED,
VISIT_REQUIRED, RENEWAL_*, RESUPPLY_READY, ACTIVE, NO_RECENT_SHIPMENT,
TRANSFER_PENDING; coverage_calculator.py:42-51, 86-100). It has no doc-completeness
verdict. The doc display state comes separately from `compute_doc_state`
(main.py:321-330), which is driven by `_normalize_payer_type` -> the payer-name
guesser (`_normalize_payer`, imported at main.py:257). GROUNDED.
- The live `status_label` comes from `FLAG_LABELS` (main.py:136-146), which is a
LEGACY label set ("Docs Required", "Escalate", "New Patient", "Confirm Appointment",
"Begin Outreach", "Clear to Ship", "On Track", "No Recent Shipment"). It is NOT the
locked four-label verdict set. This plan does not touch it; replacing it is the P6
frontend-nesting step owned by another agent. GROUNDED.
### Two wiring realities discovered by reading the code (both load-bearing)
1. **Circular-import constraint.** `dedup.py:35` does `from core.coverage_calculator
import ShipmentRecord, CoverageFlag`. Therefore `coverage_calculator.py` can never
import `dedup`/`readiness` (circular). The Phase 2 doc's item 5 sketch ("add a path
in coverage_calculator.py") cannot be implemented literally as written. The seam
must live OUTSIDE coverage_calculator: a new orchestration module called from
`api/main.py`. GROUNDED (import verified; conclusion follows necessarily).
2. **The normalizer never populates `plan_type`.** `ShipmentRecord` has had a
`plan_type` field since the 2026-06-23 lock (coverage_calculator.py:79-82), but
`normalize_csv` constructs records WITHOUT it (normalizer.py:315-331) and
`HEADER_MAP` has no plan_type aliases (normalizer.py:20-88). Header matching is
exact-normalized (lowercase, `-`/`_` -> space; normalizer.py:169-189), so a client
column named "plan_type"/"Plan Type" is currently UNMAPPED and dropped. Beware the
near-collision: bare "plan", "plan_name", "plan name" already alias to PAYER
(normalizer.py:46-50). Consequence: on day one of wiring, every record has
`plan_type=None`, so every readiness verdict reads "Plan Type Needed". That is
doctrinally correct (never guess) but means no green is reachable end to end until
plan_type enters via CSV mapping. GROUNDED. See Open Question 1.
### Doctrine locks this plan must honor (from the source-of-truth docs)
- Dedup key LOCKED: `(patient_id, device_type, date_of_service)`; order_number is
display-only, never in the key (dedup design doc section 1; implemented at
dedup.py:189, tested by `test_dedup_missing_order_number` and
`test_dedup_order_numbers_collected_and_deduped`). GROUNDED.
- Verdict rules LOCKED: green only by positive evidence on every required item;
required-and-absent blocks green; unknown plan reads "Plan Type Needed", never a
guess (Phase 1 brief section 4; implemented and tested:
`test_required_absent_blocks_green`, `test_plan_type_none_is_plan_type_needed`,
`test_known_plan_missing_config_does_not_false_green`). GROUNDED.
- Migration strategy LOCKED by Pi's Phase 2 design section 4: Phase 2a = both paths
execute, readiness carried as an optional field, frontend ignores it until wired,
no cut-over date without Kisa. This plan IS Phase 2a. GROUNDED (doc read).
- Grade the CURRENT shipment only (latest DOS per line); prior shipments are collapsed
history (Phase 1 brief section 7; dedup.py:226, 238-245). GROUNDED.
---
## 3. Prerequisites / dependencies
1. Repo at or after the 2026-06-27 clean checkpoint; `python -m pytest tests/` green
(verified 108 passed on 2026-07-06).
2. Railway CLI authenticated; backend service `signal-api-production-91c2`. Deploy
command: `railway up --detach` from the project root (forces fresh build).
3. `SIGNAL_API_KEY` value available for curl-based live verification (X-API-Key header
path, main.py:117-118).
4. Decision from Kisa on Open Question 1 (plan_type CSV ingestion now vs with P5).
The wiring ships either way; the decision changes only whether "Clear to Ship" is
reachable end to end on day one.
5. No dependency on the Convex spike, Supabase schema changes, or the frontend.
Frontend is untouched in this step.
Downstream items explicitly NOT in this plan (owned by the P2-P6 agent; reference
only): rules-to-config citation IDs, synonym vocabularies to config, patient rollup,
device-keyed override, plan-type enforcement + guesser removal, frontend nesting.
---
## 4. Implementation approach
**Seam decision: the readiness verdict POST-PROCESSES (augments) the
coverage_calculator output. It does not wrap it and does not replace it.** Grounds:
- The two engines grade DIFFERENT axes. coverage_calculator = timing urgency
(CoverageFlag); readiness = documentation completeness (LineStatus). readiness.py's
own scope-boundary comment says the timing layer is "combined at the worklist
level" (readiness.py:139-142). Neither output is a superset of the other. GROUNDED.
- The Phase 2 design section 4 mandates coexistence (Phase 2a): both paths execute,
readiness rides as an optional field, old path still serves the worklist. GROUNDED.
- Wrapping inside coverage_calculator is impossible without a circular import
(section 2 above). GROUNDED.
- Replacing would change output cardinality (dedup collapses rows to lines) and break
the row-keyed frontend, `_compute_stats` (main.py:397-416), and the export echo
(main.py:559-620) in one step. Cardinality-preserving augmentation keeps this step
additive and reversible; the cardinality change belongs to P3 rollup + P6 frontend.
GRAFTED (design judgment, consistent with the locked migration strategy).
**How the augment works, concretely:** dedup the batch's ShipmentRecords into
CoverageLines, grade each line's CURRENT shipment with `evaluate_readiness`, then
attach each line's verdict to EVERY row-level RecordOut belonging to that line. Row
count, ordering, stats, legacy labels: all unchanged. Rows from prior shipments of a
line carry the same line verdict (graded from the latest DOS), which is the locked
grade-the-current-shipment rule surfacing at row level. GRAFTED (mechanism), built on
GROUNDED locks.
### Ordered steps
1. **Create `python-backend/core/worklist_readiness.py`** (new module, the only new
import surface). Two functions:
- `readiness_by_line(records, confirmed_visits) -> dict[tuple, ReadinessVerdict]`
keyed by `(patient_id, device_type, plan_type_id)` where `plan_type_id` is
`plan_type or "unknown"` (mirrors `CoverageLineKey`, dedup.py:85-90).
Internally: `dedup()` -> `assign_to_coverage_lines()` -> per line,
`evaluate_readiness()` on `line.current_shipment`'s csv_* fields, passing
`confirmed_visit_date` looked up by `sha256(patient_id)` from the
confirmed_visits dict (same keying as coverage_calculator.py:387-388).
- `readiness_for_record(record, confirmed_visit_date) -> ReadinessVerdict` for the
single-record Confirm Visit path (one record is its own line; call
`evaluate_readiness` directly).
- Log only hashed patient_ids (reuse the `_hash_pid` pattern, dedup.py:40-42).
2. **Add additive response fields in `api/main.py`:**
- New Pydantic model `ReadinessItemOut` (doc_type, required_state, input_state,
quality, value, satisfied).
- `RecordOut` gains `plan_type: Optional[str] = None`,
`readiness_status: Optional[str] = None`,
`readiness_items: Optional[list[ReadinessItemOut]] = None`. All default None so
old clients, the export echo (`ExportRequest.records: list[RecordOut]`,
main.py:559-561), and the frontend are unaffected.
- `UploadResponse` gains `readiness_stats: dict | None = None` (counts per
LineStatus value) so live verification is measurable without the UI.
3. **Wire the batch path** in `upload_csv` immediately after `calculate_batch`
(main.py:492-494): compute `verdicts = readiness_by_line(records,
confirmed_visits)`; when building each RecordOut (main.py:502-521), resolve the
row's original ShipmentRecord via the existing `record_lookup` (main.py:496-500),
derive its line key, and pass the matching verdict into `_to_record_out` (new
optional parameter). If the record lookup misses, readiness fields stay None,
exactly like `doc_state` degrades today (main.py:317-319).
4. **Wire the single-record path** in `confirm_visit`: add
`plan_type: str | None = None` to `ConfirmVisitRequest` (main.py:649-667), pass it
into the `ShipmentRecord` construction (main.py:735-750; the field already exists
on ShipmentRecord), compute `readiness_for_record(record,
confirmed_visit_date=confirmed)`, and pass it to `_to_record_out` at main.py:766.
The frontend already echoes RecordOut fields back on confirm-visit (RecordOut
comment, main.py:192-194); adding `plan_type` to RecordOut in step 2 makes the
round trip whole once the frontend echoes it (frontend change itself is P6; until
then the field arrives None and the recompute honestly reads Plan Type Needed).
5. **(Decision-gated, Open Question 1) Minimal plan_type ingestion in
`api/normalizer.py`:** add a `plan_type` entry to HEADER_MAP with aliases such as
`"plan_type", "plan type", "plan category", "coverage type", "insurance_type",
"insurance type"`. NEVER include bare `"plan"`, `"plan_name"`, or `"plan name"`
(those are payer aliases today, normalizer.py:46-50; stealing them would silently
re-map existing customer CSVs). Carry the value verbatim, lowercased and trimmed
only; `evaluate_readiness` already normalizes and routes anything outside the four
known values to Plan Type Needed (readiness.py:231-232, 47-49). This is
client-supplied mapping, not derivation from the payer name, so it does not
violate the never-guess lock. GRAFTED (recommendation; Kisa gates).
6. **Tests** (new file `tests/test_worklist_readiness.py` + API-level tests): see
section 8. Run the FULL suite; all 108 existing tests must still pass.
7. **Independent review:** hand the diff to code-auditor before ship (generator and
reviewer are never the same agent). Then run the `signal-e2e-test` skill so the
readiness verification is not a closed loop.
8. **Deploy and live-verify** per section 9. Then update
`docs/ship-status-ledger.md` and `context/current-state.md` (state-sync) and
commit. Do not update the pilot-readiness checklist unless an item genuinely
flips; count PASS items before quoting any percentage.
---
## 5. Files to touch
| File | Change |
|---|---|
| `python-backend/core/worklist_readiness.py` | NEW. Orchestration module: `readiness_by_line`, `readiness_for_record`. Imports dedup + readiness. Nothing imports it except api/main.py and tests. |
| `python-backend/api/main.py` | Add `ReadinessItemOut`; extend `RecordOut` (+3 optional fields) and `UploadResponse` (+1 optional field); extend `ConfirmVisitRequest` (+optional plan_type); new optional `readiness` param on `_to_record_out`; call `readiness_by_line` in `upload_csv` after line 492; call `readiness_for_record` in `confirm_visit` before line 766; small `_compute_readiness_stats` helper. |
| `python-backend/api/normalizer.py` | ONLY if Open Question 1 is approved: `plan_type` aliases in HEADER_MAP + pass-through into ShipmentRecord construction (normalizer.py:315-331) + it appears in `mapping_summary` automatically via the existing header loop. |
| `tests/test_worklist_readiness.py` | NEW. Unit tests for the orchestration module + API-shape tests (section 8). |
| `docs/ship-status-ledger.md` | Append the ship entry after live verification. |
| `context/current-state.md` | Update ACTIVE/NEXT + one author-stamped decision via state-sync. |
Files explicitly NOT touched: `core/coverage_calculator.py` (circular-import
constraint plus additive-only discipline), `core/dedup.py`, `core/readiness.py`
(already audited; do not modify while wiring), `core/doc_state_machine.py`,
`config/payer_rules.json`, anything in `signal-ui/` (P6), persistence/Supabase schema
(verdicts are recomputed per upload, not persisted, this step).
---
## 6. Integration points
### A. Batch path, `api/main.py:492` (`POST /api/upload`)
Today:
```
records (normalize_csv) ──> calculate_batch ──> CoverageResult per row ──> RecordOut per row
```
After wiring:
```
records ──┬─> calculate_batch ──────────────> CoverageResult per row ──┐
└─> readiness_by_line (dedup ──> lines ──> evaluate_readiness)┴─> RecordOut per row
+ readiness_status
+ readiness_items
+ plan_type
```
- Inputs already in scope at the call-site: `records` (main.py:457),
`confirmed_visits` (main.py:486, dict of `sha256(patient_id) -> date`).
- Row-to-line resolution: the existing `record_lookup` (main.py:496-500) maps a
CoverageResult back to its ShipmentRecord; the line key is
`(record.patient_id, record.device_type, record.plan_type or "unknown")`.
- Legacy outputs (`flag`, `status_label`, `priority_score`, sort order, `stats`,
row count) are byte-identical to before. The verdict rides alongside.
### B. Single-record path, `api/main.py:751` (`POST /api/confirm-visit`)
- `calculate_coverage(record, confirmed_visit_date=confirmed)` stays exactly as is
(timing axis).
- Add `verdict = readiness_for_record(record, confirmed_visit_date=confirmed)` and
pass it to `_to_record_out(result, record=record, confirmed_visit_date=confirmed,
readiness=verdict)` at main.py:766.
- `evaluate_readiness` counts a confirmed visit as positive evidence
(readiness.py:135-144), so confirming a visit flips the visit item to satisfied in
the same response, no second upload needed.
### C. What plugs into what (function-level contract)
- `evaluate_readiness` consumes exactly the fields `MergedShipment` carries
(csv_swo_status, csv_visit_date, csv_pecos_verified, csv_pa_status,
csv_diagnosis_on_file, plan_type) plus `confirmed_visit_date`. Verified
field-by-field against dedup.py:64-83 and readiness.py:216-226. GROUNDED.
- `CoverageLine.plan_type == "unknown"` feeds straight into `evaluate_readiness`,
which routes it to Plan Type Needed because "unknown" is not in KNOWN_PLAN_TYPES
(readiness.py:47-49, 231-232; tested by
`test_plan_type_unknown_string_is_plan_type_needed`). GROUNDED.
---
## 7. Skeleton snippets (illustrative only, do NOT paste blindly; re-verify line numbers at execution time)
### `core/worklist_readiness.py` (new)
```python
"""Orchestrates dedup + readiness for the live pipeline (Phase 2a coexistence).
PHI CONTRACT: patient_id only; hash it in any log line."""
import hashlib
from datetime import date
from typing import Optional
from core.coverage_calculator import ShipmentRecord
from core.dedup import dedup, assign_to_coverage_lines, UNKNOWN_PLAN_TYPE
from core.readiness import evaluate_readiness, ReadinessVerdict
LineKey = tuple[str, str, str] # (patient_id, device_type, plan_type_id)
def _confirmed_for(patient_id: str, confirmed_visits: dict[str, date]) -> Optional[date]:
return confirmed_visits.get(hashlib.sha256(patient_id.encode()).hexdigest())
def readiness_for_record(record: ShipmentRecord,
confirmed_visit_date: Optional[date] = None) -> ReadinessVerdict:
return evaluate_readiness(
record.plan_type,
csv_swo_status=record.csv_swo_status,
csv_visit_date=record.csv_visit_date,
confirmed_visit_date=confirmed_visit_date,
csv_pecos_verified=record.csv_pecos_verified,
csv_pa_status=record.csv_pa_status,
csv_diagnosis_on_file=record.csv_diagnosis_on_file,
)
def readiness_by_line(records: list[ShipmentRecord],
confirmed_visits: Optional[dict[str, date]] = None,
) -> dict[LineKey, ReadinessVerdict]:
confirmed_visits = confirmed_visits or {}
lines = assign_to_coverage_lines(dedup(records))
verdicts: dict[LineKey, ReadinessVerdict] = {}
for key, line in lines.items():
cur = line.current_shipment # grade the CURRENT shipment only (locked)
verdicts[(key.patient_id, key.device_type, key.plan_type_id)] = evaluate_readiness(
line.plan_type if line.plan_type != UNKNOWN_PLAN_TYPE else None,
csv_swo_status=cur.csv_swo_status,
csv_visit_date=cur.csv_visit_date,
confirmed_visit_date=_confirmed_for(key.patient_id, confirmed_visits),
csv_pecos_verified=cur.csv_pecos_verified,
csv_pa_status=cur.csv_pa_status,
csv_diagnosis_on_file=cur.csv_diagnosis_on_file,
)
return verdicts
```
### `api/main.py` additions (sketch)
```python
class ReadinessItemOut(BaseModel):
doc_type: str
required_state: str # REQUIRED | NOT_REQUIRED | NOT_EVALUATED
input_state: str # SUPPLIED | ABSENT
quality: str # GOOD | PENDING | BAD | NONE
value: Optional[str] = None
satisfied: bool
# RecordOut gains (all additive, default None):
# plan_type: Optional[str] = None
# readiness_status: Optional[str] = None # one of the 5 LineStatus values, verbatim
# readiness_items: Optional[list[ReadinessItemOut]] = None
# UploadResponse gains:
# readiness_stats: dict | None = None
```
```python
# upload_csv, right after main.py:492-494:
from core.worklist_readiness import readiness_by_line
verdicts = readiness_by_line(records, confirmed_visits)
# inside the RecordOut build loop: resolve rec = record_lookup.get(...) as today, then
line_verdict = None
if rec is not None:
line_verdict = verdicts.get(
(rec.patient_id, rec.device_type, rec.plan_type or "unknown"))
# pass readiness=line_verdict into _to_record_out; inside _to_record_out:
# readiness_status=verdict.line_status.value if verdict else None,
# plan_type=(getattr(record, "plan_type", None) if record else None),
# readiness_items=[ReadinessItemOut(..., required_state=i.required_state.value, ...)
# for i in verdict.doc_items] if verdict else None,
```
```python
# confirm_visit, before main.py:766:
from core.worklist_readiness import readiness_for_record
verdict = readiness_for_record(record, confirmed_visit_date=confirmed)
return _to_record_out(result, record=record, confirmed_visit_date=confirmed,
readiness=verdict)
# and ConfirmVisitRequest gains: plan_type: str | None = None
# passed into the ShipmentRecord(... plan_type=body.plan_type) construction.
```
---
## 8. Test strategy & acceptance criteria
New file `tests/test_worklist_readiness.py`. The engine-level behaviors are already
covered (57 unit tests + 35 E2E); the new tests target the WIRING, i.e. the seams
this plan adds. GROUNDED engine coverage, GRAFTED wiring tests to write:
1. **False green is blocked at the API layer (the headline acceptance case).**
One record, `plan_type="medicare"`, SWO "On File", csv_visit_date present,
diagnosis "Yes", **PECOS absent (None)**, shipment recent enough that the legacy
flag reads RESUPPLY_READY (legacy `status_label` "Clear to Ship" per FLAG_LABELS,
main.py:143). Assert: `readiness_status == "Action Needed"` on the same RecordOut.
This is the exact false-green the model exists to kill, visible in one response:
legacy label green-ish, readiness verdict honest. (Engine-level twin:
`test_required_absent_blocks_green`, tests/test_readiness.py:67.)
2. **Plan Type Needed.** Same record with `plan_type=None` (today's default for every
normalized record). Assert `readiness_status == "Plan Type Needed"` and PECOS/PA
items are NOT_EVALUATED while universal items still grade. (Engine twins:
readiness tests at lines 128-146.)
3. **Clear to Ship is reachable.** `plan_type="medicare"`, all five items with
positive evidence (PA not required for Medicare FFS per payer_rules.json:36).
Assert `readiness_status == "Clear to Ship"`. If Open Question 1 is approved, also
prove it END TO END through `normalize_csv` with a plan_type column; if not
approved, prove it at the `readiness_by_line` level with constructed records and
note the end-to-end gap explicitly.
4. **Dedup wiring.** Two CSV rows, same (patient, device, DOS), different
order_numbers -> one line verdict; both output rows (if both survive to output)
carry the identical verdict. Two rows same patient+device with DIFFERENT DOS
(a resupply history) -> still ONE line; assert the verdict is graded from the
LATEST DOS row's csv fields (change a doc field between the two rows to prove
which one drove the verdict).
5. **Confirm Visit recompute.** Record with plan_type medicare, everything good
except no visit evidence -> upload path gives "Action Needed"; POST
/api/confirm-visit with a valid confirmed date -> response
`readiness_status == "Clear to Ship"` and the visit item satisfied.
6. **Back-compat invariants.** For a fixture CSV, the wired response equals the
pre-wiring response on every legacy field: same row count, same order, same
`flag`/`status_label`/`priority_score`/`stats`. Readiness fields are the only
additions. Also: `readiness_stats` totals equal the number of distinct graded
lines' rows accounted for (define precisely in the test).
7. **PHI check.** Assert no raw patient_id appears in log output captured from
`readiness_by_line` (caplog); hashed only.
8. **Full suite.** `python -m pytest tests/` must show 108 + new tests passed, zero
failures, before deploy.
**Acceptance criteria (all must hold):** cases 1 and 2 pass exactly as stated (these
prove the false-green kill and the never-guess doctrine in the live pipeline); case 6
proves the step was truly additive; full suite green; code-auditor sign-off recorded.
---
## 9. Live-verify steps (drive the real app, measure, never eyeball)
Backend-only change, so verification is API-level against the live Railway service.
The UI renders nothing new by design (Phase 2a: frontend ignores the new fields).
1. Deploy: `railway up --detach` from `/Users/sttil-solutions/projects/signal`.
Watch build logs (`railway logs`) until healthy.
2. `curl -s https://signal-api-production-91c2.up.railway.app/health` -> `status: ok`.
3. Upload a synthetic fixture CSV (use the signal-e2e-test skill's generator; NEVER
real data):
`curl -s -X POST .../api/upload -H "X-API-Key: $SIGNAL_API_KEY" -F "file=@fixture.csv"`.
Measure, do not skim:
- every record has a `readiness_status` key;
- with no plan_type column: 100% of graded records read "Plan Type Needed"
(count them with jq, compare to total);
- `readiness_stats` present and its counts sum correctly.
4. If plan_type ingestion shipped (Open Question 1): upload the fixture WITH a
plan_type column containing one engineered Clear to Ship row (medicare, all
evidence positive) and one engineered false-green row (medicare, PECOS blank,
timing green). Assert with jq: the first reads "Clear to Ship", the second reads
"Action Needed" while its legacy `status_label` still shows the legacy green
label. This is the false-green kill, observed live.
5. Confirm Visit round trip: POST `/api/confirm-visit` for the Action Needed
patient's visit case (test 5 shape) and assert the returned record's
`readiness_status` flips as specified.
6. Regression: re-run the exact pre-wiring fixture and diff the response against a
saved pre-deploy response, ignoring only the new keys. Zero legacy diffs allowed.
7. `railway logs` spot check: any new log lines show hashed patient_ids only.
8. Record results (counts, not adjectives) in `docs/ship-status-ledger.md`.
---
## 10. Constraints & gotchas
- **PHI (non-negotiable):** patient_id is the sole crosswalk key. `DocItem.value`
carries only client status strings or ISO dates, never identity fields. Never add
name/DOB/SSN/contact anywhere, including test fixtures. Hash patient_id in every
new log line (`_hash_pid` pattern, dedup.py:40-42).
- **Locked labels:** `readiness_status` emits the `LineStatus` values verbatim: At
Risk, Action Needed, On Track, Clear to Ship, plus Plan Type Needed for unknown
plans. Never invent a sixth, never rename, and never mix in the resupply-lifecycle
set (Supply Lapsed, Renewal Due, Resupply Ready, Active, Outreach Worklist), which
is a separate locked vocabulary for the pilot guide and copy. The legacy
FLAG_LABELS map (main.py:136-146) stays untouched until P6.
- **Locked dedup key:** `(patient_id, device_type, date_of_service)`. order_number is
display-only. Do not "improve" the key while wiring.
- **Circular import:** `coverage_calculator.py` must never import
`dedup`/`readiness`/`worklist_readiness` (dedup.py:35 imports FROM it). The new
module is only imported by `api/main.py` and tests.
- **`_load_rules` cache hazard:** `@lru_cache` returns the live mutable dict
(readiness.py:99-102). Tests must never mutate it (documented hazard, Phase 2
design section 2B).
- **Normalizer alias collision:** "plan", "plan_name", "plan name" mean PAYER today
(normalizer.py:46-50). plan_type aliases must not include them.
- **Language:** "resupply", never "refill", in any new copy, field names, or test
names. (`refill_window_days` in payer_rules.json is an existing code key; leave it,
renaming config keys is not this step.)
- **Prior-shipment rows carry the current shipment's verdict.** A six-month history
upload shows N legacy rows for one line, all with the same line verdict graded from
the latest DOS. By design (grade the current shipment only). Expect it; do not
"fix" it here; the row collapse is P3/P6.
- **Verdicts are not persisted.** `persist_upload` (main.py:538-546) stores shipment
records and coverage results only. Readiness is recomputed per request this phase.
Persisting verdicts is a schema decision that goes through lead-sttil's data
handling check first.
- **Deploy mechanics:** backend `railway up --detach` (fresh build; `railway
redeploy` only reuses the old image and will NOT pick up code changes). Frontend is
untouched this step; if it ever needs a deploy, Vercel is manual (`vercel --prod`,
the GitHub webhook is broken).
- **Auth on verify:** use the X-API-Key header path; Clerk JWT is the frontend path.
---
## 11. Definition of Done
All of the following, in order, with evidence recorded:
1. `core/worklist_readiness.py` exists; imports of dedup/readiness in
`python-backend/` are exactly: worklist_readiness (plus tests).
2. Both call-sites wired: `/api/upload` (batch, main.py:492 region) and
`/api/confirm-visit` (single, main.py:751 region) return readiness fields.
3. Full test suite green (108 existing + new wiring tests), run and count recorded,
including acceptance cases 1 (false green blocked) and 2 (Plan Type Needed).
4. Back-compat invariant proven (section 8 case 6): legacy response fields
byte-identical for a fixture CSV.
5. code-auditor review passed (independent agent, findings resolved or accepted).
6. signal-e2e-test skill run against the wired pipeline, results recorded.
7. Deployed via `railway up --detach`; all live-verify steps in section 9 pass with
measured counts, not impressions.
8. `docs/ship-status-ledger.md` entry appended; `context/current-state.md` updated
via state-sync with one author-stamped decision line.
9. No production change outside the files listed in section 5; no PHI fields added;
no label invented; dedup key untouched.
---
## 12. Open questions / decisions needed from Kisa
1. **Plan-type CSV ingestion now, or wait for P5?** (The single biggest call.)
Without it, every live verdict reads "Plan Type Needed" on day one: honest and
doctrine-correct, but no green is reachable end to end and the new field looks
inert. My recommendation: include the minimal verbatim column mapping (section 4
step 5) in this wiring. It is a client-supplied mapped field, not a guess, so it
honors the lock; the full enforcement, guesser removal, and mapping-review UX stay
with P5's owner. GRAFTED recommendation; Kisa gates.
2. **Confirm the seam:** readiness AUGMENTS the coverage result (Phase 2a
coexistence), it does not replace the worklist scoring yet. This follows Pi's
locked migration strategy, but the wiring makes it real, so I want it said out
loud and confirmed.
3. **`readiness_stats` in the upload response now?** Cheap, additive, makes live
verification measurable. Recommendation: yes. GRAFTED.
4. **Cut-over timing** (Phase 2b/2c: frontend switches, old path retired) remains
entirely Kisa's decision per the Phase 2 design, section 4. Nothing in this plan
pre-commits it.
5. **State-file correction:** current-state.md says "73 tests green" (2026-06-27
entry); today's grounded counts are 30 + 27 unit, 35 E2E, 108 total. Approve
recording the corrected counts at the next state-sync so no one re-quotes 73.

View file

@ -0,0 +1,547 @@
# Build Plan 02: Readiness Model Completion (P2-P6)
**Date:** 2026-07-06
**Author:** lead-signal (Claude), grounded against the 2026-06-27 clean checkpoint
**Status:** Execute-from-me plan. Not started.
**Precondition:** Build Plan `docs/build-plans/01-readiness-model-wiring.md` is DONE, meaning the readiness model (dedup.py + readiness.py) is live in the `/api/upload` pipeline. Verify the precondition contract below before executing anything here.
**Prior design docs this plan builds on (read them, do not re-derive):**
- `docs/readiness-model-brief-2026-06-23.md` (Phase 1 spec, locked decisions)
- `docs/readiness-model-design-phase2-2026-06-26.md` (Pi's Phase 2 design, the direct parent of this plan)
- `docs/dedup-design-2026-06-24.md` (locked dedup key and pipeline shape)
Every load-bearing claim below is tagged **GROUNDED** (verified in a file at a cited location) or **GRAFTED** (assumption or design decision not yet verified in code). Line numbers were read on 2026-07-06 at the 2026-06-27 checkpoint. The 01 wiring plan will shift some of them: grep for the quoted anchors, do not trust line numbers blindly.
---
## How to use this plan (instructions for the executing session)
1. Run the test baseline first: `python3 -m pytest tests/ -q` from the repo root (`/Users/sttil-solutions/projects/signal`). Tests self-insert `python-backend` onto `sys.path` (GROUNDED: `tests/test_readiness.py` lines 22-24). Baseline function counts at checkpoint: 30 in `tests/test_dedup.py`, 27 in `tests/test_readiness.py`, 35 in `tests/signal_e2e_test.py` (GROUNDED: `grep -c "def test"`).
2. Verify the **precondition contract** (next section). Anything 01 did not deliver gets folded into the relevant item here (P5 step 0 covers the most likely gap).
3. Execute items in order. Each item is shipped alone: build, test, **code-auditor review (a different agent, never self-signed)**, commit, deploy, update `context/current-state.md` and `docs/ship-status-ledger.md`. Then the next item.
4. Never modify these locked facts: dedup key `(patient_id, device_type, date_of_service)`; the four worklist labels (At Risk, Action Needed, On Track, Clear to Ship); the separate resupply-lifecycle name set (Supply Lapsed, Renewal Due, Resupply Ready, Active, Outreach Worklist); the PHI contract (patient_id sole crosswalk, hashed in logs, no name/DOB/SSN/contact fields anywhere); plan type is never guessed.
5. Say "resupply", never "refill", in all user-facing copy. The JSON key `refill_window_days` is a legacy code identifier: leave it in code, keep it out of copy.
### Precondition contract (what 01 must have delivered; verify each)
| # | Assumed post-wiring fact | How to verify |
|---|---|---|
| W1 | `/api/upload` runs normalize, then `dedup()`, then `assign_to_coverage_lines()`, then `evaluate_readiness()` per gradeable line | grep `evaluate_readiness` in `python-backend/api/main.py` |
| W2 | The upload response carries a per-line readiness verdict (line_status + doc_items) in some serialized form | grep `doc_items` / `line_status` in `api/main.py` |
| W3 | Timing flags (`CoverageFlag`) still compute per line (the readiness verdict is the doc axis; timing is the separate axis, per `readiness.py` lines 139-144) | read the wired pipeline |
| W4 | `ShipmentRecord.plan_type` is populated from a client-mapped CSV column | grep `plan_type` in `python-backend/api/normalizer.py` |
**GRAFTED:** this contract is an assumption about a plan that did not exist when this document was written. At the 2026-06-27 checkpoint, W1-W4 are all false (GROUNDED: `api/main.py` line 25 imports only `calculate_batch` from the old path; `grep plan_type api/normalizer.py` returns nothing). If W4 is missing after 01, execute P5 step 1 before any item that needs plan-typed test data.
### Execution order and dependencies
**P2 → P3 → P4 → P5 → P6.**
- P2 first: citation IDs are pure config + engine work, no API shape change, and every later item's verdict serialization should carry `rule_id` from day one. P5's "every determination cites its basis" needs P2 done.
- P3 second: creates the patient rollup and the nested API payload that P4 (re-rollup after override) and P6 (nested display) both consume.
- P4 third: its recompute step ends in "re-roll-up the patient", which requires P3.
- P5 fourth: independent of P4 (they may swap or run in parallel if two sessions exist), but the Plan Type Needed capping behavior is only fully testable at patient level, so after P3.
- P6 strictly last: it renders what P2-P5 produce, and it has a design gate (Kisa approves a rendered mockup first).
---
## P2: Rules-to-config citation IDs
### Objective
Give every documentation requirement in `python-backend/config/payer_rules.json` a stable citation ID so each DocItem in a verdict names the rule that decided it.
### Current state (GROUNDED)
- GROUNDED: `payer_rules.json` stores flat scalars per plan type (lines 32-72): `visit_renewal_days: 180`, `refill_window_days: 30`, `pa_required: true/false`, `pecos_required: true/false`, plus a `default` entry (lines 65-71) and a `devices` wear-day section (lines 3-31). No rule IDs anywhere in the file.
- GROUNDED: `core/readiness.py` `_required_from_config()` (lines 197-213) reads `plan_cfg[flag_key]` as a flat boolean and returns only a `RequiredState`. `DocItem` (lines 82-90) has no `rule_id` field. A verdict cannot cite which rule fired.
- GROUNDED: `core/coverage_calculator.py` reads the SAME file flat: `payer_config.get("visit_renewal_days")` (line 331), `refill_window_days` (line 332), and `_get_wear_days()` reads the `devices` section (lines 108-126). **Converting the JSON format without updating these readers in the same commit breaks the live calculator** (a wrapped dict where an int is expected raises at `visit_date + timedelta(days=...)`).
- GROUNDED: the design is already specified: `docs/readiness-model-design-phase2-2026-06-26.md` section 3 Item 1 defines the wrapped format `{"value": 180, "rule_id": "R001"}`, backward compatibility with flat values, and the stable registry R001-R004 plus R100-R102 for the universal LCD L33822 items. IDs are never changed after shipping.
- GRAFTED: 01 exposes doc_items in the API payload (contract W2), so adding `rule_id` to `DocItem` propagates to the frontend without a separate serialization step. Verify; if not, add `rule_id` to whatever DocItem serializer 01 created.
### Approach
1. Create `python-backend/core/rules.py` with two helpers that accept BOTH formats (flat scalar and wrapped dict): `rule_value(cfg, key, default=None)` and `rule_id_for(cfg, key)`. Include the registry constants for the universal items.
2. Update `core/coverage_calculator.py` to read `visit_renewal_days` and `refill_window_days` through `rule_value()` (lines 331-332 today; grep `payer_config.get`). Leave `_get_wear_days` and the `devices` section untouched this pass (wear-day rules get IDs in a later phase if needed; do not expand scope).
3. Update `core/readiness.py`:
- `DocItem` gains `rule_id: Optional[str] = None`.
- `_required_from_config()` returns `(RequiredState, Optional[str])`, the rule_id coming from config when wrapped, or the registry default (`R003` for pa_required, `R004` for pecos_required) when flat.
- `_make_item()` accepts and stores `rule_id`.
- The three universal items get constants: swo R100, visit R101, diagnosis R102.
- When `required_state` is `NOT_EVALUATED` (plan unknown or config gap), `rule_id` stays `None`: no rule fired, so nothing is cited. Document this in the docstring.
4. Convert `payer_rules.json`: wrap the four plan-scoped keys (`visit_renewal_days`, `refill_window_days`, `pa_required`, `pecos_required`) in ALL five plan entries including `default`, using exactly the registry IDs from the phase-2 doc (R001 visit_renewal_days, R002 refill_window_days, R003 pa_required, R004 pecos_required, identical IDs across plan types since the ID names the rule kind, and the plan type + value together are the citation). Add a top-level `_rule_registry` informational block listing all seven IDs with one-line descriptions, and update the `_comment` date.
5. Confirm `rule_id` flows into the serialized doc_items payload (contract W2). The target shape is the phase-2 doc section 5 example: each doc_item carries `rule_id`.
### Files to touch
| Path | Change |
|---|---|
| `python-backend/core/rules.py` | NEW: format-tolerant readers + registry constants |
| `python-backend/config/payer_rules.json` | wrap the 4 plan-scoped keys x 5 plan entries; add `_rule_registry` |
| `python-backend/core/readiness.py` | DocItem.rule_id; `_required_from_config` returns tuple; universal-item IDs |
| `python-backend/core/coverage_calculator.py` | read payer config through `rule_value()` |
| `python-backend/api/main.py` (or wherever 01 serializes doc_items) | include `rule_id` per doc_item |
| `tests/test_readiness.py`, `tests/test_coverage_flags.py` | new tests below; existing must stay green |
### Skeleton snippets (illustrative only, not applied)
```python
# core/rules.py
UNIVERSAL_RULE_IDS = {"swo": "R100", "visit": "R101", "diagnosis": "R102"}
PLAN_KEY_RULE_IDS = {
"visit_renewal_days": "R001", "refill_window_days": "R002",
"pa_required": "R003", "pecos_required": "R004",
}
def rule_value(cfg: dict, key: str, default=None):
"""Read a rule value, accepting flat (180) and wrapped ({'value': 180, 'rule_id': 'R001'})."""
raw = cfg.get(key, default)
if isinstance(raw, dict) and "value" in raw:
return raw["value"]
return raw
def rule_id_for(cfg: dict, key: str) -> str | None:
raw = cfg.get(key)
if isinstance(raw, dict) and "rule_id" in raw:
return raw["rule_id"]
return PLAN_KEY_RULE_IDS.get(key)
```
```json
"medicare": {
"visit_renewal_days": { "value": 180, "rule_id": "R001" },
"refill_window_days": { "value": 30, "rule_id": "R002" },
"pa_required": { "value": false, "rule_id": "R003" },
"pecos_required": { "value": true, "rule_id": "R004" },
"_note": "unchanged",
"covered_devices": ["dexcom_g6", "dexcom_g7", "freestyle_libre_2", "freestyle_libre_3"]
}
```
```python
# readiness.py deltas
@dataclass
class DocItem:
...
rule_id: Optional[str] = None # names the payer_rules.json rule that decided required_state
def _required_from_config(...) -> tuple[RequiredState, Optional[str]]:
if not plan_known:
return RequiredState.NOT_EVALUATED, None
...
return (RequiredState.REQUIRED if bool(rule_value(plan_cfg, flag_key))
else RequiredState.NOT_REQUIRED), rule_id_for(plan_cfg, flag_key)
```
### Test strategy & acceptance criteria
New tests (add to `tests/test_readiness.py` unless noted):
- `test_doc_items_carry_rule_ids`: a medicare verdict yields swo R100, visit R101, diagnosis R102, pecos R004, pa R003.
- `test_flat_and_wrapped_config_produce_identical_verdicts`: run `evaluate_readiness` with a flat rules dict and a wrapped rules dict (injected via the existing `rules=` parameter, never by mutating the lru_cache); statuses and satisfied flags identical.
- `test_not_evaluated_items_have_no_rule_id`: unknown plan yields pecos/pa with `rule_id is None`.
- `test_wrapped_config_drives_timing_rules` (in `tests/test_coverage_flags.py`): with the wrapped JSON on disk, `calculate_coverage` still computes `next_visit_due_date` correctly.
Acceptance: all baseline tests green with the converted JSON on disk; every REQUIRED or NOT_REQUIRED DocItem carries a non-null rule_id; the live `/api/upload` path returns doc_items with rule_id; `_rule_registry` in the JSON matches the phase-2 doc registry exactly.
### Definition of Done
Tests green (baseline + new), code-auditor review passed by a different agent, `payer_rules.json` and code deployed together via `railway up --detach` from the repo root, one live verdict spot-checked to cite R004, ship-status ledger and current-state updated.
---
## P3: Patient rollup
### Objective
Aggregate a patient's coverage lines into one patient-level readiness view (`PatientRollup`) and expose a nested patients payload from the API, so the worklist can show one row per patient.
### Current state (GROUNDED)
- GROUNDED: no rollup code exists anywhere in the backend (`grep -rn "rollup\|PatientRollup" python-backend/ --include="*.py"` returns nothing).
- GROUNDED: the pipeline stops at lines: `core/dedup.py` `assign_to_coverage_lines()` (lines 218-250) returns `dict[CoverageLineKey, CoverageLine]` keyed by `(patient_id, device_type, plan_type_id)`, with `gradeable=is_cgm_device(...)` and unknown plan keyed as `"unknown"`.
- GROUNDED: `/api/upload` returns a flat `records: list[RecordOut]` (`api/main.py` `UploadResponse` lines 204-211), one entry per scored record; the frontend renders one table row per record and counts records, not patients (`WorklistTable.jsx` lines 100-122, rowKey = `patient_id + "-" + i` at line 209).
- GROUNDED: the rollup rules are locked in `readiness-model-brief-2026-06-23.md` section 4 and specified in the phase-2 doc Item 3: rollup = worst gradeable line; severity Plan Type Needed > At Risk > Action Needed > On Track > Clear to Ship; any gradeable Plan Type Needed line caps the patient no greener than Action Needed; non-gradeable lines excluded from the light and all tab counts; tabs count patients.
- GRAFTED: the capping rule is Pi's recommendation, still listed as open question 2 in the phase-2 doc section 6. Build to the recommendation; it stands unless Kisa reverses it (see closing section).
### Approach
1. Create `python-backend/core/rollup.py` with the `PatientRollup` dataclass (field list from phase-2 doc Item 3) and two functions: `rollup_patient(lines: list[CoverageLine]) -> PatientRollup` and `rollup_patients(lines: dict[CoverageLineKey, CoverageLine]) -> dict[str, PatientRollup]` keyed by patient_id.
2. Encode the rollup decision exactly as this truth table (gradeable lines only):
| Gradeable line statuses present | rollup_status |
|---|---|
| none (patient has only non-gradeable lines) | `None` (UI treatment is an open Kisa question; do not invent a label) |
| all Plan Type Needed | Plan Type Needed |
| some Plan Type Needed + others | the worse of (Action Needed, worst non-PTN status). Example: PTN + Clear to Ship = Action Needed; PTN + At Risk = At Risk |
| no Plan Type Needed | worst status by severity (At Risk > Action Needed > On Track > Clear to Ship) |
3. Lines whose `line_status` is still `None` (not yet graded, e.g. non-gradeable) never participate in the light.
4. API: add a nested `patients: list[PatientOut]` to the upload response alongside the existing flat `records` (which stays untouched until P6 switches the UI, per the phase-2 migration strategy section 4). `PatientOut` shape follows the phase-2 doc section 5 example: patient_id, rollup_status, lines[] each carrying device_type, plan_type, gradeable, line_status, timing_flag, doc_items[] (with rule_id from P2), and display fields (order_numbers, hcpcs_codes, total_quantity, shipment_date, payer).
5. Stats: add patient-level counts (`patients_total` plus one count per rollup status) next to the existing record-level stats; do not remove the old stats yet.
6. Logging: any log line touching a patient uses a hashed patient_id. Reuse the `_hash_pid` pattern from `dedup.py` (lines 40-42); prefer moving it to a tiny shared util over duplicating it.
### Files to touch
| Path | Change |
|---|---|
| `python-backend/core/rollup.py` | NEW: PatientRollup + rollup functions + severity constants |
| `python-backend/core/dedup.py` | optionally export/move `_hash_pid` to a shared util |
| `python-backend/api/main.py` | `PatientOut` model, assemble nested payload, patient-level stats |
| `tests/test_rollup.py` | NEW test module |
### Skeleton snippets (illustrative only, not applied)
```python
# core/rollup.py
from core.readiness import LineStatus
from core.dedup import CoverageLine, CoverageLineKey
SEVERITY = { # higher = worse
LineStatus.CLEAR_TO_SHIP: 0,
LineStatus.ON_TRACK: 1,
LineStatus.ACTION_NEEDED: 2,
LineStatus.AT_RISK: 3,
LineStatus.PLAN_TYPE_NEEDED: 4,
}
@dataclass
class PatientRollup:
patient_id: str
lines: list[CoverageLine]
rollup_status: Optional[LineStatus] # None when no gradeable graded lines
cgm_line_count: int
total_line_count: int
plan_type_needed: bool # any gradeable line in Plan Type Needed
def rollup_patient(lines: list[CoverageLine]) -> PatientRollup:
graded = [l for l in lines if l.gradeable and l.line_status is not None]
statuses = [LineStatus(l.line_status) for l in graded]
if not statuses:
status = None
elif all(s == LineStatus.PLAN_TYPE_NEEDED for s in statuses):
status = LineStatus.PLAN_TYPE_NEEDED
elif any(s == LineStatus.PLAN_TYPE_NEEDED for s in statuses):
worst_other = max((s for s in statuses if s != LineStatus.PLAN_TYPE_NEEDED),
key=SEVERITY.get)
status = max(worst_other, LineStatus.ACTION_NEEDED, key=SEVERITY.get)
else:
status = max(statuses, key=SEVERITY.get)
...
```
### Test strategy & acceptance criteria
New `tests/test_rollup.py` (names from the phase-2 doc plus additions):
- `test_patient_rollup_single_line`: one gradeable line, rollup equals that line.
- `test_patient_rollup_mixed_statuses`: Clear to Ship + At Risk = At Risk.
- `test_patient_rollup_plan_type_needed_capping`: PTN + Clear to Ship = Action Needed; PTN + At Risk = At Risk.
- `test_patient_rollup_all_lines_plan_type_needed`: rollup = Plan Type Needed.
- `test_patient_rollup_non_cgm_excluded`: an omnipod_5 line (gradeable False) never changes the light or the counts.
- `test_patient_rollup_no_gradeable_lines`: rollup_status is None, counts still correct.
- API-level: `test_upload_response_contains_patients_nested` and `test_patient_stats_count_patients_not_rows` (a 2-device patient counts once).
Acceptance: a multi-device patient produces exactly one PatientRollup; the truth table above holds verbatim; the flat `records` payload is byte-for-byte unaffected; no raw patient_id appears in any new log line.
### Definition of Done
Tests green, auditor review passed, deployed via `railway up --detach`, a live upload of a multi-device fixture CSV verified to return one nested patient with correct rollup, ledger and current-state updated.
---
## P4: Device-keyed staff override + recompute + rollup
### Objective
Make staff overrides (Confirm Visit, SWO/PA status changes) apply to the correct device line, recompute that line's verdict, and re-roll-up the patient, ending the override leak across a patient's devices.
**Scope note (conflict flagged):** the task list glosses P4 as "device-type-specific rule overrides on top of the base payer rules." The locked design docs define this item differently: `readiness-model-design-phase2-2026-06-26.md` Item 4 and `readiness-model-brief-2026-06-23.md` section 6 specify device-keyed STAFF overrides with recompute and rollup. This plan follows the design docs. A per-device payer-rules config layer is a different feature that has no design; if Kisa wants it, it needs its own brief (see closing section, open question 3).
### Current state (GROUNDED)
- GROUNDED: the leak is real and structural. `core/persistence.py` `upsert_doc_status()` upserts `doc_status` with `on_conflict="org_id,patient_id_hash,doc_type"` (lines 250-252): no device_type in the key, so an SWO override saved for a patient applies to every device that patient has. `load_doc_statuses_for_org()` (lines 259-285) returns `patient_hash -> {doc_type: {...}}`, also device-blind.
- GROUNDED: `api/main.py` `DocStatusRequest` (lines 303-308) has no device_type field; `PUT /api/doc-status` (lines 769-841) validates swo/pa, stores, and returns only a display label. No recompute, no rollup.
- GROUNDED: `POST /api/confirm-visit` (lines 670-766) stores the confirmed date patient-scoped (correct: the visit is the person) but recomputes ONE record through the old `calculate_coverage` (line 751) and returns a single flat record. No line re-grade, no patient re-rollup.
- GROUNDED: frontend mirrors the leak: `WorklistTable.jsx` keys `localDocStates` by bare patient_id (line 63, updates at lines 350-355 and 361-366) and `updateDocStatus(patientId, docType, ...)` sends no device (line 517).
- GROUNDED: the design is specified: phase-2 doc Item 4 defines `OverrideKey(org_id, patient_hash, device_type, doc_type)`, the `Override` dataclass, and `apply_overrides()` semantics: a visit override fans out to ALL the patient's lines; every other doc_type applies only to lines with matching device_type. Pilot scope is locked to visit + SWO/PA only; no PECOS or diagnosis overrides this phase.
- GROUNDED: the migration path exists: `supabase/migrations/` holds `20260607000001_audit_log_worm.sql`, applied via the Supabase CLI (per `CLAUDE.md` compliance table).
### Approach
1. **DB migration** `supabase/migrations/<timestamp>_doc_status_device_scope.sql`:
- `ALTER TABLE doc_status ADD COLUMN device_type text NOT NULL DEFAULT '';`
- Replace the unique constraint with `(org_id, patient_id_hash, doc_type, device_type)`.
- Convention: `device_type = ''` marks a legacy patient-scoped row; it applies to all of that patient's devices until staff re-saves it, at which point the new device-scoped row wins. Using `NOT NULL DEFAULT ''` (not NULL) keeps the unique constraint honest, since Postgres treats NULLs as always-distinct in unique constraints.
- Table stores `patient_id_hash` only, never raw patient_id (already true, keep it that way).
2. **Persistence**: `upsert_doc_status(..., device_type: str)` includes device_type in the payload and the `on_conflict` list. `load_doc_statuses_for_org()` returns `patient_hash -> device_type -> doc_type -> {...}` with the `''` bucket for legacy rows.
3. **New `python-backend/core/overrides.py`**: `OverrideKey`, `Override` (fields per phase-2 Item 4 skeleton), and `apply_overrides(lines, overrides)` implementing: visit overrides apply to all the patient's gradeable lines; swo/pa apply to matching device_type, with `''` treated as match-all; after applying, re-grade affected lines with `evaluate_readiness` and return the set of touched patient_ids.
4. **Recompute contract** (brief section 6): after any override write, recompute returns `{line, rollup}` for the affected patient only. No full batch rebuild per click. Implementation: re-grade the touched line(s), call `rollup_patient()` (P3) on that patient's lines, return both in the API response.
5. **API**: `DocStatusRequest` gains `device_type: str` (required); `PUT /api/doc-status` responds with the recomputed `{line, rollup}`. `POST /api/confirm-visit` response extends to include the re-rolled patient (visit is patient-scoped, all lines recompute). Keep the old response fields present so the current UI does not break before P6.
6. **Frontend minimal fix only** (the full nesting is P6): key `localDocStates` by `` `${patient_id}:${device_type}` `` and pass `device_type` through `updateDocStatus` in `signal-ui/src/lib/api.js`. This alone stops the visible leak.
### Files to touch
| Path | Change |
|---|---|
| `supabase/migrations/<ts>_doc_status_device_scope.sql` | NEW: device_type column + new unique key |
| `python-backend/core/persistence.py` | device_type in upsert + nested load shape |
| `python-backend/core/overrides.py` | NEW: OverrideKey/Override/apply_overrides |
| `python-backend/api/main.py` | DocStatusRequest.device_type; {line, rollup} responses on doc-status and confirm-visit |
| `signal-ui/src/lib/api.js` | updateDocStatus carries device_type |
| `signal-ui/src/components/WorklistTable.jsx` | localDocStates keyed `${patient_id}:${device_type}` (key change only) |
| `tests/test_overrides.py` | NEW test module |
### Skeleton snippets (illustrative only, not applied)
```python
# core/overrides.py (shapes from the phase-2 design doc, Item 4)
@dataclass(frozen=True)
class OverrideKey:
org_id: str
patient_hash: str
device_type: str # '' = legacy patient-scoped row, applies to all devices
doc_type: str # "visit" | "swo" | "pa"
@dataclass
class Override:
key: OverrideKey
status: str
status_date: date
expiry_date: Optional[date]
confirmed_by: str
confirmed_at: datetime
def apply_overrides(lines: dict[CoverageLineKey, CoverageLine],
overrides: list[Override]) -> set[str]:
"""Apply overrides, re-grade affected lines, return touched patient_ids.
visit: fans out to every line of that patient (the visit is the person).
swo/pa: only lines whose device_type matches; '' matches all (legacy)."""
```
```sql
-- migration sketch
ALTER TABLE doc_status ADD COLUMN device_type text NOT NULL DEFAULT '';
ALTER TABLE doc_status DROP CONSTRAINT IF EXISTS doc_status_org_id_patient_id_hash_doc_type_key;
ALTER TABLE doc_status ADD CONSTRAINT doc_status_scope_key
UNIQUE (org_id, patient_id_hash, doc_type, device_type);
```
(Verify the actual constraint name in Supabase before writing the migration: `\d doc_status` or the dashboard.)
### Test strategy & acceptance criteria
New `tests/test_overrides.py` (names from the phase-2 doc plus additions):
- `test_override_visit_applies_to_all_patient_lines`
- `test_override_swo_applies_only_to_one_device`
- `test_override_triggers_recompute`: SWO override flips a line from Action Needed to a better status when it satisfies the last open required item.
- `test_recompute_triggers_rerollup`: the returned rollup reflects the recomputed line.
- `test_legacy_blank_device_row_applies_patient_wide`
- API-level: `test_doc_status_put_returns_line_and_rollup`, `test_doc_status_requires_device_type`.
Acceptance, verified by DRIVING the real app (not by reading code): a two-device patient (e.g. dexcom_g7 + freestyle_libre_2), cycle SWO on device A, device A's line changes, device B's line does not, and the patient badge updates to the recomputed rollup.
### Definition of Done
Migration applied via Supabase CLI, tests green, auditor review passed, backend deployed via `railway up --detach`, frontend key fix deployed via `vercel --prod` (manual, webhook broken), driven verification of the no-leak behavior recorded in the ledger, current-state updated.
---
## P5: Plan-type enforcement in the live path
### Objective
Make the live pipeline honor the locked doctrine end to end: an unknown plan type reads Plan Type Needed and can never be green, plan type comes only from a client-mapped CSV field, and the payer-name guesser can never influence a readiness verdict.
### Current state (GROUNDED)
- GROUNDED: the ENGINE already enforces this. `core/readiness.py` normalizes but never guesses plan_type (lines 231-232), routes unknown plans to `PLAN_TYPE_NEEDED` (lines 268-269), and fails safe to `PLAN_TYPE_NEEDED` when a known plan's config is missing (lines 272-274). `core/dedup.py` carries plan_type verbatim and routes conflicts to None (lines 142-156). The gap is everything upstream of the engine.
- GROUNDED: the live path still guesses. `core/coverage_calculator.py` `_normalize_payer()` (lines 137-162) keyword-matches raw payer names, `_get_payer_config()` (lines 165-168) falls back to the `default` config in `payer_rules.json` (lines 65-71), and `api/main.py` `_normalize_payer_type()` (lines 255-263) feeds the guesser's output into `compute_doc_state`. This is the false-green hole named in the brief, section 8.
- GROUNDED: no CSV can currently deliver a plan type at all. `api/normalizer.py` FIELD_ALIASES (lines 19-88) has NO `plan_type` canonical field; the headers "plan", "plan_name" alias to `payer` (lines 46-50). `ShipmentRecord.plan_type` defaults to None (`coverage_calculator.py` line 82).
- GROUNDED: the import UI has no plan type either. `CSVImport.jsx` FIELD_LABELS (lines 6-19) and OVERRIDE_OPTIONS (lines 22-29) lack plan_type; `grep plan_type signal-ui/src/components/*.jsx` returns nothing.
- GROUNDED: the migration strategy is specified in the phase-2 doc sections 3 (Item 5) and 4: old and new paths coexist, the old path logs a deprecation warning, and deleting `_normalize_payer` plus the `default` config entry is Phase 2c, gated on Kisa, explicitly NOT this build.
- GRAFTED: 01 may have delivered the normalizer mapping (contract W4). Verify first; skip delivered steps.
### Approach
0. Verify contract W4. If the 01 wiring already added plan_type mapping, skip step 1 and reconcile alias lists.
1. **Normalizer**: add a `plan_type` canonical field to FIELD_ALIASES with header aliases: `plan_type`, `plan type`, `plantype`, `insurance_type`, `insurance type`, `coverage_type`, `coverage type`, `payer_type`, `payer type`, `lob`, `line_of_business`, `line of business`, `benefit_type`, `benefit type`, `plan_category`. Do NOT move "plan"/"plan_name" (a bare "Plan" column in the wild is a payer name; they stay payer aliases). Add a VALUE map applied to the cell contents:
- `medicare`, `medicare ffs`, `ffs`, `medicare part b`, `part b`, `traditional medicare`, `original medicare` map to `medicare`
- `medicare advantage`, `ma`, `medicare part c`, `part c`, `medicare_advantage` map to `medicare_advantage`
- `medicaid`, `mcd`, `state medicaid` map to `medicaid`
- `commercial`, `private`, `employer`, `group` map to `commercial`
- anything else, including blank, maps to None
Doctrine check: normalizing a client-supplied plan-type VALUE is honest mapping, not guessing; the client asserted the plan type, Signal only canonicalizes spelling. Deriving plan type from the payer NAME remains forbidden. Unrecognized values log a warning (hashed patient_id only) and grade as Plan Type Needed.
2. **Enforcement**: guarantee no call site ever passes `_normalize_payer()` output into `evaluate_readiness` or into the CoverageLine plan_type. The readiness verdict receives only the client-mapped field. Lock this with a test, then grep the wired `api/main.py` for any residual `_normalize_payer_type` feeding readiness data and cut that edge (the old `compute_doc_state` display path may keep it until Phase 2b/2c per the migration strategy).
3. **Deprecation fence**: whenever the old timing path falls back to `_normalize_payer` because plan_type is None, log the phase-2 doc's warning once per batch: "plan_type not provided, falling back to payer-name guess for timing rules only. This path will be removed. Supply plan_type in the CSV import."
4. **Guard the default config**: `readiness.py` already cannot reach the `default` entry (line 235 looks up only known plan types). Add `test_default_config_never_reaches_readiness` to lock that in permanently. Do not delete the `default` entry (Phase 2c, Kisa-gated).
5. **Import UI**: `CSVImport.jsx` FIELD_LABELS gains `plan_type: "Plan Type"`; OVERRIDE_OPTIONS gains `{ value: "plan_type", label: "Plan Type" }`. The existing mapping review then surfaces it like any other detected column.
6. **Demo and fixture data (load-bearing for demo quality)**: add a Plan Type column to the demo/sample CSVs and the 25-variant test corpus additions. Without this, every demo patient reads Plan Type Needed the moment enforcement ships and the demo collapses. Locate the fixtures (search the repo for the CSV test sets referenced in `CLAUDE.md`, "50 new files generated (PA + NJ sets)") and update the canonical demo file(s) at minimum.
### Files to touch
| Path | Change |
|---|---|
| `python-backend/api/normalizer.py` | plan_type field aliases + value normalization map |
| `python-backend/api/main.py` | ensure readiness receives only mapped plan_type; batch deprecation warning |
| `python-backend/core/coverage_calculator.py` | deprecation warning at the `_normalize_payer` fallback |
| `signal-ui/src/components/CSVImport.jsx` | FIELD_LABELS + OVERRIDE_OPTIONS gain Plan Type |
| demo/sample CSV fixtures | add Plan Type column |
| `tests/` | new tests below |
### Skeleton snippets (illustrative only, not applied)
```python
# api/normalizer.py additions
FIELD_ALIASES["plan_type"] = [
"plan_type", "plan type", "plantype", "insurance_type", "insurance type",
"coverage_type", "coverage type", "payer_type", "payer type",
"lob", "line_of_business", "line of business", "benefit_type", "benefit type",
]
PLAN_TYPE_VALUES = {
"medicare": "medicare", "medicare ffs": "medicare", "ffs": "medicare",
"medicare part b": "medicare", "part b": "medicare",
"traditional medicare": "medicare", "original medicare": "medicare",
"medicare advantage": "medicare_advantage", "ma": "medicare_advantage",
"medicare part c": "medicare_advantage", "part c": "medicare_advantage",
"medicaid": "medicaid", "mcd": "medicaid", "state medicaid": "medicaid",
"commercial": "commercial", "private": "commercial", "employer": "commercial",
}
def _normalize_plan_type(raw: str | None) -> str | None:
"""Canonicalize a CLIENT-SUPPLIED plan type value. Never called on payer names.
Unrecognized -> None -> Plan Type Needed. Never guess."""
s = (raw or "").strip().lower()
return PLAN_TYPE_VALUES.get(s)
```
### Test strategy & acceptance criteria
- `test_live_path_routes_to_new_engine_when_plan_type_present` and `test_live_path_falls_back_when_plan_type_missing` (phase-2 names; the fallback affects timing only, readiness reads Plan Type Needed).
- `test_deprecation_warning_logged` (caplog on the fallback).
- `test_plan_type_value_normalization`: every synonym above maps correctly; `"Medicare Part B"` in a PLAN TYPE column maps to medicare; the same string in a PAYER column changes nothing about readiness.
- `test_unknown_plan_type_value_yields_plan_type_needed`.
- `test_default_config_never_reaches_readiness`.
- `test_payer_name_never_sets_plan_type`: a CSV with payer "Medicare Part B" and NO plan_type column yields readiness Plan Type Needed, not medicare.
- E2E (extend `tests/signal_e2e_test.py` or run the signal-e2e-test skill): upload a fixture without a plan type column, assert zero Clear to Ship readiness verdicts; upload the same fixture with the column, assert greens appear only where every required item is satisfied.
Acceptance: with no plan_type mapped, no patient can show a green readiness verdict anywhere in the payload; with plan_type mapped, verdicts match the engine truth table; the guesser affects only legacy timing flags and always logs its warning; demo CSVs updated so the demo still shows a healthy mix of statuses.
### Definition of Done
Tests green, auditor review passed, deployed (backend `railway up --detach`, frontend `vercel --prod`), driven verification: upload both fixture variants against the live app and record the verdict counts in the ledger, current-state updated, and lead-content notified if demo narrative changes (demo now includes a Plan Type mapping step).
---
## P6: Frontend nesting and true-green display
### Objective
Rebuild the worklist to show one row per patient carrying the rollup verdict, expanding into per-device coverage lines with their doc checklists and citations, with green earned only from the readiness verdict.
### Current state (GROUNDED)
- GROUNDED: the worklist is row-keyed, not patient-keyed: one `<tr>` per record, `rowKey = r.patient_id + "-" + i` (`WorklistTable.jsx` line 209), so a patient with N devices or shipments appears N times. Filters and tab counts count records (lines 100-122).
- GROUNDED: "Clear to Ship" is currently wired to the TIMING flag: the FILTERS array maps the Clear to Ship tab to `RESUPPLY_READY` (line 38) and the green badge comes from `Badge.jsx` FLAG_CONFIG.RESUPPLY_READY (lines 71-78). The readiness verdict does not drive the badge anywhere.
- GROUNDED: the existing false-green stopgap: a `RESUPPLY_READY` row with open cascade items is re-badged `DOCS_REQUIRED` (amber "Action Needed", lines 219-223) and excluded from the Clear-to-Ship count (line 120). This is display-level patching, not the earned-green verdict; it becomes redundant once the rollup drives the UI.
- GROUNDED: no readiness rendering exists: `grep plan_type signal-ui/src/components/*.jsx` returns nothing; no component renders line_status, doc_items, rule_id, or a Plan Type Needed state.
- GROUNDED: `Badge.jsx` maps timing flags to the four locked labels plus a gray informational "No Recent Shipment" badge (lines 52-60). `StatusLegend.jsx` shows the four locked labels only.
- GROUNDED: the design does not exist yet. `readiness.py` header (lines 22-24) flags the label mapping, frontend nesting, and Plan Type Needed UX as a design task, and the phase-2 doc (Item 6 and closing note) assigns the frontend UX design as a separate brief that is not in `docs/`. Kisa decides visual questions from a rendered mockup, never from description.
- GROUNDED: deploy is manual: `vercel --prod` (webhook broken).
### Approach
1. **Design gate (blocking, before any code)**: commission product-designer for a rendered mockup (HTML rendered to PNG) covering: the patient row with rollup badge; the expanded state showing device lines (line badge, plan type, timing, order numbers per `dedup-design-2026-06-24.md` section 6); the doc checklist per line with required/satisfied and the rule citation (rule_id from P2, displayed as fine print or tooltip); the Plan Type Needed treatment (badge style plus the call to action "map the plan type", which resolves at re-import or via mapping review); whether StatusLegend gains a Plan Type Needed entry; the display of a patient with only non-gradeable lines. Kisa approves the mockup before build. Log the approval in current-state.
2. **Data switch**: consume the nested `patients` payload from P3. Keep the flat `records` path as a fallback behind a temporary flag during the transition (phase-2 migration section 4, Phase 2b: frontend switches, old path still runs).
3. **Build**:
- Patient-grouped table: one row per patient keyed by `patient_id` (stable key, never index); expand shows that patient's lines keyed by `patient_id + device_type + plan_type`.
- `Badge.jsx` gains a `LINE_STATUS_CONFIG` for the five line statuses (the four locked labels plus the approved Plan Type Needed treatment). Do not rename, reword, or add labels beyond the approved design.
- Tabs and counts: driven by rollup_status, counting patients (brief section 4: "Tabs count patients, not rows"). The Clear to Ship tab counts patients whose rollup is Clear to Ship, which by construction requires every required item satisfied on every gradeable line: this is the true green.
- Doc checklist rendered from `doc_items` (doc_type, value, required_state, satisfied, rule_id); override cycle buttons keyed `${patient_id}:${device_type}` and sending device_type (P4 contract).
- Non-gradeable lines display inside the expansion but carry no verdict badge and never affect counts.
- Remove the `DOCS_REQUIRED` stopgap only after the rollup badge is live and verified, in the same PR, so no false green ever ships in between.
- The "Outreach Worklist" header (line 130) is from the resupply-lifecycle name set and stays.
4. **Copy**: labels only from the locked sets; "resupply" wording; PHI footer line stays.
5. **Verification by driving, not eyeballing**: `cd signal-ui && pnpm dev`, upload a purpose-built fixture CSV with precomputed expected outcomes (a two-device patient, a Plan Type Needed patient, a true-green patient, a required-absent patient, a non-gradeable-only patient). Assert rendered row counts, tab counts, and badge texts against expected values (browser automation or scripted DOM checks; the signal-e2e-test skill covers the model side). Repeat against the production URL after deploy.
### Files to touch
| Path | Change |
|---|---|
| `signal-ui/src/components/WorklistTable.jsx` | major rewrite: patient rows + nested lines + verdict-driven badges/tabs |
| `signal-ui/src/components/Badge.jsx` | LINE_STATUS_CONFIG for the five line statuses |
| `signal-ui/src/components/StatusLegend.jsx` | only if the approved design adds a Plan Type Needed entry |
| `signal-ui/src/components/Sidebar.jsx`, `StatCard.jsx` | patient-level counts (verify what they read today before editing) |
| `signal-ui/src/App.jsx`, `signal-ui/src/lib/api.js` | plumb the nested patients payload |
| `signal-ui/src/components/CSVExport.jsx` | decision needed: export per line (recommended, keeps the work-queue CSV actionable) or per patient; confirm with Kisa at mockup review |
| fixture CSVs | the driven-verification fixture described above |
### Skeleton snippets (illustrative only, not applied)
```jsx
// Badge.jsx addition (labels are the locked set; Plan Type Needed styling comes
// from the approved mockup, placeholder values shown)
const LINE_STATUS_CONFIG = {
"Clear to Ship": { label: "Clear to Ship", /* green, check icon */ },
"On Track": { label: "On Track", /* teal dot */ },
"Action Needed": { label: "Action Needed", /* amber arrow */ },
"At Risk": { label: "At Risk", /* red warning */ },
"Plan Type Needed": { label: "Plan Type Needed", /* per approved design */ },
};
```
```jsx
// WorklistTable sketch: patient rows from the nested payload
{patients.map((p) => (
<Fragment key={p.patient_id}>
<PatientRow patient={p} expanded={expanded === p.patient_id} ... />
{expanded === p.patient_id && p.lines.map((line) => (
<DeviceLineRow key={`${p.patient_id}:${line.device_type}:${line.plan_type}`}
line={line} ... />
))}
</Fragment>
))}
```
### Test strategy & acceptance criteria
Driven acceptance checks against the fixture CSV (exact expected numbers precomputed in the fixture's README):
- One row per patient; a two-device patient renders once and expands to two lines.
- Tab counts equal patient counts and are internally consistent (at-risk + action-needed + on-track + clear-to-ship + plan-type-needed patients plus any uncounted non-gradeable-only patients account for all).
- A required-and-absent item never renders green anywhere (row badge, line badge, checklist item).
- The Plan Type Needed patient shows the approved treatment; the mixed patient (PTN line + Clear line) shows Action Needed at the patient level (P3 capping).
- Override cycle on device A leaves device B's line unchanged (P4 regression check at the UI level).
- The Clear to Ship tab is empty for an upload with no plan_type column mapped.
### Definition of Done
Kisa-approved mockup on record, built, all driven checks pass locally AND on the production URL after `vercel --prod`, auditor review passed, the DOCS_REQUIRED stopgap removed in the same change, ledger and current-state updated, pilot guide screenshots flagged to lead-content if the visible UI changed.
---
## Closing: cross-item constraints, gotchas, and open questions
### Cross-item constraints and gotchas
- **PHI (non-negotiable, applies to every item)**: no patient names, DOB, SSN, or contact fields in any dataclass, API model, DB migration, log line, or UI element. patient_id is the sole crosswalk key. Every log line uses a hashed patient_id (the `_hash_pid` pattern, `dedup.py` lines 40-42). The `doc_status` table stores `patient_id_hash` only; keep it that way in the P4 migration.
- **Locked label sets, never mixed**: worklist verdict labels are At Risk, Action Needed, On Track, Clear to Ship. Plan Type Needed is the locked readiness state for ungradeable lines (`LineStatus` enum, `readiness.py` lines 74-79), not a new label invention; its visual treatment is a P6 design decision. The resupply-lifecycle names (Supply Lapsed, Renewal Due, Resupply Ready, Active, Outreach Worklist) are a separate set for pilot-guide copy and headers. Never invent a new label, never move a name between sets. The legacy gray "No Recent Shipment" badge exists in `Badge.jsx`; leave it unless the approved P6 design says otherwise.
- **Locked dedup key**: `(patient_id, device_type, date_of_service)`. order_number is display-only, never in any key, filter, or dedup decision.
- **P2 coupling hazard**: `payer_rules.json` is read by BOTH engines. The wrapped citation format must land in the same commit as the updated readers in `readiness.py` AND `coverage_calculator.py`, or the live API breaks on the next deploy.
- **lru_cache hazard**: `_load_rules()` (`readiness.py` lines 99-102) caches and returns the live mutable dict. Tests must inject rules via the `rules=` parameter, never mutate the cached dict (phase-2 doc section 2B documents the incident).
- **Coexistence discipline**: nothing in P2-P6 deletes the old pipeline, `_normalize_payer`, or the `default` config entry. That is Phase 2c, explicitly Kisa-gated (phase-2 doc section 4), recommended only after 2+ weeks clean on the new path.
- **Adjacent but out of scope**: moving the synonym vocabularies (`_SWO_GOOD` etc., `readiness.py` lines 113-121) into `payer_rules.json` is phase-2 doc Item 2. It is NOT one of these five items. If executing sessions have spare capacity, it slots naturally right after P2; do not fold it into P2's commit.
- **Deploy mechanics**: backend `railway up --detach` from the repo root (fresh build; service signal-api-production-91c2). Frontend `vercel --prod`, manual, the webhook is broken. DB migrations via the Supabase CLI, the same path used for `20260607000001_audit_log_worm.sql`.
- **Process**: code-auditor reviews every item before it ships; the generator never signs off its own work. UI and model behavior are verified by driving the real app and measuring, never by eyeballing a screenshot or reading code. Update `context/current-state.md` and `docs/ship-status-ledger.md` at every item boundary.
- **Line-number drift**: all citations in this plan predate the 01 wiring. Grep the quoted anchors before editing.
### Open questions / decisions needed from Kisa
1. **PECOS "Pending" stays BAD?** (phase-2 doc Q1). Pi recommends yes: PECOS verification is binary, "Pending" is a real gap, and supplier training documents it. This plan assumes yes.
2. **Plan Type Needed capping confirmed?** (phase-2 doc Q2). A patient with one Clear to Ship line and one Plan Type Needed line shows Action Needed, not Plan Type Needed. This plan builds to that recommendation; a reversal changes `rollup.py` and its tests only.
3. **P4 definition check.** The task list glossed P4 as per-device payer-RULE config overrides; the locked design docs define it as device-keyed STAFF overrides (this plan's reading). Confirm the design-doc reading. If a per-device rule config layer is also wanted (e.g. different renewal windows per device under one plan), commission a separate design brief; do not bolt it onto P4.
4. **P6 visual decisions, by rendered mockup only**: nesting layout, Plan Type Needed badge treatment and call to action, whether StatusLegend gains a Plan Type Needed entry, and CSV export granularity (per line vs per patient).
5. **Non-gradeable-only patients**: a patient with only non-CGM lines has no verdict. Recommended: display as a gray informational row excluded from all tabs. Needs mockup approval alongside question 4.
6. **Cutover timing**: when the frontend switches to the nested payload (Phase 2b, inside P6) and when the old path is deleted (Phase 2c). No date is set; Kisa decides. Recommended gate for 2c: two weeks on the new path without regression (phase-2 doc Q4).
7. **Demo narrative change from P5**: after enforcement, the demo flow includes mapping a Plan Type column, and demo CSVs must carry it. Flag to lead-sttil/lead-content before any pilot demo is scheduled on the new build.