# 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.
