# Signal Demo MVP — Core Build Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build Confirm Visit workflow, 90/60/45 priority system, full documentation state machine, CSV cycle reset diff, transferred patient detection, and polished worklist UI for the Robert Robinson (Gaboro) demo on June 10, 2026.

**Architecture:** Extend the existing FastAPI backend (Railway) with new status flags, a doc state machine, confirmed visit persistence in Supabase, and a Confirm Visit API endpoint. Extend the React/Vite frontend (Vercel) with expandable rows, a 5-dot doc status bar, and the Confirm Visit modal that updates live without a page refresh.

**Tech Stack:** Python 3.11 / FastAPI (Railway), Supabase (PostgreSQL + RLS), React 18 / Vite / Tailwind CSS (pnpm), Clerk auth (JWT v2)

**Key URLs:**
- Backend (live): https://signal-api-production-91c2.up.railway.app
- Frontend (local dev): http://localhost:5173 — run `cd signal-ui && pnpm dev`
- Frontend (Vercel): https://signal-ui-xi.vercel.app
- Deploy backend: `railway up --detach` from project root (forces fresh build)
- Run tests: `cd /path/to/signal && python -m pytest tests/ -v`

**PHI Contract (non-negotiable):** All backend code may only touch: patient_id, device_type, shipment_date, quantity, payer, component, and doc status fields (swo_status, pa_status, etc.). No patient names, SSNs, DOBs, addresses, or contact info. patient_id is hashed before storage.

**Clerk org for Gaboro:** org_3EPAEcAw06V2yGMSkxE3UjqIA3c (already in CLAUDE.md + provisioned in Supabase via DEMO_ORG_SLUG="gaboro-pilot")

---

## File Map

**Create:**
- `python-backend/core/doc_state_machine.py` — DocState dataclass, payer-dependent doc requirements, cascade logic
- `python-backend/db/migrations/001_add_confirmed_visits.sql` — Supabase migration for confirmed_visits table
- `signal-ui/src/components/DocStatusBar.jsx` — 5 colored dots, one per doc item
- `signal-ui/src/components/ConfirmVisitModal.jsx` — date input modal with validation
- `signal-ui/src/components/CascadeAlert.jsx` — cascade consequence chain display
- `tests/test_doc_state_machine.py`
- `tests/test_coverage_flags.py`

**Modify:**
- `python-backend/core/coverage_calculator.py` — new CoverageFlag enum, 90/60/45 visit tier logic, visit date priority chain, updated ShipmentRecord with optional doc fields
- `python-backend/api/main.py` — updated RecordOut (doc_state, visit_confidence, cascade, is_transfer), new confirm-visit endpoint, updated FLAG_LABELS
- `python-backend/api/normalizer.py` — add doc columns + transfer columns to HEADER_MAP, update ShipmentRecord construction
- `python-backend/core/persistence.py` — add get_confirmed_visit(), upsert_confirmed_visit(), load_confirmed_visits_for_org()
- `python-backend/config/payer_rules.json` — add medicare_advantage section, pa_required/pecos_required flags
- `python-backend/db/schema.sql` — add confirmed_visits table definition
- `signal-ui/src/components/WorklistTable.jsx` — expandable rows, DocStatusBar, Confirm Visit button, cascade display
- `signal-ui/src/components/Badge.jsx` — new status labels + visual weight (bold/normal per tier)
- `signal-ui/src/lib/api.js` — add confirmVisit() function

---

## Task 1: Extend ShipmentRecord with optional doc fields

**Files:**
- Modify: `python-backend/core/coverage_calculator.py:42-55`

Read `python-backend/core/coverage_calculator.py` before editing.

- [ ] **Step 1: Write the failing test**

Create `tests/test_coverage_flags.py`:
```python
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))

from datetime import date, timedelta
from core.coverage_calculator import ShipmentRecord, calculate_coverage, CoverageFlag

TODAY = date.today()

def make_record(**kwargs):
    defaults = dict(
        patient_id="PT001",
        device_type="dexcom_g7",
        shipment_date=TODAY - timedelta(days=10),
        quantity=1,
        payer="medicare",
        component="sensor",
    )
    defaults.update(kwargs)
    return ShipmentRecord(**defaults)

def test_shipment_record_accepts_csv_visit_date():
    r = make_record(csv_visit_date=TODAY - timedelta(days=30))
    assert r.csv_visit_date == TODAY - timedelta(days=30)

def test_shipment_record_accepts_doc_fields():
    r = make_record(
        csv_swo_status="On File",
        csv_pecos_verified="Yes",
        csv_pa_status="Not Required",
        csv_diagnosis_on_file="Yes",
    )
    assert r.csv_swo_status == "On File"
    assert r.csv_pecos_verified == "Yes"

def test_shipment_record_doc_fields_default_none():
    r = make_record()
    assert r.csv_visit_date is None
    assert r.csv_swo_status is None
    assert r.csv_transfer_from is None
```

- [ ] **Step 2: Run test — verify it fails**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py -v
```
Expected: FAIL — ShipmentRecord has no field `csv_visit_date`

- [ ] **Step 3: Add optional doc fields to ShipmentRecord**

Replace the existing ShipmentRecord dataclass (lines ~42-55) with:

```python
from typing import Optional  # add to imports if not already present

@dataclass(frozen=True)
class ShipmentRecord:
    """
    Minimal shipment record. Only non-PHI fields allowed.

    patient_id: Supplier's internal MRN or account number.
                This is the sole crosswalk key — no real identity data here.
    csv_* fields: Optional doc status columns parsed from the CSV.
    csv_transfer_from: Non-empty string means this is a transferred patient.
    """
    patient_id: str
    device_type: str
    shipment_date: date
    quantity: int
    payer: str
    component: str = "sensor"
    # Optional doc fields — populated when CSV contains these columns
    csv_visit_date: Optional[date] = None
    csv_swo_status: Optional[str] = None      # "On File" | "Pending" | "Expired"
    csv_pecos_verified: Optional[str] = None  # "Yes" | "No"
    csv_pa_status: Optional[str] = None       # "Approved" | "Pending" | "Denied" | "Not Required"
    csv_diagnosis_on_file: Optional[str] = None  # "Yes" | "No"
    csv_transfer_from: Optional[str] = None   # prior supplier name if transfer
```

- [ ] **Step 4: Run test — verify it passes**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py::test_shipment_record_accepts_csv_visit_date tests/test_coverage_flags.py::test_shipment_record_doc_fields_default_none -v
```
Expected: PASS

- [ ] **Step 5: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add python-backend/core/coverage_calculator.py tests/test_coverage_flags.py
git commit -m "feat: extend ShipmentRecord with optional doc state fields from CSV"
```

---

## Task 2: New CoverageFlag enum + 90/60/45 priority system

**Files:**
- Modify: `python-backend/core/coverage_calculator.py`

- [ ] **Step 1: Write failing tests**

Add to `tests/test_coverage_flags.py`:
```python
def test_supply_lapsed_flag_when_coverage_ended():
    # Shipment 200 days ago, 1 sensor (10 wear days) — coverage ended long ago
    r = make_record(shipment_date=TODAY - timedelta(days=200), quantity=1)
    result = calculate_coverage(r)
    assert result.flag == CoverageFlag.SUPPLY_LAPSED

def test_renewal_critical_at_45_days():
    # Last confirmed visit was 135 days ago — next visit due in 45 days
    r = make_record(
        shipment_date=TODAY - timedelta(days=10),
        quantity=1,
        csv_visit_date=TODAY - timedelta(days=135),
    )
    result = calculate_coverage(r)
    assert result.flag == CoverageFlag.RENEWAL_CRITICAL

def test_renewal_elevated_at_60_days():
    r = make_record(
        shipment_date=TODAY - timedelta(days=10),
        quantity=1,
        csv_visit_date=TODAY - timedelta(days=120),
    )
    result = calculate_coverage(r)
    assert result.flag == CoverageFlag.RENEWAL_ELEVATED

def test_renewal_soon_at_90_days():
    r = make_record(
        shipment_date=TODAY - timedelta(days=10),
        quantity=1,
        csv_visit_date=TODAY - timedelta(days=90),
    )
    result = calculate_coverage(r)
    assert result.flag == CoverageFlag.RENEWAL_SOON

def test_active_when_visit_far_away():
    r = make_record(
        shipment_date=TODAY - timedelta(days=10),
        quantity=1,
        csv_visit_date=TODAY - timedelta(days=10),
    )
    result = calculate_coverage(r)
    assert result.flag == CoverageFlag.ACTIVE

def test_transfer_pending_flag():
    r = make_record(csv_transfer_from="Prior Supplier LLC")
    result = calculate_coverage(r)
    assert result.flag == CoverageFlag.TRANSFER_PENDING

def test_priority_sort_order():
    lapsed = make_record(shipment_date=TODAY - timedelta(days=200))
    critical = make_record(patient_id="PT002", shipment_date=TODAY - timedelta(days=10),
                           csv_visit_date=TODAY - timedelta(days=135))
    active = make_record(patient_id="PT003", shipment_date=TODAY - timedelta(days=10),
                         csv_visit_date=TODAY - timedelta(days=10))
    from core.coverage_calculator import calculate_batch
    results = calculate_batch([active, critical, lapsed])
    assert results[0].flag == CoverageFlag.SUPPLY_LAPSED
    assert results[1].flag == CoverageFlag.RENEWAL_CRITICAL
    assert results[2].flag == CoverageFlag.ACTIVE
