Signal/docs/build-plans/01-readiness-model-wiring.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

33 KiB

Build Plan 01 — Wire the Readiness Model into the Live Pipeline

Date: 2026-07-06 Author: lead-signal (Claude), grounded against the working tree on this date Status: PLAN ONLY. No production code has been modified. Execute-from-here document for a future session (possibly a less capable model). Every load-bearing claim is tagged GROUNDED (verified in a file or by running a command) or GRAFTED (assumption or recommendation). Prior design docs this builds on (read them, do not re-derive them):

  • docs/readiness-model-brief-2026-06-23.md (Phase 1 spec, locked decisions)
  • docs/readiness-model-design-phase2-2026-06-26.md (Pi's Phase 2 design, esp. section 4 migration strategy)
  • docs/dedup-design-2026-06-24.md (locked dedup key and tiebreakers)

1. Objective

Wire the already-built, already-tested readiness engines (core/dedup.py + core/readiness.py) into the live FastAPI pipeline as an ADDITIVE readiness verdict on both scoring paths (CSV batch upload and Confirm Visit recompute), changing no existing worklist behavior, per the Phase 2a coexistence strategy.


2. Current state (GROUNDED)

The engines exist, tested, and are wired to NOTHING

  • python-backend/core/dedup.py (251 lines). GROUNDED.
    • dedup(records: list[ShipmentRecord]) -> list[MergedShipment] groups rows by the LOCKED key (patient_id, device_type, shipment_date-as-date) (dedup.py:185-215).
    • assign_to_coverage_lines(merged) -> dict[CoverageLineKey, CoverageLine] groups MergedShipments by (patient_id, device_type, plan_type or "unknown"); latest DOS becomes current_shipment, earlier ones prior_shipments (dedup.py:218-250).
    • A fresh CoverageLine is intentionally ungraded: line_status=None, timing_flag=None (dedup.py:246-247). Grading is a separate step. GROUNDED.
  • python-backend/core/readiness.py (287 lines). GROUNDED.
    • evaluate_readiness(plan_type, *, csv_swo_status, csv_visit_date, confirmed_visit_date, csv_pecos_verified, csv_pa_status, csv_diagnosis_on_file, rules) -> ReadinessVerdict (readiness.py:216-255). Grades ONE line's five doc items on two axes (required_state x input_state).
    • ReadinessVerdict = {plan_type, doc_items: list[DocItem], line_status: LineStatus} (readiness.py:92-96). DocItem = {doc_type, required_state, input_state, quality, value, satisfied} (readiness.py:82-89).
    • LineStatus values are EXACTLY the locked worklist labels plus the sanctioned unknown-plan state: "Clear to Ship", "On Track", "Action Needed", "At Risk", "Plan Type Needed" (readiness.py:74-79). GROUNDED. Do not invent or rename any.
    • Required-ness is read from config/payer_rules.json (readiness.py:197-213, 234-237), and payer_rules.json carries pecos_required / pa_required for all four known plan types (payer_rules.json:32-72). GROUNDED.
    • A required-and-absent item blocks green (readiness.py:183-185, 278-279); unknown or missing plan config yields "Plan Type Needed" (readiness.py:268-274). GROUNDED.
  • Neither module is imported anywhere in python-backend/. Grep on 2026-07-06 finds imports of core.dedup / core.readiness only in tests/test_dedup.py, tests/test_readiness.py, and tests/signal_e2e_test.py. GROUNDED.

Real test status (run 2026-07-06 from repo root, python -m pytest)

Suite Result
tests/test_dedup.py 30 passed
tests/test_readiness.py 27 passed
tests/signal_e2e_test.py (Pi's black-box E2E) 35 passed
Full tests/ directory 108 passed

GROUNDED (I ran them). Note: context/current-state.md (2026-06-27 entry) says "73 tests green"; the grounded per-file counts today are the table above. The 57 engine unit tests (30 + 27) match the 2026-06-26 state entry. Treat the table as current truth; do not quote 73.

The live pipeline scores through coverage_calculator only

  • python-backend/api/main.py:25from core.coverage_calculator import ShipmentRecord, calculate_batch. GROUNDED.
  • Batch path (CSV upload -> worklist): main.py:443 POST /api/upload -> normalize_csv (main.py:457) -> calculate_batch(records, as_of, confirmed_visits) (main.py:492-494) -> one RecordOut per result via _to_record_out (main.py:502-521). GROUNDED.
  • Single-record path (Confirm Visit recompute): main.py:670 POST /api/confirm-visit -> builds one ShipmentRecord from echoed request fields (main.py:735-750) -> calculate_coverage(record, confirmed_visit_date=confirmed) (main.py:751, import at main.py:679) -> _to_record_out (main.py:766). GROUNDED.
  • calculate_batch returns list[CoverageResult], ONE PER INPUT ROW, sorted by priority (coverage_calculator.py:368-396). The worklist is row-keyed, not patient-keyed (the known bug, Phase 1 brief section 8). GROUNDED.
  • CoverageResult carries the TIMING axis only (CoverageFlag: SUPPLY_LAPSED, VISIT_REQUIRED, RENEWAL_*, RESUPPLY_READY, ACTIVE, NO_RECENT_SHIPMENT, TRANSFER_PENDING; coverage_calculator.py:42-51, 86-100). It has no doc-completeness verdict. The doc display state comes separately from compute_doc_state (main.py:321-330), which is driven by _normalize_payer_type -> the payer-name guesser (_normalize_payer, imported at main.py:257). GROUNDED.
  • The live status_label comes from FLAG_LABELS (main.py:136-146), which is a LEGACY label set ("Docs Required", "Escalate", "New Patient", "Confirm Appointment", "Begin Outreach", "Clear to Ship", "On Track", "No Recent Shipment"). It is NOT the locked four-label verdict set. This plan does not touch it; replacing it is the P6 frontend-nesting step owned by another agent. GROUNDED.

Two wiring realities discovered by reading the code (both load-bearing)

  1. Circular-import constraint. dedup.py:35 does from core.coverage_calculator import ShipmentRecord, CoverageFlag. Therefore coverage_calculator.py can never import dedup/readiness (circular). The Phase 2 doc's item 5 sketch ("add a path in coverage_calculator.py") cannot be implemented literally as written. The seam must live OUTSIDE coverage_calculator: a new orchestration module called from api/main.py. GROUNDED (import verified; conclusion follows necessarily).
  2. The normalizer never populates plan_type. ShipmentRecord has had a plan_type field since the 2026-06-23 lock (coverage_calculator.py:79-82), but normalize_csv constructs records WITHOUT it (normalizer.py:315-331) and HEADER_MAP has no plan_type aliases (normalizer.py:20-88). Header matching is exact-normalized (lowercase, -/_ -> space; normalizer.py:169-189), so a client column named "plan_type"/"Plan Type" is currently UNMAPPED and dropped. Beware the near-collision: bare "plan", "plan_name", "plan name" already alias to PAYER (normalizer.py:46-50). Consequence: on day one of wiring, every record has plan_type=None, so every readiness verdict reads "Plan Type Needed". That is doctrinally correct (never guess) but means no green is reachable end to end until plan_type enters via CSV mapping. GROUNDED. See Open Question 1.

Doctrine locks this plan must honor (from the source-of-truth docs)

  • Dedup key LOCKED: (patient_id, device_type, date_of_service); order_number is display-only, never in the key (dedup design doc section 1; implemented at dedup.py:189, tested by test_dedup_missing_order_number and test_dedup_order_numbers_collected_and_deduped). GROUNDED.
  • Verdict rules LOCKED: green only by positive evidence on every required item; required-and-absent blocks green; unknown plan reads "Plan Type Needed", never a guess (Phase 1 brief section 4; implemented and tested: test_required_absent_blocks_green, test_plan_type_none_is_plan_type_needed, test_known_plan_missing_config_does_not_false_green). GROUNDED.
  • Migration strategy LOCKED by Pi's Phase 2 design section 4: Phase 2a = both paths execute, readiness carried as an optional field, frontend ignores it until wired, no cut-over date without Kisa. This plan IS Phase 2a. GROUNDED (doc read).
  • Grade the CURRENT shipment only (latest DOS per line); prior shipments are collapsed history (Phase 1 brief section 7; dedup.py:226, 238-245). GROUNDED.

3. Prerequisites / dependencies

  1. Repo at or after the 2026-06-27 clean checkpoint; python -m pytest tests/ green (verified 108 passed on 2026-07-06).
  2. Railway CLI authenticated; backend service signal-api-production-91c2. Deploy command: railway up --detach from the project root (forces fresh build).
  3. SIGNAL_API_KEY value available for curl-based live verification (X-API-Key header path, main.py:117-118).
  4. Decision from Kisa on Open Question 1 (plan_type CSV ingestion now vs with P5). The wiring ships either way; the decision changes only whether "Clear to Ship" is reachable end to end on day one.
  5. No dependency on the Convex spike, Supabase schema changes, or the frontend. Frontend is untouched in this step.

Downstream items explicitly NOT in this plan (owned by the P2-P6 agent; reference only): rules-to-config citation IDs, synonym vocabularies to config, patient rollup, device-keyed override, plan-type enforcement + guesser removal, frontend nesting.


4. Implementation approach

Seam decision: the readiness verdict POST-PROCESSES (augments) the coverage_calculator output. It does not wrap it and does not replace it. Grounds:

  • The two engines grade DIFFERENT axes. coverage_calculator = timing urgency (CoverageFlag); readiness = documentation completeness (LineStatus). readiness.py's own scope-boundary comment says the timing layer is "combined at the worklist level" (readiness.py:139-142). Neither output is a superset of the other. GROUNDED.
  • The Phase 2 design section 4 mandates coexistence (Phase 2a): both paths execute, readiness rides as an optional field, old path still serves the worklist. GROUNDED.
  • Wrapping inside coverage_calculator is impossible without a circular import (section 2 above). GROUNDED.
  • Replacing would change output cardinality (dedup collapses rows to lines) and break the row-keyed frontend, _compute_stats (main.py:397-416), and the export echo (main.py:559-620) in one step. Cardinality-preserving augmentation keeps this step additive and reversible; the cardinality change belongs to P3 rollup + P6 frontend. GRAFTED (design judgment, consistent with the locked migration strategy).

How the augment works, concretely: dedup the batch's ShipmentRecords into CoverageLines, grade each line's CURRENT shipment with evaluate_readiness, then attach each line's verdict to EVERY row-level RecordOut belonging to that line. Row count, ordering, stats, legacy labels: all unchanged. Rows from prior shipments of a line carry the same line verdict (graded from the latest DOS), which is the locked grade-the-current-shipment rule surfacing at row level. GRAFTED (mechanism), built on GROUNDED locks.

Ordered steps

  1. Create python-backend/core/worklist_readiness.py (new module, the only new import surface). Two functions:
    • readiness_by_line(records, confirmed_visits) -> dict[tuple, ReadinessVerdict] keyed by (patient_id, device_type, plan_type_id) where plan_type_id is plan_type or "unknown" (mirrors CoverageLineKey, dedup.py:85-90). Internally: dedup() -> assign_to_coverage_lines() -> per line, evaluate_readiness() on line.current_shipment's csv_* fields, passing confirmed_visit_date looked up by sha256(patient_id) from the confirmed_visits dict (same keying as coverage_calculator.py:387-388).
    • readiness_for_record(record, confirmed_visit_date) -> ReadinessVerdict for the single-record Confirm Visit path (one record is its own line; call evaluate_readiness directly).
    • Log only hashed patient_ids (reuse the _hash_pid pattern, dedup.py:40-42).
  2. Add additive response fields in api/main.py:
    • New Pydantic model ReadinessItemOut (doc_type, required_state, input_state, quality, value, satisfied).
    • RecordOut gains plan_type: Optional[str] = None, readiness_status: Optional[str] = None, readiness_items: Optional[list[ReadinessItemOut]] = None. All default None so old clients, the export echo (ExportRequest.records: list[RecordOut], main.py:559-561), and the frontend are unaffected.
    • UploadResponse gains readiness_stats: dict | None = None (counts per LineStatus value) so live verification is measurable without the UI.
  3. Wire the batch path in upload_csv immediately after calculate_batch (main.py:492-494): compute verdicts = readiness_by_line(records, confirmed_visits); when building each RecordOut (main.py:502-521), resolve the row's original ShipmentRecord via the existing record_lookup (main.py:496-500), derive its line key, and pass the matching verdict into _to_record_out (new optional parameter). If the record lookup misses, readiness fields stay None, exactly like doc_state degrades today (main.py:317-319).
  4. Wire the single-record path in confirm_visit: add plan_type: str | None = None to ConfirmVisitRequest (main.py:649-667), pass it into the ShipmentRecord construction (main.py:735-750; the field already exists on ShipmentRecord), compute readiness_for_record(record, confirmed_visit_date=confirmed), and pass it to _to_record_out at main.py:766. The frontend already echoes RecordOut fields back on confirm-visit (RecordOut comment, main.py:192-194); adding plan_type to RecordOut in step 2 makes the round trip whole once the frontend echoes it (frontend change itself is P6; until then the field arrives None and the recompute honestly reads Plan Type Needed).
  5. (Decision-gated, Open Question 1) Minimal plan_type ingestion in api/normalizer.py: add a plan_type entry to HEADER_MAP with aliases such as "plan_type", "plan type", "plan category", "coverage type", "insurance_type", "insurance type". NEVER include bare "plan", "plan_name", or "plan name" (those are payer aliases today, normalizer.py:46-50; stealing them would silently re-map existing customer CSVs). Carry the value verbatim, lowercased and trimmed only; evaluate_readiness already normalizes and routes anything outside the four known values to Plan Type Needed (readiness.py:231-232, 47-49). This is client-supplied mapping, not derivation from the payer name, so it does not violate the never-guess lock. GRAFTED (recommendation; Kisa gates).
  6. Tests (new file tests/test_worklist_readiness.py + API-level tests): see section 8. Run the FULL suite; all 108 existing tests must still pass.
  7. Independent review: hand the diff to code-auditor before ship (generator and reviewer are never the same agent). Then run the signal-e2e-test skill so the readiness verification is not a closed loop.
  8. Deploy and live-verify per section 9. Then update docs/ship-status-ledger.md and context/current-state.md (state-sync) and commit. Do not update the pilot-readiness checklist unless an item genuinely flips; count PASS items before quoting any percentage.

5. Files to touch

File Change
python-backend/core/worklist_readiness.py NEW. Orchestration module: readiness_by_line, readiness_for_record. Imports dedup + readiness. Nothing imports it except api/main.py and tests.
python-backend/api/main.py Add ReadinessItemOut; extend RecordOut (+3 optional fields) and UploadResponse (+1 optional field); extend ConfirmVisitRequest (+optional plan_type); new optional readiness param on _to_record_out; call readiness_by_line in upload_csv after line 492; call readiness_for_record in confirm_visit before line 766; small _compute_readiness_stats helper.
python-backend/api/normalizer.py ONLY if Open Question 1 is approved: plan_type aliases in HEADER_MAP + pass-through into ShipmentRecord construction (normalizer.py:315-331) + it appears in mapping_summary automatically via the existing header loop.
tests/test_worklist_readiness.py NEW. Unit tests for the orchestration module + API-shape tests (section 8).
docs/ship-status-ledger.md Append the ship entry after live verification.
context/current-state.md Update ACTIVE/NEXT + one author-stamped decision via state-sync.

Files explicitly NOT touched: core/coverage_calculator.py (circular-import constraint plus additive-only discipline), core/dedup.py, core/readiness.py (already audited; do not modify while wiring), core/doc_state_machine.py, config/payer_rules.json, anything in signal-ui/ (P6), persistence/Supabase schema (verdicts are recomputed per upload, not persisted, this step).


6. Integration points

A. Batch path, api/main.py:492 (POST /api/upload)

Today:

records (normalize_csv) ──> calculate_batch ──> CoverageResult per row ──> RecordOut per row

After wiring:

records ──┬─> calculate_batch ──────────────> CoverageResult per row ──┐
          └─> readiness_by_line (dedup ──> lines ──> evaluate_readiness)┴─> RecordOut per row
                                                                            + readiness_status
                                                                            + readiness_items
                                                                            + plan_type
  • Inputs already in scope at the call-site: records (main.py:457), confirmed_visits (main.py:486, dict of sha256(patient_id) -> date).
  • Row-to-line resolution: the existing record_lookup (main.py:496-500) maps a CoverageResult back to its ShipmentRecord; the line key is (record.patient_id, record.device_type, record.plan_type or "unknown").
  • Legacy outputs (flag, status_label, priority_score, sort order, stats, row count) are byte-identical to before. The verdict rides alongside.

B. Single-record path, api/main.py:751 (POST /api/confirm-visit)

  • calculate_coverage(record, confirmed_visit_date=confirmed) stays exactly as is (timing axis).
  • Add verdict = readiness_for_record(record, confirmed_visit_date=confirmed) and pass it to _to_record_out(result, record=record, confirmed_visit_date=confirmed, readiness=verdict) at main.py:766.
  • evaluate_readiness counts a confirmed visit as positive evidence (readiness.py:135-144), so confirming a visit flips the visit item to satisfied in the same response, no second upload needed.

C. What plugs into what (function-level contract)

  • evaluate_readiness consumes exactly the fields MergedShipment carries (csv_swo_status, csv_visit_date, csv_pecos_verified, csv_pa_status, csv_diagnosis_on_file, plan_type) plus confirmed_visit_date. Verified field-by-field against dedup.py:64-83 and readiness.py:216-226. GROUNDED.
  • CoverageLine.plan_type == "unknown" feeds straight into evaluate_readiness, which routes it to Plan Type Needed because "unknown" is not in KNOWN_PLAN_TYPES (readiness.py:47-49, 231-232; tested by test_plan_type_unknown_string_is_plan_type_needed). GROUNDED.

7. Skeleton snippets (illustrative only, do NOT paste blindly; re-verify line numbers at execution time)

core/worklist_readiness.py (new)

"""Orchestrates dedup + readiness for the live pipeline (Phase 2a coexistence).
PHI CONTRACT: patient_id only; hash it in any log line."""
import hashlib
from datetime import date
from typing import Optional

from core.coverage_calculator import ShipmentRecord
from core.dedup import dedup, assign_to_coverage_lines, UNKNOWN_PLAN_TYPE
from core.readiness import evaluate_readiness, ReadinessVerdict

LineKey = tuple[str, str, str]  # (patient_id, device_type, plan_type_id)


def _confirmed_for(patient_id: str, confirmed_visits: dict[str, date]) -> Optional[date]:
    return confirmed_visits.get(hashlib.sha256(patient_id.encode()).hexdigest())


def readiness_for_record(record: ShipmentRecord,
                         confirmed_visit_date: Optional[date] = None) -> ReadinessVerdict:
    return evaluate_readiness(
        record.plan_type,
        csv_swo_status=record.csv_swo_status,
        csv_visit_date=record.csv_visit_date,
        confirmed_visit_date=confirmed_visit_date,
        csv_pecos_verified=record.csv_pecos_verified,
        csv_pa_status=record.csv_pa_status,
        csv_diagnosis_on_file=record.csv_diagnosis_on_file,
    )


def readiness_by_line(records: list[ShipmentRecord],
                      confirmed_visits: Optional[dict[str, date]] = None,
                      ) -> dict[LineKey, ReadinessVerdict]:
    confirmed_visits = confirmed_visits or {}
    lines = assign_to_coverage_lines(dedup(records))
    verdicts: dict[LineKey, ReadinessVerdict] = {}
    for key, line in lines.items():
        cur = line.current_shipment          # grade the CURRENT shipment only (locked)
        verdicts[(key.patient_id, key.device_type, key.plan_type_id)] = evaluate_readiness(
            line.plan_type if line.plan_type != UNKNOWN_PLAN_TYPE else None,
            csv_swo_status=cur.csv_swo_status,
            csv_visit_date=cur.csv_visit_date,
            confirmed_visit_date=_confirmed_for(key.patient_id, confirmed_visits),
            csv_pecos_verified=cur.csv_pecos_verified,
            csv_pa_status=cur.csv_pa_status,
            csv_diagnosis_on_file=cur.csv_diagnosis_on_file,
        )
    return verdicts

api/main.py additions (sketch)

class ReadinessItemOut(BaseModel):
    doc_type: str
    required_state: str      # REQUIRED | NOT_REQUIRED | NOT_EVALUATED
    input_state: str         # SUPPLIED | ABSENT
    quality: str             # GOOD | PENDING | BAD | NONE
    value: Optional[str] = None
    satisfied: bool

# RecordOut gains (all additive, default None):
#   plan_type: Optional[str] = None
#   readiness_status: Optional[str] = None      # one of the 5 LineStatus values, verbatim
#   readiness_items: Optional[list[ReadinessItemOut]] = None
# UploadResponse gains:
#   readiness_stats: dict | None = None
# upload_csv, right after main.py:492-494:
from core.worklist_readiness import readiness_by_line
verdicts = readiness_by_line(records, confirmed_visits)

# inside the RecordOut build loop: resolve rec = record_lookup.get(...) as today, then
line_verdict = None
if rec is not None:
    line_verdict = verdicts.get(
        (rec.patient_id, rec.device_type, rec.plan_type or "unknown"))
# pass readiness=line_verdict into _to_record_out; inside _to_record_out:
#   readiness_status=verdict.line_status.value if verdict else None,
#   plan_type=(getattr(record, "plan_type", None) if record else None),
#   readiness_items=[ReadinessItemOut(..., required_state=i.required_state.value, ...)
#                    for i in verdict.doc_items] if verdict else None,
# confirm_visit, before main.py:766:
from core.worklist_readiness import readiness_for_record
verdict = readiness_for_record(record, confirmed_visit_date=confirmed)
return _to_record_out(result, record=record, confirmed_visit_date=confirmed,
                      readiness=verdict)
# and ConfirmVisitRequest gains: plan_type: str | None = None
# passed into the ShipmentRecord(... plan_type=body.plan_type) construction.

8. Test strategy & acceptance criteria

New file tests/test_worklist_readiness.py. The engine-level behaviors are already covered (57 unit tests + 35 E2E); the new tests target the WIRING, i.e. the seams this plan adds. GROUNDED engine coverage, GRAFTED wiring tests to write:

  1. False green is blocked at the API layer (the headline acceptance case). One record, plan_type="medicare", SWO "On File", csv_visit_date present, diagnosis "Yes", PECOS absent (None), shipment recent enough that the legacy flag reads RESUPPLY_READY (legacy status_label "Clear to Ship" per FLAG_LABELS, main.py:143). Assert: readiness_status == "Action Needed" on the same RecordOut. This is the exact false-green the model exists to kill, visible in one response: legacy label green-ish, readiness verdict honest. (Engine-level twin: test_required_absent_blocks_green, tests/test_readiness.py:67.)
  2. Plan Type Needed. Same record with plan_type=None (today's default for every normalized record). Assert readiness_status == "Plan Type Needed" and PECOS/PA items are NOT_EVALUATED while universal items still grade. (Engine twins: readiness tests at lines 128-146.)
  3. Clear to Ship is reachable. plan_type="medicare", all five items with positive evidence (PA not required for Medicare FFS per payer_rules.json:36). Assert readiness_status == "Clear to Ship". If Open Question 1 is approved, also prove it END TO END through normalize_csv with a plan_type column; if not approved, prove it at the readiness_by_line level with constructed records and note the end-to-end gap explicitly.
  4. Dedup wiring. Two CSV rows, same (patient, device, DOS), different order_numbers -> one line verdict; both output rows (if both survive to output) carry the identical verdict. Two rows same patient+device with DIFFERENT DOS (a resupply history) -> still ONE line; assert the verdict is graded from the LATEST DOS row's csv fields (change a doc field between the two rows to prove which one drove the verdict).
  5. Confirm Visit recompute. Record with plan_type medicare, everything good except no visit evidence -> upload path gives "Action Needed"; POST /api/confirm-visit with a valid confirmed date -> response readiness_status == "Clear to Ship" and the visit item satisfied.
  6. Back-compat invariants. For a fixture CSV, the wired response equals the pre-wiring response on every legacy field: same row count, same order, same flag/status_label/priority_score/stats. Readiness fields are the only additions. Also: readiness_stats totals equal the number of distinct graded lines' rows accounted for (define precisely in the test).
  7. PHI check. Assert no raw patient_id appears in log output captured from readiness_by_line (caplog); hashed only.
  8. Full suite. python -m pytest tests/ must show 108 + new tests passed, zero failures, before deploy.

Acceptance criteria (all must hold): cases 1 and 2 pass exactly as stated (these prove the false-green kill and the never-guess doctrine in the live pipeline); case 6 proves the step was truly additive; full suite green; code-auditor sign-off recorded.


9. Live-verify steps (drive the real app, measure, never eyeball)

Backend-only change, so verification is API-level against the live Railway service. The UI renders nothing new by design (Phase 2a: frontend ignores the new fields).

  1. Deploy: railway up --detach from /Users/sttil-solutions/projects/signal. Watch build logs (railway logs) until healthy.
  2. curl -s https://signal-api-production-91c2.up.railway.app/health -> status: ok.
  3. Upload a synthetic fixture CSV (use the signal-e2e-test skill's generator; NEVER real data): curl -s -X POST .../api/upload -H "X-API-Key: $SIGNAL_API_KEY" -F "file=@fixture.csv". Measure, do not skim:
    • every record has a readiness_status key;
    • with no plan_type column: 100% of graded records read "Plan Type Needed" (count them with jq, compare to total);
    • readiness_stats present and its counts sum correctly.
  4. If plan_type ingestion shipped (Open Question 1): upload the fixture WITH a plan_type column containing one engineered Clear to Ship row (medicare, all evidence positive) and one engineered false-green row (medicare, PECOS blank, timing green). Assert with jq: the first reads "Clear to Ship", the second reads "Action Needed" while its legacy status_label still shows the legacy green label. This is the false-green kill, observed live.
  5. Confirm Visit round trip: POST /api/confirm-visit for the Action Needed patient's visit case (test 5 shape) and assert the returned record's readiness_status flips as specified.
  6. Regression: re-run the exact pre-wiring fixture and diff the response against a saved pre-deploy response, ignoring only the new keys. Zero legacy diffs allowed.
  7. railway logs spot check: any new log lines show hashed patient_ids only.
  8. Record results (counts, not adjectives) in docs/ship-status-ledger.md.

10. Constraints & gotchas

  • PHI (non-negotiable): patient_id is the sole crosswalk key. DocItem.value carries only client status strings or ISO dates, never identity fields. Never add name/DOB/SSN/contact anywhere, including test fixtures. Hash patient_id in every new log line (_hash_pid pattern, dedup.py:40-42).
  • Locked labels: readiness_status emits the LineStatus values verbatim: At Risk, Action Needed, On Track, Clear to Ship, plus Plan Type Needed for unknown plans. Never invent a sixth, never rename, and never mix in the resupply-lifecycle set (Supply Lapsed, Renewal Due, Resupply Ready, Active, Outreach Worklist), which is a separate locked vocabulary for the pilot guide and copy. The legacy FLAG_LABELS map (main.py:136-146) stays untouched until P6.
  • Locked dedup key: (patient_id, device_type, date_of_service). order_number is display-only. Do not "improve" the key while wiring.
  • Circular import: coverage_calculator.py must never import dedup/readiness/worklist_readiness (dedup.py:35 imports FROM it). The new module is only imported by api/main.py and tests.
  • _load_rules cache hazard: @lru_cache returns the live mutable dict (readiness.py:99-102). Tests must never mutate it (documented hazard, Phase 2 design section 2B).
  • Normalizer alias collision: "plan", "plan_name", "plan name" mean PAYER today (normalizer.py:46-50). plan_type aliases must not include them.
  • Language: "resupply", never "refill", in any new copy, field names, or test names. (refill_window_days in payer_rules.json is an existing code key; leave it, renaming config keys is not this step.)
  • Prior-shipment rows carry the current shipment's verdict. A six-month history upload shows N legacy rows for one line, all with the same line verdict graded from the latest DOS. By design (grade the current shipment only). Expect it; do not "fix" it here; the row collapse is P3/P6.
  • Verdicts are not persisted. persist_upload (main.py:538-546) stores shipment records and coverage results only. Readiness is recomputed per request this phase. Persisting verdicts is a schema decision that goes through lead-sttil's data handling check first.
  • Deploy mechanics: backend railway up --detach (fresh build; railway redeploy only reuses the old image and will NOT pick up code changes). Frontend is untouched this step; if it ever needs a deploy, Vercel is manual (vercel --prod, the GitHub webhook is broken).
  • Auth on verify: use the X-API-Key header path; Clerk JWT is the frontend path.

11. Definition of Done

All of the following, in order, with evidence recorded:

  1. core/worklist_readiness.py exists; imports of dedup/readiness in python-backend/ are exactly: worklist_readiness (plus tests).
  2. Both call-sites wired: /api/upload (batch, main.py:492 region) and /api/confirm-visit (single, main.py:751 region) return readiness fields.
  3. Full test suite green (108 existing + new wiring tests), run and count recorded, including acceptance cases 1 (false green blocked) and 2 (Plan Type Needed).
  4. Back-compat invariant proven (section 8 case 6): legacy response fields byte-identical for a fixture CSV.
  5. code-auditor review passed (independent agent, findings resolved or accepted).
  6. signal-e2e-test skill run against the wired pipeline, results recorded.
  7. Deployed via railway up --detach; all live-verify steps in section 9 pass with measured counts, not impressions.
  8. docs/ship-status-ledger.md entry appended; context/current-state.md updated via state-sync with one author-stamped decision line.
  9. No production change outside the files listed in section 5; no PHI fields added; no label invented; dedup key untouched.

12. Open questions / decisions needed from Kisa

  1. Plan-type CSV ingestion now, or wait for P5? (The single biggest call.) Without it, every live verdict reads "Plan Type Needed" on day one: honest and doctrine-correct, but no green is reachable end to end and the new field looks inert. My recommendation: include the minimal verbatim column mapping (section 4 step 5) in this wiring. It is a client-supplied mapped field, not a guess, so it honors the lock; the full enforcement, guesser removal, and mapping-review UX stay with P5's owner. GRAFTED recommendation; Kisa gates.
  2. Confirm the seam: readiness AUGMENTS the coverage result (Phase 2a coexistence), it does not replace the worklist scoring yet. This follows Pi's locked migration strategy, but the wiring makes it real, so I want it said out loud and confirmed.
  3. readiness_stats in the upload response now? Cheap, additive, makes live verification measurable. Recommendation: yes. GRAFTED.
  4. Cut-over timing (Phase 2b/2c: frontend switches, old path retired) remains entirely Kisa's decision per the Phase 2 design, section 4. Nothing in this plan pre-commits it.
  5. State-file correction: current-state.md says "73 tests green" (2026-06-27 entry); today's grounded counts are 30 + 27 unit, 35 E2E, 108 total. Approve recording the corrected counts at the next state-sync so no one re-quotes 73.