Signal/docs/build-plans/02-readiness-model-completion.md
Kisa da6c242b07 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>
2026-07-06 05:08:46 -04:00

46 KiB

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)

# 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)
"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"]
}
# 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)
  1. Lines whose line_status is still None (not yet graded, e.g. non-gradeable) never participate in the light.
  2. 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).
  3. 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.
  4. 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)

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

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

  1. Verify contract W4. If the 01 wiring already added plan_type mapping, skip step 1 and reconcile alias lists.
  2. 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.
  3. 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).
  4. 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."
  5. 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).
  6. 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.
  7. 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)

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

// 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 */ },
};
// 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.