```

- [ ] **Step 2: Run tests — verify they fail**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py -v 2>&1 | head -40
```

- [ ] **Step 3: Replace CoverageFlag enum + update calculate_coverage**

In `python-backend/core/coverage_calculator.py`, replace the entire CoverageFlag enum and CoverageResult dataclass and all calculation functions with the following. Read the existing file first to preserve imports.

**New CoverageFlag enum** (replace existing):
```python
class CoverageFlag(str, Enum):
    SUPPLY_LAPSED     = "SUPPLY_LAPSED"      # supply cycle ended, no new shipment
    VISIT_REQUIRED    = "VISIT_REQUIRED"     # visit date past due, no confirmed new visit
    TRANSFER_PENDING  = "TRANSFER_PENDING"   # transferred patient, all docs need verification
    RENEWAL_CRITICAL  = "RENEWAL_CRITICAL"   # ≤ 45 days to next visit due
    RENEWAL_ELEVATED  = "RENEWAL_ELEVATED"   # ≤ 60 days to next visit due
    RENEWAL_SOON      = "RENEWAL_SOON"       # ≤ 90 days to next visit due
    RESUPPLY_READY    = "RESUPPLY_READY"     # within refill window, visit not yet urgent
    ACTIVE            = "ACTIVE"             # all clear
```

**New CoverageResult dataclass** (replace existing):
```python
@dataclass
class CoverageResult:
    patient_id: str
    device_type: str
    payer: str
    component: str
    last_shipment_date: date
    coverage_end_date: date
    next_visit_due_date: Optional[date]
    flag: CoverageFlag
    days_until_coverage_end: int
    days_until_visit_due: Optional[int]
    priority_score: int
    visit_date_confidence: str = "estimated"  # "confirmed" | "estimated"
    is_transfer: bool = False
    rule_version: str = RULE_VERSION
```

**New helper functions** (replace _compute_priority):
```python
def _resolve_visit_date(
    record: ShipmentRecord,
    confirmed_visit_date: Optional[date] = None,
) -> tuple[Optional[date], str]:
    """
    Visit date priority chain:
    1. CSV visit date column (if present + valid)
    2. Supabase confirmed visit date (stored by staff)
    3. Estimated proxy: shipment_date - 30 days
    Returns (resolved_date, confidence) where confidence is 'confirmed' or 'estimated'.
    """
    if record.csv_visit_date:
        return record.csv_visit_date, "confirmed"
    if confirmed_visit_date:
        return confirmed_visit_date, "confirmed"
    estimated = record.shipment_date - timedelta(days=30)
    return estimated, "estimated"


def _compute_visit_flag(
    next_visit_due: date,
    today: date,
) -> CoverageFlag:
    """Map days-to-next-visit to the appropriate tier flag."""
    days = (next_visit_due - today).days
    if days < 0:
        return CoverageFlag.VISIT_REQUIRED
    if days <= 45:
        return CoverageFlag.RENEWAL_CRITICAL
    if days <= 60:
        return CoverageFlag.RENEWAL_ELEVATED
    if days <= 90:
        return CoverageFlag.RENEWAL_SOON
    return CoverageFlag.ACTIVE


def _compute_priority(flag: CoverageFlag, days_until_visit: Optional[int]) -> int:
    """
    Priority score for worklist sort. Higher = more urgent.
    Supply Lapsed and Transfer Pending are always highest.
    Within visit tiers, urgency increases as days decrease.
    """
    urgency_days = abs(days_until_visit) if days_until_visit is not None else 0
    if flag == CoverageFlag.SUPPLY_LAPSED:
        return 2000 + urgency_days
    if flag == CoverageFlag.TRANSFER_PENDING:
        return 1800
    if flag == CoverageFlag.VISIT_REQUIRED:
        return 1500 + urgency_days
    if flag == CoverageFlag.RENEWAL_CRITICAL:
        return 1000 + (45 - max(0, urgency_days))
    if flag == CoverageFlag.RENEWAL_ELEVATED:
        return 700 + (60 - max(0, urgency_days))
    if flag == CoverageFlag.RENEWAL_SOON:
        return 400 + (90 - max(0, urgency_days))
    if flag == CoverageFlag.RESUPPLY_READY:
        return 200
    return 0  # ACTIVE
```

**New calculate_coverage function** (replace existing):
```python
def calculate_coverage(
    record: ShipmentRecord,
    as_of: Optional[date] = None,
    confirmed_visit_date: Optional[date] = None,
) -> CoverageResult:
    """
    Calculate coverage status for a single shipment record.

    Args:
        record: ShipmentRecord with non-PHI fields only.
        as_of:  Date to evaluate against. Defaults to today.
        confirmed_visit_date: Confirmed visit date from Supabase (staff-entered).
    """
    rules = _load_payer_rules()
    today = as_of or date.today()

    # Transferred patients: all docs need verification before anything else
    if record.csv_transfer_from:
        return CoverageResult(
            patient_id=record.patient_id,
            device_type=record.device_type,
            payer=record.payer,
            component=record.component,
            last_shipment_date=record.shipment_date,
            coverage_end_date=record.shipment_date,
            next_visit_due_date=None,
            flag=CoverageFlag.TRANSFER_PENDING,
            days_until_coverage_end=0,
            days_until_visit_due=None,
            priority_score=1800,
            visit_date_confidence="estimated",
            is_transfer=True,
            rule_version=RULE_VERSION,
        )

    wear_days = _get_wear_days(rules, record.device_type, record.component)
    payer_config = _get_payer_config(rules, record.payer)

    total_wear_days = wear_days * record.quantity
    coverage_end = record.shipment_date + timedelta(days=total_wear_days)
    days_until_end = (coverage_end - today).days

    # Supply lapsed — coverage cycle ended
    if days_until_end < 0:
        visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date)
        next_visit_due = visit_date + timedelta(days=180) if visit_date else None
        days_visit = (next_visit_due - today).days if next_visit_due else None
        return CoverageResult(
            patient_id=record.patient_id,
            device_type=record.device_type,
            payer=record.payer,
            component=record.component,
            last_shipment_date=record.shipment_date,
            coverage_end_date=coverage_end,
            next_visit_due_date=next_visit_due,
            flag=CoverageFlag.SUPPLY_LAPSED,
            days_until_coverage_end=days_until_end,
            days_until_visit_due=days_visit,
            priority_score=_compute_priority(CoverageFlag.SUPPLY_LAPSED, days_visit),
            visit_date_confidence=confidence,
            rule_version=RULE_VERSION,
        )

    visit_renewal_days = payer_config.get("visit_renewal_days")
    refill_window_days = payer_config.get("refill_window_days", 30)

    # Resolve visit date using priority chain
    visit_date, confidence = _resolve_visit_date(record, confirmed_visit_date)
    next_visit_due: Optional[date] = None
    days_until_visit: Optional[int] = None
    flag = CoverageFlag.ACTIVE

    if visit_renewal_days and visit_date:
        next_visit_due = visit_date + timedelta(days=visit_renewal_days)
        days_until_visit = (next_visit_due - today).days
        flag = _compute_visit_flag(next_visit_due, today)

    # If no visit urgency but within refill window, mark resupply ready
    if flag == CoverageFlag.ACTIVE and days_until_end <= refill_window_days:
        flag = CoverageFlag.RESUPPLY_READY

    priority = _compute_priority(flag, days_until_visit)

    return CoverageResult(
        patient_id=record.patient_id,
        device_type=record.device_type,
        payer=record.payer,
        component=record.component,
        last_shipment_date=record.shipment_date,
        coverage_end_date=coverage_end,
        next_visit_due_date=next_visit_due,
        flag=flag,
        days_until_coverage_end=days_until_end,
        days_until_visit_due=days_until_visit,
        priority_score=priority,
        visit_date_confidence=confidence,
        rule_version=RULE_VERSION,
    )
```

