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>
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)
- Run the test baseline first:
python3 -m pytest tests/ -qfrom the repo root (/Users/sttil-solutions/projects/signal). Tests self-insertpython-backendontosys.path(GROUNDED:tests/test_readiness.pylines 22-24). Baseline function counts at checkpoint: 30 intests/test_dedup.py, 27 intests/test_readiness.py, 35 intests/signal_e2e_test.py(GROUNDED:grep -c "def test"). - 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).
- 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.mdanddocs/ship-status-ledger.md. Then the next item. - 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. - Say "resupply", never "refill", in all user-facing copy. The JSON key
refill_window_daysis 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_idfrom 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.jsonstores 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 adefaultentry (lines 65-71) and adeviceswear-day section (lines 3-31). No rule IDs anywhere in the file. - GROUNDED:
core/readiness.py_required_from_config()(lines 197-213) readsplan_cfg[flag_key]as a flat boolean and returns only aRequiredState.DocItem(lines 82-90) has norule_idfield. A verdict cannot cite which rule fired. - GROUNDED:
core/coverage_calculator.pyreads the SAME file flat:payer_config.get("visit_renewal_days")(line 331),refill_window_days(line 332), and_get_wear_days()reads thedevicessection (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 atvisit_date + timedelta(days=...)). - GROUNDED: the design is already specified:
docs/readiness-model-design-phase2-2026-06-26.mdsection 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_idtoDocItempropagates to the frontend without a separate serialization step. Verify; if not, addrule_idto whatever DocItem serializer 01 created.
Approach
- Create
python-backend/core/rules.pywith two helpers that accept BOTH formats (flat scalar and wrapped dict):rule_value(cfg, key, default=None)andrule_id_for(cfg, key). Include the registry constants for the universal items. - Update
core/coverage_calculator.pyto readvisit_renewal_daysandrefill_window_daysthroughrule_value()(lines 331-332 today; greppayer_config.get). Leave_get_wear_daysand thedevicessection untouched this pass (wear-day rules get IDs in a later phase if needed; do not expand scope). - Update
core/readiness.py:DocItemgainsrule_id: Optional[str] = None._required_from_config()returns(RequiredState, Optional[str]), the rule_id coming from config when wrapped, or the registry default (R003for pa_required,R004for pecos_required) when flat._make_item()accepts and storesrule_id.- The three universal items get constants: swo R100, visit R101, diagnosis R102.
- When
required_stateisNOT_EVALUATED(plan unknown or config gap),rule_idstaysNone: no rule fired, so nothing is cited. Document this in the docstring.
- 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 includingdefault, 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_registryinformational block listing all seven IDs with one-line descriptions, and update the_commentdate. - Confirm
rule_idflows into the serialized doc_items payload (contract W2). The target shape is the phase-2 doc section 5 example: each doc_item carriesrule_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: runevaluate_readinesswith a flat rules dict and a wrapped rules dict (injected via the existingrules=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 withrule_id is None.test_wrapped_config_drives_timing_rules(intests/test_coverage_flags.py): with the wrapped JSON on disk,calculate_coveragestill computesnext_visit_due_datecorrectly.
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.pyassign_to_coverage_lines()(lines 218-250) returnsdict[CoverageLineKey, CoverageLine]keyed by(patient_id, device_type, plan_type_id), withgradeable=is_cgm_device(...)and unknown plan keyed as"unknown". - GROUNDED:
/api/uploadreturns a flatrecords: list[RecordOut](api/main.pyUploadResponselines 204-211), one entry per scored record; the frontend renders one table row per record and counts records, not patients (WorklistTable.jsxlines 100-122, rowKey =patient_id + "-" + iat line 209). - GROUNDED: the rollup rules are locked in
readiness-model-brief-2026-06-23.mdsection 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
- Create
python-backend/core/rollup.pywith thePatientRollupdataclass (field list from phase-2 doc Item 3) and two functions:rollup_patient(lines: list[CoverageLine]) -> PatientRollupandrollup_patients(lines: dict[CoverageLineKey, CoverageLine]) -> dict[str, PatientRollup]keyed by patient_id. - 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) |
- Lines whose
line_statusis stillNone(not yet graded, e.g. non-gradeable) never participate in the light. - API: add a nested
patients: list[PatientOut]to the upload response alongside the existing flatrecords(which stays untouched until P6 switches the UI, per the phase-2 migration strategy section 4).PatientOutshape 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). - Stats: add patient-level counts (
patients_totalplus one count per rollup status) next to the existing record-level stats; do not remove the old stats yet. - Logging: any log line touching a patient uses a hashed patient_id. Reuse the
_hash_pidpattern fromdedup.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_nestedandtest_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.pyupsert_doc_status()upsertsdoc_statuswithon_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) returnspatient_hash -> {doc_type: {...}}, also device-blind. - GROUNDED:
api/main.pyDocStatusRequest(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 oldcalculate_coverage(line 751) and returns a single flat record. No line re-grade, no patient re-rollup. - GROUNDED: frontend mirrors the leak:
WorklistTable.jsxkeyslocalDocStatesby bare patient_id (line 63, updates at lines 350-355 and 361-366) andupdateDocStatus(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), theOverridedataclass, andapply_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/holds20260607000001_audit_log_worm.sql, applied via the Supabase CLI (perCLAUDE.mdcompliance table).
Approach
- 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. UsingNOT NULL DEFAULT ''(not NULL) keeps the unique constraint honest, since Postgres treats NULLs as always-distinct in unique constraints. - Table stores
patient_id_hashonly, never raw patient_id (already true, keep it that way).
- Persistence:
upsert_doc_status(..., device_type: str)includes device_type in the payload and theon_conflictlist.load_doc_statuses_for_org()returnspatient_hash -> device_type -> doc_type -> {...}with the''bucket for legacy rows. - New
python-backend/core/overrides.py:OverrideKey,Override(fields per phase-2 Item 4 skeleton), andapply_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 withevaluate_readinessand return the set of touched patient_ids. - 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), callrollup_patient()(P3) on that patient's lines, return both in the API response. - API:
DocStatusRequestgainsdevice_type: str(required);PUT /api/doc-statusresponds with the recomputed{line, rollup}.POST /api/confirm-visitresponse 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. - Frontend minimal fix only (the full nesting is P6): key
localDocStatesby`${patient_id}:${device_type}`and passdevice_typethroughupdateDocStatusinsignal-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_linestest_override_swo_applies_only_to_one_devicetest_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.pynormalizes but never guesses plan_type (lines 231-232), routes unknown plans toPLAN_TYPE_NEEDED(lines 268-269), and fails safe toPLAN_TYPE_NEEDEDwhen a known plan's config is missing (lines 272-274).core/dedup.pycarries 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 thedefaultconfig inpayer_rules.json(lines 65-71), andapi/main.py_normalize_payer_type()(lines 255-263) feeds the guesser's output intocompute_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.pyFIELD_ALIASES (lines 19-88) has NOplan_typecanonical field; the headers "plan", "plan_name" alias topayer(lines 46-50).ShipmentRecord.plan_typedefaults to None (coverage_calculator.pyline 82). - GROUNDED: the import UI has no plan type either.
CSVImport.jsxFIELD_LABELS (lines 6-19) and OVERRIDE_OPTIONS (lines 22-29) lack plan_type;grep plan_type signal-ui/src/components/*.jsxreturns 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_payerplus thedefaultconfig 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
- Verify contract W4. If the 01 wiring already added plan_type mapping, skip step 1 and reconcile alias lists.
- Normalizer: add a
plan_typecanonical 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 medicaremap tomedicaremedicare advantage,ma,medicare part c,part c,medicare_advantagemap tomedicare_advantagemedicaid,mcd,state medicaidmap tomedicaidcommercial,private,employer,groupmap tocommercial- 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.
- Enforcement: guarantee no call site ever passes
_normalize_payer()output intoevaluate_readinessor into the CoverageLine plan_type. The readiness verdict receives only the client-mapped field. Lock this with a test, then grep the wiredapi/main.pyfor any residual_normalize_payer_typefeeding readiness data and cut that edge (the oldcompute_doc_statedisplay path may keep it until Phase 2b/2c per the migration strategy). - Deprecation fence: whenever the old timing path falls back to
_normalize_payerbecause 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." - Guard the default config:
readiness.pyalready cannot reach thedefaultentry (line 235 looks up only known plan types). Addtest_default_config_never_reaches_readinessto lock that in permanently. Do not delete thedefaultentry (Phase 2c, Kisa-gated). - Import UI:
CSVImport.jsxFIELD_LABELS gainsplan_type: "Plan Type"; OVERRIDE_OPTIONS gains{ value: "plan_type", label: "Plan Type" }. The existing mapping review then surfaces it like any other detected column. - 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_presentandtest_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.pyor 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.jsxline 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 fromBadge.jsxFLAG_CONFIG.RESUPPLY_READY (lines 71-78). The readiness verdict does not drive the badge anywhere. - GROUNDED: the existing false-green stopgap: a
RESUPPLY_READYrow with open cascade items is re-badgedDOCS_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/*.jsxreturns nothing; no component renders line_status, doc_items, rule_id, or a Plan Type Needed state. - GROUNDED:
Badge.jsxmaps timing flags to the four locked labels plus a gray informational "No Recent Shipment" badge (lines 52-60).StatusLegend.jsxshows the four locked labels only. - GROUNDED: the design does not exist yet.
readiness.pyheader (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 indocs/. Kisa decides visual questions from a rendered mockup, never from description. - GROUNDED: deploy is manual:
vercel --prod(webhook broken).
Approach
- 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.mdsection 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. - Data switch: consume the nested
patientspayload from P3. Keep the flatrecordspath as a fallback behind a temporary flag during the transition (phase-2 migration section 4, Phase 2b: frontend switches, old path still runs). - Build:
- Patient-grouped table: one row per patient keyed by
patient_id(stable key, never index); expand shows that patient's lines keyed bypatient_id + device_type + plan_type. Badge.jsxgains aLINE_STATUS_CONFIGfor 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_REQUIREDstopgap 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.
- Patient-grouped table: one row per patient keyed by
- Copy: labels only from the locked sets; "resupply" wording; PHI footer line stays.
- 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_pidpattern,dedup.pylines 40-42). Thedoc_statustable storespatient_id_hashonly; 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 (
LineStatusenum,readiness.pylines 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 inBadge.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.jsonis read by BOTH engines. The wrapped citation format must land in the same commit as the updated readers inreadiness.pyANDcoverage_calculator.py, or the live API breaks on the next deploy. - lru_cache hazard:
_load_rules()(readiness.pylines 99-102) caches and returns the live mutable dict. Tests must inject rules via therules=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 thedefaultconfig 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_GOODetc.,readiness.pylines 113-121) intopayer_rules.jsonis 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 --detachfrom the repo root (fresh build; service signal-api-production-91c2). Frontendvercel --prod, manual, the webhook is broken. DB migrations via the Supabase CLI, the same path used for20260607000001_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.mdanddocs/ship-status-ledger.mdat 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
- 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.
- 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.pyand its tests only. - 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.
- 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).
- 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.
- 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).
- 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.