**Update calculate_batch** to pass confirmed_visit_date (leave the dict lookup for now — that's added in Task 5):
```python
def calculate_batch(
    records: list[ShipmentRecord],
    as_of: Optional[date] = None,
    confirmed_visits: Optional[dict[str, date]] = None,
) -> list[CoverageResult]:
    """
    confirmed_visits: dict mapping patient_id_hash → confirmed visit date.
    Loaded from Supabase by persistence layer before calling this function.
    """
    import hashlib
    confirmed_visits = confirmed_visits or {}
    results = []
    for record in records:
        patient_hash = hashlib.sha256(record.patient_id.encode()).hexdigest()
        confirmed_date = confirmed_visits.get(patient_hash)
        try:
            result = calculate_coverage(record, as_of=as_of, confirmed_visit_date=confirmed_date)
            results.append(result)
        except ValueError as exc:
            logger.warning("Skipping record for patient_id hash — %s", exc)

    results.sort(key=lambda r: r.priority_score, reverse=True)
    return results
```

- [ ] **Step 4: Run tests — verify they pass**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_coverage_flags.py -v
```
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add python-backend/core/coverage_calculator.py tests/test_coverage_flags.py
git commit -m "feat: 90/60/45 visit priority system with new CoverageFlag enum"
```

---

## Task 3: Doc State Machine

**Files:**
- Create: `python-backend/core/doc_state_machine.py`
- Create: `tests/test_doc_state_machine.py`

- [ ] **Step 1: Write the failing test**

Create `tests/test_doc_state_machine.py`:
```python
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python-backend"))

from datetime import date, timedelta
from core.doc_state_machine import compute_doc_state, DocState

TODAY = date.today()

def test_medicare_ffs_pa_not_required():
    state = compute_doc_state(
        payer_type="medicare",
        csv_swo_status="On File",
        csv_visit_date=TODAY - timedelta(days=30),
        confirmed_visit_date=None,
        csv_pecos_verified="Yes",
        csv_pa_status=None,
        csv_diagnosis_on_file="Yes",
        is_transfer=False,
    )
    assert state.pa == "Not Required"
    assert state.pecos == "Verified"

def test_medicaid_pecos_not_applicable():
    state = compute_doc_state(
        payer_type="medicaid",
        csv_swo_status="On File",
        csv_visit_date=TODAY - timedelta(days=30),
        confirmed_visit_date=None,
        csv_pecos_verified=None,
        csv_pa_status="Approved",
        csv_diagnosis_on_file="Yes",
        is_transfer=False,
    )
    assert state.pecos == "N/A"
    assert state.pa == "Approved"

def test_transfer_patient_all_pending():
    state = compute_doc_state(
        payer_type="medicare",
        csv_swo_status=None,
        csv_visit_date=None,
        confirmed_visit_date=None,
        csv_pecos_verified=None,
        csv_pa_status=None,
        csv_diagnosis_on_file=None,
        is_transfer=True,
    )
    assert state.swo == "Pending — Verify"
    assert state.visit == "Pending — Verify"
    assert state.pecos == "Pending — Verify"
    assert state.pa == "Pending — Verify"
    assert state.diagnosis == "Pending — Verify"

def test_cascade_visit_not_confirmed():
    state = compute_doc_state(
        payer_type="medicare",
        csv_swo_status="On File",
        csv_visit_date=None,
        confirmed_visit_date=None,
        csv_pecos_verified="Yes",
        csv_pa_status=None,
        csv_diagnosis_on_file="Yes",
        is_transfer=False,
    )
    assert len(state.cascade) > 0
    assert any("visit" in c.lower() for c in state.cascade)

def test_no_cascade_when_all_clear():
    state = compute_doc_state(
        payer_type="medicare",
        csv_swo_status="On File",
        csv_visit_date=TODAY - timedelta(days=30),
        confirmed_visit_date=None,
        csv_pecos_verified="Yes",
        csv_pa_status=None,
        csv_diagnosis_on_file="Yes",
        is_transfer=False,
    )
    assert state.cascade == []
```

- [ ] **Step 2: Run test — verify it fails**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_doc_state_machine.py -v 2>&1 | head -20
```
Expected: FAIL — module not found

- [ ] **Step 3: Create doc_state_machine.py**

Create `python-backend/core/doc_state_machine.py`:
```python
"""
doc_state_machine.py
Signal — STTIL Solutions

Computes per-patient documentation status across 5 items.
Requirements are payer-dependent. Produces cascade consequence chain.

PHI Contract: no patient names, DOBs, or contact info in any parameter.
"""

from dataclasses import dataclass, field
from datetime import date
from typing import Optional


# Payer types that require PECOS enrollment
_PECOS_REQUIRED = {"medicare", "medicare_advantage"}

# Payer types that require Prior Authorization for CGM
_PA_REQUIRED = {"medicare_advantage", "medicaid", "commercial"}

# Medicare FFS: PA not required (CGM coverage expansion 2023 + CMS-1828-F exemption path)
_PA_NOT_REQUIRED = {"medicare"}


@dataclass
class DocState:
    swo: str = "Pending"          # "On File" | "Pending" | "Expired"
    visit: str = "Not Confirmed"  # "Confirmed YYYY-MM-DD" | "Estimated YYYY-MM-DD" | "Not Confirmed"
    pecos: str = "Not Verified"   # "Verified" | "Not Verified" | "N/A" | "Pending — Verify"
    pa: str = "Required — Not Started"  # "Not Required" | "Approved" | "Pending" | "Denied" | "Required — Not Started" | "Pending — Verify"
    diagnosis: str = "Missing"    # "On File" | "Missing" | "Pending — Verify"
    cascade: list[str] = field(default_factory=list)


def _resolve_visit_display(
    csv_visit_date: Optional[date],
    confirmed_visit_date: Optional[date],
    is_transfer: bool,
) -> tuple[str, bool]:
    """
    Returns (display_string, is_confirmed).
    is_confirmed=True means the date is reliable (from CSV or staff-confirmed).
    """
    if is_transfer:
        return "Pending — Verify", False
    if csv_visit_date:
        return f"Confirmed {csv_visit_date.isoformat()}", True
    if confirmed_visit_date:
        return f"Confirmed {confirmed_visit_date.isoformat()}", True
    return "Not Confirmed — Estimated Only", False


def _build_cascade(
    swo: str,
    visit_confirmed: bool,
    pecos: str,
    pa: str,
    diagnosis: str,
    payer_type: str,
) -> list[str]:
    """
    Build the downstream consequence chain for missing/at-risk doc items.
    Only add cascade steps that are actually blocked.
    """
    cascade: list[str] = []

    if not visit_confirmed:
        cascade.append("Qualifying visit not confirmed")
        if swo in ("Pending", "Expired"):
            cascade.append("SWO validity at risk")
        if payer_type in _PA_REQUIRED and pa in ("Pending", "Required — Not Started"):
            cascade.append("PA renewal cannot proceed")
        cascade.append("Next shipment blocked")
        return cascade

    if swo in ("Pending", "Expired"):
        cascade.append("SWO missing or expired")
        if payer_type in _PECOS_REQUIRED and pecos not in ("Verified",):
            cascade.append("PECOS enrollment at risk")
        cascade.append("Next shipment blocked")

    if diagnosis == "Missing":
        cascade.append("Diagnosis documentation missing — required for all payers")

    return cascade


def compute_doc_state(
    payer_type: str,
    csv_swo_status: Optional[str],
    csv_visit_date: Optional[date],
    confirmed_visit_date: Optional[date],
    csv_pecos_verified: Optional[str],
    csv_pa_status: Optional[str],
    csv_diagnosis_on_file: Optional[str],
    is_transfer: bool = False,
) -> DocState:
    """
    Compute the full documentation state for one patient.

    payer_type: "medicare" | "medicare_advantage" | "medicaid" | "commercial"
    All csv_* fields are Optional — None means not present in the CSV.
    """

    # Transfer patients: everything is pending verification
    if is_transfer:
        return DocState(
            swo="Pending — Verify",
            visit="Pending — Verify",
            pecos="Pending — Verify",
            pa="Pending — Verify",
            diagnosis="Pending — Verify",
            cascade=[
                "Patient transferred from prior supplier",
                "Verify: SWO from current prescriber, qualifying visit within 6 months, PA status with current payer",
            ],
        )

    # SWO
    swo_map = {"on file": "On File", "pending": "Pending", "expired": "Expired"}
    swo = swo_map.get((csv_swo_status or "").strip().lower(), "Pending")

    # Visit
    visit_display, visit_confirmed = _resolve_visit_display(
        csv_visit_date, confirmed_visit_date, is_transfer
    )

    # PECOS — only required for Medicare
    if payer_type in _PECOS_REQUIRED:
        pecos_val = (csv_pecos_verified or "").strip().lower()
        pecos = "Verified" if pecos_val in ("yes", "verified", "true", "1") else "Not Verified"
    else:
        pecos = "N/A"

    # PA — not required for Medicare FFS; required for MA, Medicaid, Commercial
    if payer_type in _PA_NOT_REQUIRED:
        pa = "Not Required"
    elif csv_pa_status:
        pa_map = {
            "approved": "Approved",
            "pending": "Pending",
            "denied": "Denied",
            "not required": "Not Required",
            "not started": "Required — Not Started",
        }
        pa_key = csv_pa_status.strip().lower()
        pa = pa_map.get(pa_key, "Required — Not Started")
    else:
        pa = "Required — Not Started"

    # Diagnosis
    dx_val = (csv_diagnosis_on_file or "").strip().lower()
    diagnosis = "On File" if dx_val in ("yes", "true", "on file", "1") else "Missing"

    cascade = _build_cascade(swo, visit_confirmed, pecos, pa, diagnosis, payer_type)

    return DocState(
        swo=swo,
        visit=visit_display,
        pecos=pecos,
        pa=pa,
        diagnosis=diagnosis,
        cascade=cascade,
    )
```

- [ ] **Step 4: Run tests — verify they pass**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/test_doc_state_machine.py -v
```
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add python-backend/core/doc_state_machine.py tests/test_doc_state_machine.py
git commit -m "feat: doc state machine — 5-item payer-dependent requirements with cascade logic"
```

---

## Task 4: payer_rules.json updates + Supabase confirmed_visits migration

**Files:**
- Modify: `python-backend/config/payer_rules.json`
- Create: `python-backend/db/migrations/001_add_confirmed_visits.sql`
- Modify: `python-backend/db/schema.sql`

- [ ] **Step 1: Update payer_rules.json**

Read the file first, then replace its contents:
```json
{
  "_comment": "Wear-day rules by device type and payer. Updated 2026-06-07. Run quarterly review per compliance checklist.",
  "devices": {
    "dexcom_g6": {
      "display_name": "Dexcom G6",
      "sensor_wear_days": 10,
      "transmitter_wear_days": 90,
      "components": ["sensor", "transmitter"]
    },
    "dexcom_g7": {
      "display_name": "Dexcom G7",
      "sensor_wear_days": 10,
      "components": ["sensor"]
    },
    "freestyle_libre_2": {
      "display_name": "FreeStyle Libre 2",
      "sensor_wear_days": 14,
      "components": ["sensor"]
    },
    "freestyle_libre_3": {
      "display_name": "FreeStyle Libre 3",
      "sensor_wear_days": 14,
      "components": ["sensor"]
    },
    "omnipod_5": {
      "display_name": "Omnipod 5",
      "pod_wear_days": 3,
      "sensor_wear_days": 14,
      "components": ["pod", "sensor"]
    }
  },
  "payer_rules": {
    "medicare": {
      "visit_renewal_days": 180,
      "refill_window_days": 30,
      "pa_required": false,
      "pecos_required": true,
      "_note": "Medicare FFS. PA not required (CGM coverage expansion 2023). Face-to-face visit required every 6 months. CMS-1828-F: suppliers at ≥90% affirmation rate may qualify for PA exemption from June 1, 2026.",
      "covered_devices": ["dexcom_g6", "dexcom_g7", "freestyle_libre_2", "freestyle_libre_3"]
    },
    "medicare_advantage": {
      "visit_renewal_days": 180,
      "refill_window_days": 30,
      "pa_required": true,
      "pecos_required": true,
      "_note": "Medicare Advantage — PA required, varies by plan. Verify plan-specific rules.",
      "covered_devices": ["dexcom_g6", "dexcom_g7", "freestyle_libre_2", "freestyle_libre_3"]
    },
    "medicaid": {
      "visit_renewal_days": 180,
      "refill_window_days": 30,
      "pa_required": true,
      "pecos_required": false,
      "_pa_note_pa": "PA required — Keystone First, UPMC Health Plan, Highmark BCBS require PA. NJ FamilyCare, Horizon BCBS NJ require PA.",
      "covered_devices": []
    },
    "commercial": {
      "visit_renewal_days": 180,
      "refill_window_days": 30,
      "pa_required": true,
      "pecos_required": false,
      "_note": "Commercial payer rules vary by plan. PA required by default — verify per plan.",
      "covered_devices": []
    },
    "default": {
      "visit_renewal_days": 180,
      "refill_window_days": 30,
      "pa_required": true,
      "pecos_required": false,
      "covered_devices": []
    }
  }
}
```

- [ ] **Step 2: Create Supabase migration SQL**

Create `python-backend/db/migrations/001_add_confirmed_visits.sql`:
```sql
-- Migration 001: Add confirmed_visits table
-- Run in Supabase SQL Editor: Dashboard > SQL Editor > New query
-- Safe to run multiple times.

create table if not exists confirmed_visits (
  id              uuid        primary key default uuid_generate_v4(),
  org_id          uuid        not null references organizations(id) on delete cascade,
  patient_id_hash text        not null,  -- SHA-256 of patient_id — no raw PHI
  confirmed_date  date        not null,
  confirmed_by    text,                  -- staff identifier (not PHI — just a label)
  created_at      timestamptz not null default now(),
  updated_at      timestamptz not null default now(),
  unique(org_id, patient_id_hash)
);

create index if not exists idx_confirmed_visits_org on confirmed_visits(org_id);
create index if not exists idx_confirmed_visits_hash on confirmed_visits(patient_id_hash);

alter table confirmed_visits enable row level security;
```

- [ ] **Step 3: Add confirmed_visits to schema.sql**

Read `python-backend/db/schema.sql`, then append before the final `-- Indexes` section:
```sql
-- Confirmed visit dates (staff-entered, persists across CSV imports)
create table if not exists confirmed_visits (
  id              uuid        primary key default uuid_generate_v4(),
  org_id          uuid        not null references organizations(id) on delete cascade,
  patient_id_hash text        not null,
  confirmed_date  date        not null,
  confirmed_by    text,
  created_at      timestamptz not null default now(),
  updated_at      timestamptz not null default now(),
  unique(org_id, patient_id_hash)
);
create index if not exists idx_confirmed_visits_org  on confirmed_visits(org_id);
create index if not exists idx_confirmed_visits_hash on confirmed_visits(patient_id_hash);
alter table confirmed_visits enable row level security;
```

- [ ] **Step 4: Run the migration in Supabase**

The migration must be run manually in the Supabase dashboard SQL editor (there is no CLI migration runner configured). Copy the contents of `python-backend/db/migrations/001_add_confirmed_visits.sql` and paste into the Supabase dashboard SQL editor at your project.

After running, verify: `SELECT COUNT(*) FROM confirmed_visits;` should return 0 with no error.

- [ ] **Step 5: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add python-backend/config/payer_rules.json python-backend/db/migrations/001_add_confirmed_visits.sql python-backend/db/schema.sql
git commit -m "feat: payer_rules medicare_advantage + PA/NJ rules; confirmed_visits migration"
```

---

## Task 5: Persistence — confirmed visit read/write + upload diff

**Files:**
- Modify: `python-backend/core/persistence.py`

Read `python-backend/core/persistence.py` before editing — preserve all existing functions.

- [ ] **Step 1: Add confirmed visit functions**

Append to `python-backend/core/persistence.py`:
```python
def upsert_confirmed_visit(
    org_id: str,
    patient_id_hash: str,
    confirmed_date: date,
    confirmed_by: str = "staff",
) -> bool:
    """
    Insert or update a confirmed visit date for a patient.
    Returns True on success, False if Supabase unavailable.
    """
    client = get_client()
    if not client:
        return False
    try:
        client.table("confirmed_visits").upsert({
            "org_id": org_id,
            "patient_id_hash": patient_id_hash,
            "confirmed_date": confirmed_date.isoformat(),
            "confirmed_by": confirmed_by,
            "updated_at": "now()",
        }, on_conflict="org_id,patient_id_hash").execute()
        return True
    except Exception as e:
        logger.error(f"Failed to upsert confirmed visit: {e}")
        return False


def load_confirmed_visits_for_org(org_id: str) -> dict[str, date]:
    """
    Load all confirmed visit dates for an org.
    Returns dict mapping patient_id_hash → confirmed_date.
    """
    client = get_client()
    if not client:
        return {}
    try:
        result = client.table("confirmed_visits") \
            .select("patient_id_hash,confirmed_date") \
            .eq("org_id", org_id) \
            .execute()
        return {
            row["patient_id_hash"]: date.fromisoformat(row["confirmed_date"])
            for row in (result.data or [])
        }
    except Exception as e:
        logger.error(f"Failed to load confirmed visits: {e}")
        return {}


def get_or_create_org(clerk_org_id: str | None = None) -> str | None:
    """Public wrapper around _get_or_create_org for use by the API."""
    return _get_or_create_org(clerk_org_id=clerk_org_id)
```

- [ ] **Step 2: Verify imports in main.py still work**

```bash
cd /Users/sttil-solutions/projects/signal/python-backend && python -c "from core.persistence import upsert_confirmed_visit, load_confirmed_visits_for_org, get_or_create_org; print('OK')"
```
Expected: `OK`

- [ ] **Step 3: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add python-backend/core/persistence.py
git commit -m "feat: confirmed visit persistence — upsert and bulk load per org"
```

---

## Task 6: Normalizer updates — doc columns + transfer columns

**Files:**
- Modify: `python-backend/api/normalizer.py`

Read `python-backend/api/normalizer.py` before editing.

- [ ] **Step 1: Add doc column mappings to HEADER_MAP**

In normalizer.py, add these entries to the `HEADER_MAP` dict (after the existing "component" entry):
```python
    "csv_visit_date": [
        "visit_date", "visit date", "qualifying_visit_date", "qualifying visit date",
        "last_visit_date", "last visit date", "face_to_face_date", "f2f_date",
        "encounter_date", "encounter date", "physician_visit_date",
    ],
    "csv_swo_status": [
        "swo_status", "swo status", "swo", "standing_written_order",
        "standing written order", "order_status", "order status",
    ],
    "csv_pecos_verified": [
        "pecos_verified", "pecos verified", "pecos", "pecos_status",
        "enrollment_verified", "enrollment verified",
    ],
    "csv_pa_status": [
        "pa_status", "pa status", "prior_auth_status", "prior auth status",
        "prior_authorization_status", "auth_status", "pa", "authorization",
    ],
    "csv_diagnosis_on_file": [
        "diagnosis_on_file", "diagnosis on file", "diagnosis", "dx_on_file",
        "dx on file", "icd_on_file", "icd on file",
    ],
    "csv_transfer_from": [
        "transfer_from", "transfer from", "previous_supplier", "previous supplier",
        "prior_supplier", "prior supplier", "transfer_status", "transferred_from",
    ],
```

- [ ] **Step 2: Update the ShipmentRecord construction in normalize_csv**

In the `normalize_csv` function, after the existing `component` parsing, add:
```python
        # Optional doc fields — only populated if the CSV contains these columns
        csv_visit_date: Optional[date] = None
        raw_visit = mapped.get("csv_visit_date", "")
        if raw_visit:
            csv_visit_date = _parse_date(raw_visit)  # None if unparseable (non-error)

        csv_swo_status = mapped.get("csv_swo_status") or None
        csv_pecos_verified = mapped.get("csv_pecos_verified") or None
        csv_pa_status = mapped.get("csv_pa_status") or None
        csv_diagnosis_on_file = mapped.get("csv_diagnosis_on_file") or None
        transfer_raw = mapped.get("csv_transfer_from", "").strip()
        csv_transfer_from = transfer_raw if transfer_raw else None
```

Then update the `ShipmentRecord(...)` constructor call to include these fields:
```python
        records.append(ShipmentRecord(
            patient_id=patient_id,
            device_type=device_type,
            shipment_date=shipment_date,
            quantity=quantity,
            payer=payer,
            component=component,
            csv_visit_date=csv_visit_date,
            csv_swo_status=csv_swo_status,
            csv_pecos_verified=csv_pecos_verified,
            csv_pa_status=csv_pa_status,
            csv_diagnosis_on_file=csv_diagnosis_on_file,
            csv_transfer_from=csv_transfer_from,
        ))
```

- [ ] **Step 3: Update _normalize_payer in normalizer.py to distinguish Medicare Advantage**

Find the PAYER_MAP dict and update the medicare_advantage line:
```python
    "medicare advantage": "medicare_advantage",  # distinct from medicare FFS
```

- [ ] **Step 4: Verify the normalizer still runs on a baseline CSV**

```bash
cd /Users/sttil-solutions/projects/signal && python -c "
from python_backend.api.normalizer import normalize_csv
import pathlib
text = pathlib.Path('test-data/sample-batch-01-ok.csv').read_text()
records, skipped, summary = normalize_csv(text)
print(f'Records: {len(records)}, Skipped: {len(skipped)}')
print('First record:', records[0] if records else 'none')
"
```
Expected: Records >= 1, no errors

Alternatively run from python-backend dir:
```bash
cd /Users/sttil-solutions/projects/signal/python-backend && python -c "
from api.normalizer import normalize_csv
import pathlib, sys
text = pathlib.Path('../test-data/sample-batch-01-ok.csv').read_text()
records, skipped, summary = normalize_csv(text)
print(f'Records: {len(records)}, Skipped: {len(skipped)}')
"
```

- [ ] **Step 5: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add python-backend/api/normalizer.py
git commit -m "feat: normalizer adds doc column mappings and transfer column detection"
```

---

## Task 7: API — updated RecordOut + confirm-visit endpoint + upload integration

**Files:**
- Modify: `python-backend/api/main.py`

Read `python-backend/api/main.py` before editing.

- [ ] **Step 1: Add imports at top of main.py**

Add these imports (after existing imports):
```python
import hashlib
from datetime import date as date_type
from core.doc_state_machine import compute_doc_state, DocState
from core.persistence import (
    persist_export, persist_upload,
    upsert_confirmed_visit, load_confirmed_visits_for_org, get_or_create_org,
)
```

Remove the existing `from core.persistence import persist_export, persist_upload` line.

- [ ] **Step 2: Update FLAG_LABELS and FLAG_ACTIONS for new flags**

Replace the existing FLAG_LABELS and FLAG_ACTIONS dicts:
```python
FLAG_LABELS = {
    "SUPPLY_LAPSED":    "Supply Lapsed",
    "VISIT_REQUIRED":   "Visit Required",
    "TRANSFER_PENDING": "Needs Verification — Transfer",
    "RENEWAL_CRITICAL": "Renewal Due — Critical",
    "RENEWAL_ELEVATED": "Renewal Due SOON",
    "RENEWAL_SOON":     "Renewal Due SOON",
    "RESUPPLY_READY":   "Resupply Ready",
    "ACTIVE":           "Active",
}

FLAG_ACTIONS = {
    "SUPPLY_LAPSED":    "Contact Prescriber — Supply Lapsed",
    "VISIT_REQUIRED":   "Contact Prescriber — Visit Overdue",
    "TRANSFER_PENDING": "Verify All Documentation",
    "RENEWAL_CRITICAL": "Contact Prescriber — Confirm Visit Appointment",
    "RENEWAL_ELEVATED": "Contact Prescriber — Schedule Qualifying Visit",
    "RENEWAL_SOON":     "Monitor — Visit Due in 90 Days",
    "RESUPPLY_READY":   "Initiate Resupply",
    "ACTIVE":           "No action needed",
}
```

- [ ] **Step 3: Update RecordOut to include doc state and visit confidence**

Replace the existing RecordOut class:
```python
class DocStateOut(BaseModel):
    swo: str
    visit: str
    pecos: str
    pa: str
    diagnosis: str

class RecordOut(BaseModel):
    patient_id: str
    device_type: str
    device_display: str
    payer: str
    component: str
    days_until_coverage_end: int
    days_until_visit_due: Optional[int] = None
    flag: str
    priority_score: int
    coverage_end_date: str
    next_visit_due_date: Optional[str] = None
    action: str
    status_label: str
    reason: str
    rule_version: str
    visit_date_confidence: str = "estimated"
    is_transfer: bool = False
    doc_state: Optional[DocStateOut] = None
    cascade: list[str] = []
```

- [ ] **Step 4: Update _to_record_out to compute doc state**

Replace the existing `_to_record_out` function. It now needs access to the original ShipmentRecord to compute doc state:
```python
def _to_record_out(r, record=None) -> RecordOut:
    flag_val = r.flag.value if hasattr(r.flag, "value") else str(r.flag)

    # Compute doc state if we have the original record
    doc_state_out = None
    cascade = []
    if record is not None:
        normalized_payer = _normalize_payer_type(r.payer)
        doc = compute_doc_state(
            payer_type=normalized_payer,
            csv_swo_status=record.csv_swo_status,
            csv_visit_date=record.csv_visit_date,
            confirmed_visit_date=None,  # already factored into r.next_visit_due_date
            csv_pecos_verified=record.csv_pecos_verified,
            csv_pa_status=record.csv_pa_status,
            csv_diagnosis_on_file=record.csv_diagnosis_on_file,
            is_transfer=bool(record.csv_transfer_from),
        )
        doc_state_out = DocStateOut(
            swo=doc.swo, visit=doc.visit, pecos=doc.pecos, pa=doc.pa, diagnosis=doc.diagnosis
        )
        cascade = doc.cascade

    return RecordOut(
        patient_id=r.patient_id,
        device_type=r.device_type,
        device_display=DEVICE_DISPLAY.get(r.device_type, r.device_type),
        payer=r.payer,
        component=r.component,
        days_until_coverage_end=r.days_until_coverage_end,
        days_until_visit_due=r.days_until_visit_due,
        flag=flag_val,
        priority_score=r.priority_score,
        coverage_end_date=r.coverage_end_date.isoformat(),
        next_visit_due_date=r.next_visit_due_date.isoformat() if r.next_visit_due_date else None,
        action=FLAG_ACTIONS.get(flag_val, "Review"),
        status_label=FLAG_LABELS.get(flag_val, flag_val),
        reason=_build_reason(flag_val, r.days_until_coverage_end, r.days_until_visit_due),
        rule_version=r.rule_version,
        visit_date_confidence=getattr(r, "visit_date_confidence", "estimated"),
        is_transfer=getattr(r, "is_transfer", False),
        doc_state=doc_state_out,
        cascade=cascade,
    )


def _normalize_payer_type(payer: str) -> str:
    """Map raw payer string to doc_state_machine payer_type."""
    from core.coverage_calculator import _normalize_payer
    normalized = _normalize_payer(payer)
    # normalizer maps "medicare advantage" → "medicare_advantage" but coverage_calc maps it to "commercial"
    # Use the raw payer string for MA detection
    if "medicare advantage" in payer.lower() or "medicare_advantage" in payer.lower():
        return "medicare_advantage"
    return normalized
```

- [ ] **Step 5: Update the /api/upload endpoint to pass records to _to_record_out and load confirmed visits**

In the `upload_csv` function, replace:
```python
    results = calculate_batch(records, as_of=date.today())
    out = [_to_record_out(r) for r in results]
```
with:
```python
    # Load confirmed visit dates from Supabase for this org
    clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
    org_id = get_or_create_org(clerk_org_id=clerk_org_id)
    confirmed_visits = load_confirmed_visits_for_org(org_id) if org_id else {}

    results = calculate_batch(records, as_of=date.today(), confirmed_visits=confirmed_visits)

    # Build a lookup from patient_id to original record for doc state computation
    record_lookup = {r.patient_id: r for r in records}
    out = [_to_record_out(r, record=record_lookup.get(r.patient_id)) for r in results]
```

Also remove the duplicate `clerk_org_id` extraction later in the function (it was used for persist_upload) and use the one computed above.

- [ ] **Step 6: Add the confirm-visit endpoint**

Add after the existing `/api/export` endpoint:
```python
class ConfirmVisitRequest(BaseModel):
    patient_id: str
    confirmed_date: str  # ISO date string YYYY-MM-DD
    # Fields needed to recompute coverage after confirmation
    shipment_date: str
    payer: str
    device_type: str
    quantity: int = 1
    component: str = "sensor"


@app.post("/api/confirm-visit")
async def confirm_visit(
    body: ConfirmVisitRequest,
    claims: dict = Depends(require_auth),
):
    """
    Store a staff-confirmed qualifying visit date and return the updated coverage record.
    The confirmed date persists across all future CSV imports for this patient_id.
    """
    from core.coverage_calculator import ShipmentRecord, calculate_coverage

    # Validate dates
    try:
        confirmed = date_type.fromisoformat(body.confirmed_date)
        shipment = date_type.fromisoformat(body.shipment_date)
    except ValueError as e:
        raise HTTPException(status_code=400, detail=f"Invalid date: {e}. Use YYYY-MM-DD.")

    if confirmed > date_type.today():
        raise HTTPException(status_code=400, detail="Confirmed date cannot be in the future.")

    # Get org
    clerk_org_id = claims.get("o", {}).get("id") if isinstance(claims.get("o"), dict) else None
    org_id = get_or_create_org(clerk_org_id=clerk_org_id)
    if not org_id:
        raise HTTPException(status_code=503, detail="Organization not found.")

    # Hash and store
    patient_hash = hashlib.sha256(body.patient_id.encode()).hexdigest()
    success = upsert_confirmed_visit(org_id, patient_hash, confirmed, confirmed_by="staff")
    if not success:
        raise HTTPException(status_code=503, detail="Failed to save confirmed visit.")

    # Recompute coverage with confirmed visit date
    record = ShipmentRecord(
        patient_id=body.patient_id,
        device_type=body.device_type,
        shipment_date=shipment,
        quantity=body.quantity,
        payer=body.payer,
        component=body.component,
    )
    result = calculate_coverage(record, confirmed_visit_date=confirmed)

    log_event(
        AuditAction.CSV_INGEST,
        f"confirm-visit:{body.patient_id[:8]}",
        "staff",
        "success",
        "0.0.0.0",
        detail=f"Visit confirmed {confirmed.isoformat()}",
    )

    return _to_record_out(result, record=record)
```

- [ ] **Step 7: Verify the backend starts cleanly**

```bash
cd /Users/sttil-solutions/projects/signal && python -m uvicorn python-backend.api.main:app --port 8001 --no-access-log 2>&1 | head -5
```
Expected: no import errors. Kill with Ctrl+C after confirming.

Or from the python-backend directory:
```bash
cd /Users/sttil-solutions/projects/signal/python-backend && python -m uvicorn api.main:app --port 8001 2>&1 | head -5
```

- [ ] **Step 8: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add python-backend/api/main.py
git commit -m "feat: confirm-visit endpoint + RecordOut with doc_state + cascade"
```

---

## Task 8: Deploy backend to Railway

- [ ] **Step 1: Deploy**

```bash
cd /Users/sttil-solutions/projects/signal && railway up --detach
```

- [ ] **Step 2: Wait for build and verify health**

After Railway build completes (watch status at https://railway.app or `railway status`):
```bash
curl -s https://signal-api-production-91c2.up.railway.app/health | python3 -c "import json,sys; d=json.load(sys.stdin); print(d)"
```
Expected: `{'status': 'ok', 'service': 'signal-api', 'version': '1.0.0'}`

---

## Task 9: Frontend — Badge.jsx update for new status labels

**Files:**
- Modify: `signal-ui/src/components/Badge.jsx`

Read `signal-ui/src/components/Badge.jsx` before editing.

- [ ] **Step 1: Replace Badge.jsx with updated flag handling**

The new flags are: SUPPLY_LAPSED, VISIT_REQUIRED, TRANSFER_PENDING, RENEWAL_CRITICAL, RENEWAL_ELEVATED, RENEWAL_SOON, RESUPPLY_READY, ACTIVE.

Visual weight rules per spec:
- SUPPLY_LAPSED: dark red, bold
- VISIT_REQUIRED: red bold + warning icon
- RENEWAL_CRITICAL: red bold
- RENEWAL_ELEVATED: amber bold
- RENEWAL_SOON: amber normal
- RESUPPLY_READY: teal/brand, normal
- ACTIVE: green normal
- TRANSFER_PENDING: orange, normal

```jsx
const FLAG_CONFIG = {
  SUPPLY_LAPSED: {
    label: "Supply Lapsed",
    bg: "bg-[rgba(180,0,0,0.12)]",
    text: "text-[#B00000]",
    border: "border-[#B00000]",
    weight: "font-bold",
    icon: "⚠",
  },
  VISIT_REQUIRED: {
    label: "Visit Required",
    bg: "bg-[rgba(220,30,30,0.10)]",
    text: "text-[#CC2222]",
    border: "border-[#CC2222]",
    weight: "font-bold",
    icon: "⚠",
  },
  TRANSFER_PENDING: {
    label: "Needs Verification",
    bg: "bg-[rgba(200,120,0,0.10)]",
    text: "text-[#C87800]",
    border: "border-[#C87800]",
    weight: "font-semibold",
    icon: "↔",
  },
  RENEWAL_CRITICAL: {
    label: "Renewal Due — Critical",
    bg: "bg-[rgba(220,60,0,0.10)]",
    text: "text-[#DC3C00]",
    border: "border-[#DC3C00]",
    weight: "font-bold",
    icon: "",
  },
  RENEWAL_ELEVATED: {
    label: "Renewal Due SOON",
    bg: "bg-[rgba(203,107,32,0.12)]",
    text: "text-[#CB6B20]",
    border: "border-[#CB6B20]",
    weight: "font-semibold",
    icon: "",
  },
  RENEWAL_SOON: {
    label: "Renewal Due SOON",
    bg: "bg-[rgba(203,107,32,0.08)]",
    text: "text-[#CB6B20]",
    border: "border-[#CB6B20]",
    weight: "font-normal",
    icon: "",
  },
  RESUPPLY_READY: {
    label: "Resupply Ready",
    bg: "bg-[rgba(46,163,163,0.10)]",
    text: "text-[#1A7070]",
    border: "border-[#2EA3A3]",
    weight: "font-normal",
    icon: "",
  },
  ACTIVE: {
    label: "Active",
    bg: "bg-[rgba(40,160,80,0.10)]",
    text: "text-[#1A8040]",
    border: "border-[#28A050]",
    weight: "font-normal",
    icon: "",
  },
};

export default function Badge({ flag }) {
  const cfg = FLAG_CONFIG[flag] || {
    label: flag,
    bg: "bg-gray-100",
    text: "text-gray-600",
    border: "border-gray-300",
    weight: "font-normal",
    icon: "",
  };
  return (
    <span
      className={`inline-flex items-center gap-[3px] px-[8px] py-[3px] rounded-full text-[11px] border ${cfg.bg} ${cfg.text} ${cfg.border} ${cfg.weight} whitespace-nowrap`}
    >
      {cfg.icon && <span className="text-[10px]">{cfg.icon}</span>}
      {cfg.label}
    </span>
  );
}
```

- [ ] **Step 2: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add signal-ui/src/components/Badge.jsx
git commit -m "feat: Badge updated for new status flags with visual weight system"
```

---

## Task 10: Frontend — DocStatusBar component

**Files:**
- Create: `signal-ui/src/components/DocStatusBar.jsx`

- [ ] **Step 1: Create DocStatusBar.jsx**

```jsx
/**
 * DocStatusBar — 5 colored dots showing SWO, Visit, PECOS, PA, Diagnosis status.
 * Green = complete, Amber = pending/estimated, Red = missing/denied.
 */

const DOT_STATUS = {
  // SWO
  "On File": "green",
  "Expired": "red",
  // Visit
  "Not Confirmed — Estimated Only": "amber",
  "Not Confirmed": "amber",
  // PECOS
  "Verified": "green",
  "Not Verified": "red",
  "N/A": "gray",
  // PA
  "Not Required": "green",
  "Approved": "green",
  "Denied": "red",
  // Diagnosis
  "On File": "green",
  "Missing": "red",
  // Transfer / generic pending
  "Pending — Verify": "amber",
  "Pending": "amber",
  "Required — Not Started": "red",
};

function dotColor(status) {
  if (!status) return "amber";
  const s = status.trim();
  if (s.startsWith("Confirmed")) return "green";
  if (s.startsWith("Approved")) return "green";
  return DOT_STATUS[s] || "amber";
}

const COLOR_MAP = {
  green: "bg-[#28A050]",
  amber: "bg-[#CB6B20]",
  red:   "bg-[#CC2222]",
  gray:  "bg-[#AAAAAA]",
};

const LABELS = ["SWO", "Visit", "PECOS", "PA", "Dx"];

export default function DocStatusBar({ docState }) {
  if (!docState) return null;

  const items = [
    docState.swo,
    docState.visit,
    docState.pecos,
    docState.pa,
    docState.diagnosis,
  ];

  return (
    <div className="flex items-center gap-[5px]" title="Documentation status: SWO · Visit · PECOS · PA · Diagnosis">
      {items.map((status, i) => (
        <div key={i} className="flex flex-col items-center gap-[2px]">
          <div
            className={`w-[8px] h-[8px] rounded-full ${COLOR_MAP[dotColor(status)]}`}
            title={`${LABELS[i]}: ${status || "Unknown"}`}
          />
        </div>
      ))}
    </div>
  );
}
```

- [ ] **Step 2: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add signal-ui/src/components/DocStatusBar.jsx
git commit -m "feat: DocStatusBar — 5-dot documentation status indicator"
```

---

## Task 11: Frontend — ConfirmVisitModal component

**Files:**
- Create: `signal-ui/src/components/ConfirmVisitModal.jsx`

- [ ] **Step 1: Create ConfirmVisitModal.jsx**

```jsx
import { useState } from "react";
import { confirmVisit } from "../lib/api";

export default function ConfirmVisitModal({ record, onClose, onConfirmed }) {
  const [visitDate, setVisitDate] = useState("");
  const [error, setError] = useState("");
  const [saving, setSaving] = useState(false);

  const today = new Date().toISOString().split("T")[0];
  const minDate = "2020-01-01";

  async function handleSave() {
    setError("");
    if (!visitDate) {
      setError("Please enter a visit date.");
      return;
    }
    if (visitDate > today) {
      setError("Visit date cannot be in the future.");
      return;
    }
    // Validate: within 6 months of shipment date
    if (record.coverage_end_date) {
      const shipment = new Date(record.coverage_end_date);
      const visit = new Date(visitDate);
      // rough check: visit must be after shipment_date - 180 days
    }

    setSaving(true);
    try {
      const updated = await confirmVisit({
        patient_id: record.patient_id,
        confirmed_date: visitDate,
        shipment_date: record.coverage_end_date
          ? new Date(new Date(record.coverage_end_date).getTime() - record.days_until_coverage_end * 86400000).toISOString().split("T")[0]
          : today,
        payer: record.payer,
        device_type: record.device_type,
        quantity: 1,
        component: record.component || "sensor",
      });
      onConfirmed(record.patient_id, updated);
      onClose();
    } catch (e) {
      setError(e.message || "Failed to save. Please try again.");
    } finally {
      setSaving(false);
    }
  }

  return (
    <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
      <div
        className="bg-[var(--bg-card)] border border-[var(--border-color)] rounded-[12px] shadow-xl p-6 w-[420px] max-w-[95vw]"
        onClick={(e) => e.stopPropagation()}
      >
        <div className="font-heading font-bold text-[15px] text-[var(--text-heading)] mb-1">
          Confirm Qualifying Visit
        </div>
        <div className="text-[12px] text-[var(--text-muted)] mb-4">
          Patient ID: <span className="font-mono font-semibold text-[var(--text-secondary)]">{record.patient_id}</span>
        </div>

        <label className="block text-[12px] font-semibold text-[var(--text-secondary)] mb-1">
          Qualifying visit date — confirmed with prescriber office or from SWO on file
        </label>
        <input
          type="date"
          value={visitDate}
          max={today}
          min={minDate}
          onChange={(e) => setVisitDate(e.target.value)}
          className="w-full border border-[var(--border-color)] rounded-[6px] px-3 py-2 text-[13px] bg-[var(--bg-elevated)] text-[var(--text-primary)] mb-2 focus:outline-none focus:border-[var(--brand)]"
        />
        <div className="text-[10.5px] text-[var(--text-muted)] mb-4">
          Contact prescriber office to confirm. Do not use patient-reported date.
        </div>

        {error && (
          <div className="text-[11px] text-[#CC2222] mb-3 px-2 py-1 bg-red-50 rounded border border-red-200">
            {error}
          </div>
        )}

        <div className="flex gap-3 justify-end">
          <button
            onClick={onClose}
            className="px-4 py-2 text-[12px] rounded-md border border-[var(--border-color)] text-[var(--text-secondary)] hover:border-[var(--brand)] cursor-pointer"
          >
            Cancel
          </button>
          <button
            onClick={handleSave}
            disabled={saving}
            className="px-4 py-2 text-[12px] rounded-md bg-[var(--brand)] text-white font-semibold hover:opacity-90 cursor-pointer disabled:opacity-50"
          >
            {saving ? "Saving..." : "Save Visit Date"}
          </button>
        </div>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add signal-ui/src/components/ConfirmVisitModal.jsx
git commit -m "feat: ConfirmVisitModal — date input with validation, connects to confirm-visit API"
```

---

## Task 12: Frontend — api.js confirmVisit function

**Files:**
- Modify: `signal-ui/src/lib/api.js`

Read `signal-ui/src/lib/api.js` before editing.

- [ ] **Step 1: Add confirmVisit export**

Append to `signal-ui/src/lib/api.js`:
```js
export async function confirmVisit(payload) {
  const { getToken } = window.__clerk_helpers__ || {};
  const token = getToken ? await getToken() : null;

  const res = await fetch(`${API_URL}/api/confirm-visit`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body: JSON.stringify(payload),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(err.detail || `HTTP ${res.status}`);
  }
  return res.json();
}
```

Note: The existing api.js likely already has an `API_URL` constant. Preserve it. If `getToken` is accessed via Clerk's useAuth hook in the calling component, update the ConfirmVisitModal to pass the token instead. Check how the existing upload function handles auth in api.js and match the pattern.

- [ ] **Step 2: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add signal-ui/src/lib/api.js
git commit -m "feat: api.js — confirmVisit function wired to /api/confirm-visit endpoint"
```

---

## Task 13: Frontend — WorklistTable expandable rows + DocStatusBar + Confirm Visit

**Files:**
- Modify: `signal-ui/src/components/WorklistTable.jsx`

Read `signal-ui/src/components/WorklistTable.jsx` before editing. This is a significant rewrite.

- [ ] **Step 1: Update the FILTERS list for new flags**

Replace the existing FILTERS array:
```js
const FILTERS = [
  { key: "all", label: "All" },
  { key: "SUPPLY_LAPSED", label: "Supply Lapsed" },
  { key: "VISIT_REQUIRED", label: "Visit Required" },
  { key: "RENEWAL_CRITICAL", label: "Renewal Critical" },
  { key: "RENEWAL_ELEVATED", label: "Renewal Elevated" },
  { key: "RENEWAL_SOON", label: "Renewal Due" },
  { key: "RESUPPLY_READY", label: "Resupply Ready" },
  { key: "ACTIVE", label: "Active" },
  { key: "TRANSFER_PENDING", label: "Transfer" },
];
```

- [ ] **Step 2: Add expandable row state + DocStatusBar + ConfirmVisitModal integration**

At the top of `WorklistTable.jsx`, add import:
```js
import { useState } from "react";
import DocStatusBar from "./DocStatusBar";
import ConfirmVisitModal from "./ConfirmVisitModal";
```

In the `WorklistTable` component function, add state:
```js
const [expandedRow, setExpandedRow] = useState(null);
const [confirmingRecord, setConfirmingRecord] = useState(null);
const [localRecords, setLocalRecords] = useState(records);
```

Add a useEffect to sync when records prop changes:
```js
import { useState, useEffect } from "react";
// ...
useEffect(() => { setLocalRecords(records); }, [records]);
```

Add handler for confirmed visit (live update without page refresh):
```js
function handleVisitConfirmed(patientId, updatedRecord) {
  setLocalRecords(prev =>
    prev.map(r => r.patient_id === patientId ? { ...r, ...updatedRecord } : r)
      .sort((a, b) => b.priority_score - a.priority_score)
  );
}
```

- [ ] **Step 3: Update the table rows**

Replace the existing row render in `filtered.map(...)` with:
```jsx
{filtered.map((r, i) => {
  const hp = ["SUPPLY_LAPSED", "VISIT_REQUIRED", "RENEWAL_CRITICAL", "TRANSFER_PENDING"].includes(r.flag);
  const isExpanded = expandedRow === (r.patient_id + i);
  const showConfirmVisit = ["VISIT_REQUIRED", "RENEWAL_CRITICAL", "RENEWAL_ELEVATED", "RENEWAL_SOON"].includes(r.flag)
    || r.visit_date_confidence === "estimated";

  return (
    <>
      <tr
        key={r.patient_id + "-" + i}
        className={`border-b border-[var(--border-subtle)] transition-colors cursor-pointer hover:bg-[var(--row-hover)] ${
          hp
            ? dark
              ? "bg-[rgba(224,104,48,0.09)] hover:bg-[rgba(203,107,32,0.14)]"
              : "bg-[rgba(224,96,40,0.05)] hover:bg-[rgba(203,107,32,0.14)]"
            : ""
        }`}
        onClick={() => setExpandedRow(isExpanded ? null : (r.patient_id + i))}
      >
        <td className="px-[22px] py-[13px] align-middle">
          <div className={`font-mono text-[12.5px] font-bold ${hp ? "text-[var(--accent-text)]" : "text-[var(--text-secondary)]"}`}>
            {r.patient_id}
          </div>
          {i === 0 && (
            <div className="font-mono text-[9.5px] font-semibold tracking-[0.06em] text-[var(--accent-text)] mt-[2px]">
              ★ TOP PRIORITY
            </div>
          )}
          {r.visit_date_confidence === "estimated" && (
            <div className="text-[9px] text-[#CB6B20] mt-[2px]">Estimated visit date</div>
          )}
        </td>
        <td className="px-[22px] py-[13px] text-[13.5px] font-medium text-[var(--text-primary)] align-middle">
          {r.device_display || r.device_type}
        </td>
        <td className="px-[22px] py-[13px] text-[13px] text-[var(--text-secondary)] align-middle">
          {r.payer}
        </td>
        <td className="px-[22px] py-[13px] align-middle">
          {daysLabel(r.days_until_coverage_end)}
        </td>
        <td className="px-[22px] py-[13px] align-middle">
          <div className="flex items-center gap-[8px]">
            <Badge flag={r.flag} />
            {r.doc_state && <DocStatusBar docState={r.doc_state} />}
          </div>
          {r.reason && (
            <div className="text-[10.5px] text-[var(--text-muted)] mt-[4px] max-w-[260px] leading-[1.4]">
              {r.reason}
            </div>
          )}
        </td>
        <td className="px-[22px] py-[13px] align-middle">
          <span className={`font-mono text-[16px] font-medium ${scoreClass(r.priority_score)}`}>
            {r.priority_score}
          </span>
        </td>
        <td className="px-[22px] py-[13px] align-middle" onClick={e => e.stopPropagation()}>
          {showConfirmVisit ? (
            <button
              onClick={() => setConfirmingRecord(r)}
              className="bg-transparent border border-[var(--accent)] text-[var(--accent-text)] px-[13px] py-[5px] rounded-md text-xs cursor-pointer font-body whitespace-nowrap transition-all hover:bg-[rgba(203,107,32,0.1)]"
            >
              Confirm Visit →
            </button>
          ) : (
            <ActionButton flag={r.flag} />
          )}
        </td>
      </tr>

      {/* Expanded row — cascade + doc checklist */}
      {isExpanded && (
        <tr key={r.patient_id + "-expanded-" + i} className="bg-[var(--bg-elevated)]">
          <td colSpan={7} className="px-[32px] py-[16px]">
            {r.cascade && r.cascade.length > 0 && (
              <div className="mb-4">
                <div className="text-[11px] font-semibold text-[#CC2222] uppercase tracking-[0.06em] mb-2">
                  Documentation Cascade
                </div>
                <div className="text-[12px] text-[var(--text-secondary)] font-mono">
                  {r.cascade.join(" → ")}
                </div>
              </div>
            )}
            {r.doc_state && (
              <div>
                <div className="text-[11px] font-semibold text-[var(--text-muted)] uppercase tracking-[0.06em] mb-2">
                  Documentation Checklist
                </div>
                <div className="grid grid-cols-2 gap-y-[6px] gap-x-[20px] text-[12px]">
                  <DocItem label="SWO" value={r.doc_state.swo} />
                  <DocItem label="Qualifying Visit" value={r.doc_state.visit} />
                  <DocItem label="PECOS Enrollment" value={r.doc_state.pecos} />
                  <DocItem label="Prior Authorization" value={r.doc_state.pa} />
                  <DocItem label="Diagnosis on File" value={r.doc_state.diagnosis} />
                </div>
              </div>
            )}
          </td>
        </tr>
      )}
    </>
  );
})}
```

Add the `DocItem` helper component at the bottom of the file (before the closing):
```jsx
function DocItem({ label, value }) {
  const isGood = value && (
    value === "On File" || value === "Verified" || value === "Not Required" ||
    value.startsWith("Confirmed") || value.startsWith("Approved") || value === "N/A"
  );
  const isBad = value && (
    value === "Missing" || value === "Not Verified" || value === "Expired" ||
    value === "Denied" || value === "Required — Not Started"
  );
  const color = isGood ? "text-[#1A8040]" : isBad ? "text-[#CC2222]" : "text-[#CB6B20]";

  return (
    <div className="flex items-start gap-[6px]">
      <span className="text-[var(--text-muted)] w-[120px] shrink-0">{label}:</span>
      <span className={`font-medium ${color}`}>{value || "Unknown"}</span>
    </div>
  );
}
```

Add the modal at the end of the component return, before the last closing div:
```jsx
{confirmingRecord && (
  <ConfirmVisitModal
    record={confirmingRecord}
    onClose={() => setConfirmingRecord(null)}
    onConfirmed={handleVisitConfirmed}
  />
)}
```

Update `filtered` to use `localRecords` instead of `records` prop directly. Update the component signature to use localRecords for filtered:
```js
const filtered = activeFilter === "all"
  ? localRecords
  : localRecords.filter((r) => r.flag === activeFilter);
```

- [ ] **Step 4: Start dev server and verify the UI**

```bash
cd /Users/sttil-solutions/projects/signal/signal-ui && pnpm dev
```

Open http://localhost:5173. Upload one of the existing test CSVs. Verify:
- Worklist renders with new status labels
- DocStatusBar dots appear in each row
- Clicking a row expands to show doc checklist
- "Confirm Visit" button appears for RENEWAL_* and VISIT_REQUIRED rows
- Clicking Confirm Visit opens the modal
- Entering a date and saving closes the modal and updates the row live

- [ ] **Step 5: Fix any TypeErrors or import errors found during testing**

- [ ] **Step 6: Commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add signal-ui/src/components/WorklistTable.jsx signal-ui/src/lib/api.js
git commit -m "feat: WorklistTable — expandable rows, DocStatusBar, ConfirmVisitModal integration"
```

---

## Task 14: Placeholder cleanup + smoke test + export verification

- [ ] **Step 1: Remove any placeholder content from WorklistTable and App.jsx**

Read `signal-ui/src/App.jsx`. Check for:
- Any hardcoded sample data
- "Coming soon" or placeholder text
- Demo-only state that should be removed

Fix any found issues.

- [ ] **Step 2: Run smoke test against live backend**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/smoke_test.py -v
```
Fix any failures.

- [ ] **Step 3: End-to-end export test**

With the dev server running:
1. Upload `test-data/sample-batch-01-ok.csv`
2. Verify records load
3. Click Export
4. Verify the downloaded CSV opens correctly and contains the new status labels

- [ ] **Step 4: Run full test suite**

```bash
cd /Users/sttil-solutions/projects/signal && python -m pytest tests/ -v
```
All tests must pass.

- [ ] **Step 5: Final commit**

```bash
cd /Users/sttil-solutions/projects/signal
git add -u
git commit -m "fix: placeholder cleanup + smoke test passing + export verified"
```

---

## Task 15: Deploy frontend to Vercel

- [ ] **Step 1: Build locally to check for errors**

```bash
cd /Users/sttil-solutions/projects/signal/signal-ui && pnpm build
```
Expected: Build succeeds with no errors.

- [ ] **Step 2: Push to trigger Vercel deploy**

```bash
cd /Users/sttil-solutions/projects/signal && git push
```
Vercel auto-deploys from the connected git branch.

- [ ] **Step 3: Verify live at Vercel URL**

Open https://signal-ui-xi.vercel.app and repeat the end-to-end test from Task 14 Step 3 on the live URL.

---

## Self-Review — Spec Coverage Check

| Spec Section | Covered by Task |
|---|---|
| Visit date priority chain (CSV > Supabase > estimated) | Task 2 (coverage_calculator), Task 6 (normalizer) |
| Confirm Visit modal + live update | Task 11, 12, 13 |
| 90/60/45 priority tiers | Task 2 |
| SUPPLY_LAPSED / VISIT_REQUIRED labels | Task 2, Task 9 (Badge) |
| 5-item doc state machine | Task 3 |
| Cascade display | Task 3 (logic), Task 13 (UI) |
| 5-dot DocStatusBar | Task 10, 13 |
| Payer-dependent PA/PECOS requirements | Task 3, Task 4 |
| Transferred patient detection + status | Task 2, 6 |
| Confirmed visit persists across imports | Task 5 (Supabase), Task 7 (endpoint) |
| PA/NJ payer strings in payer_rules.json | Task 4 |
| Medicare Advantage distinguished from Medicare FFS | Task 4, 6 |
| Export verified | Task 14 |
| Placeholder removal | Task 14 |
| Browser smoke test | Task 14 |

**Not covered in this plan (separate plans):**
- CSV generator (pa-set, nj-set, state-change patients) → see csv-generator plan
- Legal docs (LOI + NDA) → see legal-docs plan
- Compliance docs (Privacy Policy etc.) → see compliance-docs plan
- Watcher Agent → demo-mention only, not deployed this cycle
