# Sub-project 6 — Workflow Automation & Inbox: Implementation Plan **Date:** 2026-06-20 **Status:** Ready **Branch:** `sp6-workflow-inbox` (created from `main` after SP5 is merged) **Spec:** `.superpowers/brainstorm/419-1781997857/spec.md` 21 tasks across 8 phases. TDD throughout. Each task ends with a green test run + commit. **Prerequisite:** SP5 (live-tail) must be merged to `main` before this plan runs. The first task verifies this and rebases the branch if needed. **Tech additions:** None — scoring uses stdlib only (`difflib` is *not* needed because patient control number match is exact, not fuzzy). No new pip deps, no new npm deps. --- ## Task 0: Verify SP5 is on `main` and create work branch **Files:** none - [ ] **Step 1: Confirm SP5 is merged** ```bash cd /Users/openclaw/dev/cyclone git fetch origin git log --oneline main -10 git log --oneline main | grep -E "merge:.*live-tail|feat:.*live-tail|merge.*tail" | head -3 ``` Expected: at least one merge commit referencing SP5 / live-tail on `main`. If absent, STOP — do not proceed until SP5 is merged. - [ ] **Step 2: Create the work branch** ```bash git checkout main git pull --ff-only git checkout -b sp6-workflow-inbox ``` - [ ] **Step 3: Commit** (empty commit to anchor the branch) ```bash git commit --allow-empty -m "chore(sp6): start workflow automation & inbox branch" ``` --- ## Phase 1 — Schema ## Task 1: Add `REJECTED` to `ClaimState` enum **Files:** - Modify: `backend/src/cyclone/db.py:124-131` - [ ] **Step 1: Add the enum value** Edit `backend/src/cyclone/db.py`, in the `ClaimState` class, add `REJECTED = "rejected"` between `RECEIVED` and `PAID`: ```python class ClaimState(str, enum.Enum): SUBMITTED = "submitted" RECEIVED = "received" REJECTED = "rejected" # NEW — set by 999 AK9 set-level R/E PAID = "paid" PARTIAL = "partial" DENIED = "denied" RECONCILED = "reconciled" REVERSED = "reversed" ``` - [ ] **Step 2: Run full backend suite, confirm green** ```bash cd backend && ../.venv/bin/pytest -q ``` Expected: existing tests still pass (enum addition is additive; no callers reference the new value yet). - [ ] **Step 3: Commit** ```bash git add backend/src/cyclone/db.py git commit -m "feat(sp6): add REJECTED to ClaimState enum" ``` --- ## Task 2: Migration 0003 — rejection columns + state-history index **Files:** - Create: `backend/src/cyclone/migrations/0003_rejections_and_state_history.sql` - [ ] **Step 1: Write the migration** ```sql -- version: 3 -- SP6 T2: 999 rejection tracking + state-history index for the Done-today lane. ALTER TABLE claims ADD COLUMN rejection_reason TEXT, ADD COLUMN rejected_at TIMESTAMP, ADD COLUMN resubmit_count INTEGER NOT NULL DEFAULT 0; CREATE INDEX ix_claims_state_changed_at ON claims(state, state_changed_at); ``` - [ ] **Step 2: Run the full suite to trigger the migration** ```bash cd backend && ../.venv/bin/pytest -q ``` Expected: green. The migration runner picks up `0003_*.sql` automatically and applies it on each test's fresh DB. - [ ] **Step 3: Verify the columns exist on the test DB** ```bash cd backend && python -c " from cyclone.db import init_db, get_sessionmaker init_db('sqlite:////tmp/sp6-t2-verify.db') S = get_sessionmaker()() with S() as s: rows = s.execute(s.bind.dialect.compiler()).fetchall() " 2>&1 | head -5 sqlite3 /tmp/sp6-t2-verify.db ".schema claims" | head -25 ``` Expected: schema includes `rejection_reason TEXT`, `rejected_at TIMESTAMP`, `resubmit_count INTEGER NOT NULL DEFAULT 0`, and the `ix_claims_state_changed_at` index. Clean up: `rm /tmp/sp6-t2-verify.db`. - [ ] **Step 4: Commit** ```bash git add backend/src/cyclone/migrations/0003_rejections_and_state_history.sql git commit -m "feat(sp6): migration 0003 — rejection columns + state-history index" ``` --- ## Phase 2 — 999 → Rejected state ## Task 3: `apply_999_rejections` helper (TDD) **Files:** - Create: `backend/src/cyclone/inbox_state.py` - Test: `backend/tests/test_inbox_state.py` - [ ] **Step 1: Write the failing test** ```python # backend/tests/test_inbox_state.py from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from cyclone.db import Base, Claim, ClaimState, init_db from cyclone.inbox_state import apply_999_rejections from cyclone.parsers.models_999 import ParseResult999, SetFunctionalGroupResponse, SetAcceptReject def _make_engine(): eng = create_engine("sqlite://") Base.metadata.create_all(eng) return eng def _make_claim(session, *, claim_id="CLP-1", patient_control_number="PCN-001", state=ClaimState.SUBMITTED): c = Claim( id=claim_id, batch_id="B-1", patient_control_number=patient_control_number, state=state, ) session.add(c) session.commit() return c def _make_999(*, claim_refs): """claim_refs: list of (patient_control_number, accept_code).""" return ParseResult999( envelope=None, # not used by apply_999_rejections set_responses=[ SetFunctionalGroupResponse( set_control_number=ref, set_accept_reject=SetAcceptReject(code=code), ik3_segments=[], ) for ref, code in claim_refs ], ) def test_rejection_moves_submitted_claim_to_rejected_state(): eng = _make_engine() S = sessionmaker(bind=eng)() claim = _make_claim(S, patient_control_number="PCN-001") result = apply_999_rejections( S, _make_999(claim_refs=[("PCN-001", "R")]), claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), ) assert result.matched == ["CLP-1"] assert result.orphans == [] S.refresh(claim) assert claim.state == ClaimState.REJECTED assert claim.rejected_at is not None assert "999" in claim.rejection_reason def test_accepted_set_leaves_claim_alone(): eng = _make_engine() S = sessionmaker(bind=eng)() claim = _make_claim(S, patient_control_number="PCN-002") result = apply_999_rejections( S, _make_999(claim_refs=[("PCN-002", "A")]), claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), ) assert result.matched == [] S.refresh(claim) assert claim.state == ClaimState.SUBMITTED assert claim.rejected_at is None def test_unknown_pcn_recorded_as_orphan_not_error(): eng = _make_engine() S = sessionmaker(bind=eng)() result = apply_999_rejections( S, _make_999(claim_refs=[("PCN-NONEXISTENT", "R")]), claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), ) assert result.matched == [] assert result.orphans == ["PCN-NONEXISTENT"] def test_already_rejected_claim_is_idempotent(): eng = _make_engine() S = sessionmaker(bind=eng)() claim = _make_claim(S, patient_control_number="PCN-003", state=ClaimState.REJECTED) result = apply_999_rejections( S, _make_999(claim_refs=[("PCN-003", "R")]), claim_lookup=lambda pcn: S.query(Claim).filter_by(patient_control_number=pcn).first(), ) assert result.matched == [] # no-op S.refresh(claim) assert claim.state == ClaimState.REJECTED ``` - [ ] **Step 2: Run the test, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -q ``` Expected: `ModuleNotFoundError: No module named 'cyclone.inbox_state'`. - [ ] **Step 3: Implement the module** ```python # backend/src/cyclone/inbox_state.py """999 ACK → claim state transitions for the Inbox Rejected lane.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Callable from sqlalchemy.orm import Session from cyclone.db import Claim, ClaimState @dataclass class Apply999Result: matched: list[str] = field(default_factory=list) # claim ids transitioned orphans: list[str] = field(default_factory=list) # PCNs we couldn't resolve def _build_reason(code: str, ik3_count: int) -> str: parts = [f"999 AK9 set-level {code}"] if ik3_count: parts.append(f"{ik3_count} IK3 segment error(s)") return "; ".join(parts) def apply_999_rejections( session: Session, parsed_999, *, claim_lookup: Callable[[str], Claim | None], ) -> Apply999Result: """For each set response with code R or E, look up the matching claim and move it to REJECTED. Idempotent on already-rejected claims. Args: session: SQLAlchemy session. parsed_999: a ParseResult999 (or any object with .set_responses). claim_lookup: callable from patient_control_number → Claim or None. Returns: Apply999Result with lists of matched claim ids and orphan PCNs. """ result = Apply999Result() now = datetime.now(timezone.utc) for sr in parsed_999.set_responses: code = sr.set_accept_reject.code if code not in ("R", "E"): continue claim = claim_lookup(sr.set_control_number) if claim is None: result.orphans.append(sr.set_control_number) continue if claim.state == ClaimState.REJECTED: # Idempotent: don't double-mutate. continue claim.state = ClaimState.REJECTED claim.rejected_at = now claim.rejection_reason = _build_reason(code, len(sr.ik3_segments or [])) result.matched.append(claim.id) if result.matched or result.orphans: session.commit() return result ``` - [ ] **Step 4: Run the test, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_state.py -v ``` Expected: 4 passed. - [ ] **Step 5: Run full backend suite, confirm still green** ```bash cd backend && ../.venv/bin/pytest -q ``` - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/inbox_state.py backend/tests/test_inbox_state.py git commit -m "feat(sp6): apply_999_rejections state helper" ``` --- ## Task 4: Wire 999 ingest → `apply_999_rejections` (TDD) **Files:** - Modify: `backend/src/cyclone/api.py:569-640` - Modify: `backend/tests/conftest.py` (only if needed for the event_bus fixture) - Test: `backend/tests/test_999_rejected_state.py` - [ ] **Step 1: Write the failing test** ```python # backend/tests/test_999_rejected_state.py import io import os import tempfile from fastapi.testclient import TestClient from cyclone.api import app from cyclone.db import init_db, Claim, ClaimState, get_sessionmaker def _seed_claim(*, claim_id="CLP-1", patient_control_number="PCN-001"): S = get_sessionmaker()() with S() as s: s.add(Claim(id=claim_id, batch_id="B-1", patient_control_number=patient_control_number, state=ClaimState.SUBMITTED)) s.commit() def test_999_set_rejection_moves_claim_to_rejected_state(client: TestClient): _seed_claim() # Minimal 999: 1 set, accept_reject=R, set_control_number matches the PCN. raw = ( "ISA*00* *00* *ZZ*PAYER01 *ZZ*PROVIDER01 " f"*250615*0900*^*00501*000000001*0*P*:~" "GS*FA*PAYER01*PROVIDER01*20250615*0900*1*X*005010X231A1~" "ST*999*0001~" "AK1*HC*0001~" f"AK2*PCN-001*0001~" "IK3*CLM*001*8~" "IK4*2**7~" "IK5*R*5~" "AK9*R*1*1*0~" "SE*8*0001~" "GE*1*1~" "IEA*0*000000001~" ) r = client.post( "/api/parse-999", files={"file": ("sample.999", io.BytesIO(raw.encode()), "text/plain")}, ) assert r.status_code == 200, r.text S = get_sessionmaker()() with S() as s: c = s.get(Claim, "CLP-1") assert c.state == ClaimState.REJECTED assert c.rejected_at is not None assert "999" in c.rejection_reason ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v ``` Expected: FAIL — claim state stays `submitted`. - [ ] **Step 3: Wire the helper into the endpoint** In `backend/src/cyclone/api.py`, after `result = parse_999_text(text, ...)` and before `store.add_ack(...)`, add: ```python from cyclone.inbox_state import apply_999_rejections from cyclone.db import Claim ``` Then, just before the `store.add_ack(...)` call, insert: ```python # SP6 T4: move claims whose 999 set was rejected into ClaimState.REJECTED. S = get_sessionmaker()() with S() as session: def _lookup(pcn: str): return session.query(Claim).filter_by(patient_control_number=pcn).first() _rejection_result = apply_999_rejections(session, result, claim_lookup=_lookup) if _rejection_result.matched: # Publish events so the Inbox tail refetches. bus = _get_event_bus() for cid in _rejection_result.matched: bus.publish("claim.rejected", {"claim_id": cid}) if _rejection_result.orphans: log.warning("999 had %d orphan set refs: %s", len(_rejection_result.orphans), _rejection_result.orphans[:5]) ``` (`get_sessionmaker`, `_get_event_bus`, and `log` are already in scope in `api.py`.) - [ ] **Step 4: Run, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_999_rejected_state.py -v ``` Expected: 1 passed. - [ ] **Step 5: Full suite** ```bash cd backend && ../.venv/bin/pytest -q ``` Expected: all green. - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/api.py backend/tests/test_999_rejected_state.py git commit -m "feat(sp6): wire 999 ingest → claim rejected state" ``` --- ## Phase 3 — Scoring + lane computation ## Task 5: Scoring module (TDD) **Files:** - Create: `backend/src/cyclone/scoring.py` - Test: `backend/tests/test_scoring.py` - [ ] **Step 1: Write the failing test** ```python # backend/tests/test_scoring.py from dataclasses import dataclass from datetime import date from decimal import Decimal import pytest from cyclone.scoring import ScoreBreakdown, score_pair @dataclass class FakeClaim: patient_control_number: str = "PCN-001" service_date_from: date | None = date(2026, 6, 10) charge_amount: Decimal = Decimal("100.00") provider_npi: str | None = "1234567890" @dataclass class FakeRemit: patient_control_number: str = "PCN-001" service_date: date | None = date(2026, 6, 10) charge_amount: Decimal = Decimal("100.00") rendering_provider_npi: str | None = "1234567890" def test_perfect_match_scores_100(): s = score_pair(FakeClaim(), FakeRemit()) assert s.total == 100 assert s.patient == 1.0 assert s.date == 1.0 assert s.amount == 1.0 assert s.provider == 1.0 def test_date_within_3_days_scores_partial(): s = score_pair( FakeClaim(service_date_from=date(2026, 6, 10)), FakeRemit(service_date=date(2026, 6, 12)), ) assert s.date == pytest.approx(1 - 2/3) # 0.333 def test_date_beyond_3_days_scores_zero(): s = score_pair( FakeClaim(service_date_from=date(2026, 6, 10)), FakeRemit(service_date=date(2026, 6, 14)), ) assert s.date == 0.0 def test_amount_within_10pct_scores_partial(): s = score_pair( FakeClaim(charge_amount=Decimal("100.00")), FakeRemit(charge_amount=Decimal("108.00")), ) assert s.amount == pytest.approx(1 - 8/10) def test_amount_beyond_10pct_scores_zero(): s = score_pair( FakeClaim(charge_amount=Decimal("100.00")), FakeRemit(charge_amount=Decimal("120.00")), ) assert s.amount == 0.0 def test_patient_normalized_exact_match(): s = score_pair( FakeClaim(patient_control_number="PCN-001"), FakeRemit(patient_control_number=" pcn-001 "), ) assert s.patient == 1.0 def test_patient_mismatch_scores_zero(): s = score_pair( FakeClaim(patient_control_number="PCN-001"), FakeRemit(patient_control_number="PCN-002"), ) assert s.patient == 0.0 def test_missing_provider_npi_scores_zero(): s = score_pair( FakeClaim(provider_npi=None), FakeRemit(rendering_provider_npi="1234567890"), ) assert s.provider == 0.0 def test_missing_service_date_scores_zero(): s = score_pair( FakeClaim(service_date_from=None), FakeRemit(service_date=date(2026, 6, 10)), ) assert s.date == 0.0 def test_threshold_boundaries(): """Tier boundaries: ≥75 strong, 50–74 weak, <50 hidden.""" # All fields 0 except amount = 0.75 (exact) → 15 pts → total 15 → hidden s = score_pair( FakeClaim( patient_control_number="X", # 0 service_date_from=date(2026, 6, 10), charge_amount=Decimal("100.00"), provider_npi="1234567890", ), FakeRemit( patient_control_number="Y", # 0 service_date=date(2026, 6, 10), charge_amount=Decimal("100.00"), rendering_provider_npi="1234567890", ), ) assert 50 <= s.total < 75 # weak tier ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_scoring.py -q ``` - [ ] **Step 3: Implement** ```python # backend/src/cyclone/scoring.py """Transparent 4-field weighted scoring for Inbox candidate pairs. Weights (sum to 100): - patient control number match (exact, normalized) 40 - service date proximity (±3 days linear decay) 25 - charge amount proximity (±10% linear decay) 20 - provider NPI exact match 15 """ from __future__ import annotations from dataclasses import dataclass from datetime import date from decimal import Decimal from typing import Protocol class _ScorableClaim(Protocol): patient_control_number: str service_date_from: date | None charge_amount: Decimal provider_npi: str | None class _ScorableRemit(Protocol): patient_control_number: str service_date: date | None charge_amount: Decimal rendering_provider_npi: str | None @dataclass(frozen=True) class ScoreBreakdown: patient: float date: float amount: float provider: float @property def total(self) -> int: return round( self.patient * 40 + self.date * 25 + self.amount * 20 + self.provider * 15 ) @property def tier(self) -> str: if self.total >= 75: return "strong" if self.total >= 50: return "weak" return "hidden" def _norm_pcn(s: str) -> str: return (s or "").strip().lower().lstrip("0") def _date_score(claim_date: date | None, remit_date: date | None) -> float: if claim_date is None or remit_date is None: return 0.0 delta = abs((claim_date - remit_date).days) if delta > 3: return 0.0 return 1.0 - (delta / 3.0) def _amount_score(claim_amt: Decimal, remit_amt: Decimal) -> float: if claim_amt <= 0: return 0.0 diff = abs(claim_amt - remit_amt) threshold = claim_amt * Decimal("0.10") if diff > threshold: return 0.0 # Normalize diff within threshold to 0..1 return float(1.0 - (diff / threshold)) def _provider_score(claim_npi: str | None, remit_npi: str | None) -> float: if not claim_npi or not remit_npi: return 0.0 return 1.0 if claim_npi.strip() == remit_npi.strip() else 0.0 def score_pair(claim: _ScorableClaim, remit: _ScorableRemit) -> ScoreBreakdown: return ScoreBreakdown( patient=1.0 if _norm_pcn(claim.patient_control_number) == _norm_pcn(remit.patient_control_number) and _norm_pcn(claim.patient_control_number) else 0.0, date=_date_score(claim.service_date_from, remit.service_date), amount=_amount_score(claim.charge_amount, remit.charge_amount), provider=_provider_score(claim.provider_npi, remit.rendering_provider_npi), ) ``` - [ ] **Step 4: Run, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_scoring.py -v ``` Expected: 10 passed. - [ ] **Step 5: Commit** ```bash git add backend/src/cyclone/scoring.py backend/tests/test_scoring.py git commit -m "feat(sp6): 4-field weighted scoring module" ``` --- ## Task 6: Lane computation module (TDD) **Files:** - Create: `backend/src/cyclone/inbox_lanes.py` - Test: `backend/tests/test_inbox_lanes.py` - [ ] **Step 1: Write the failing test** ```python # backend/tests/test_inbox_lanes.py from datetime import datetime, timedelta, timezone from decimal import Decimal import pytest from cyclone.db import init_db, Base, Claim, ClaimState, Remittance, get_sessionmaker from cyclone.inbox_lanes import compute_lanes @pytest.fixture def session(): eng = init_db("sqlite://") S = get_sessionmaker()() yield S def _add_claim(S, *, claim_id, pcn="PCN", state=ClaimState.SUBMITTED, charge=Decimal("100.00"), provider_npi="1234567890", service_date=None, payer_id="PAYER-A"): with S() as s: c = Claim( id=claim_id, batch_id="B-1", patient_control_number=pcn, state=state, charge_amount=charge, provider_npi=provider_npi, payer_id=payer_id, service_date_from=service_date, ) s.add(c) s.commit() def _add_remit(S, *, remit_id, pcn="PCN", charge=Decimal("100.00"), rendering_npi="1234567890", service_date=None, payer_id="PAYER-A", claim_id=None): with S() as s: r = Remittance( id=remit_id, batch_id="B-1", payer_claim_control_number=pcn, charge_amount=charge, claim_id=claim_id, ) # Store the auxiliary fields via raw_json for round-trip; the parser # already populates these on a real parse. r.raw_json = { "patient_control_number": pcn, "service_date": service_date.isoformat() if service_date else None, "rendering_provider_npi": rendering_npi, "payer_id": payer_id, } s.add(r) s.commit() def test_rejected_lane_includes_rejected_claims(session): _add_claim(session, claim_id="C1", state=ClaimState.REJECTED) lanes = compute_lanes(session, dismissed_pairs=set()) assert "C1" in {r["id"] for r in lanes.rejected} def test_unmatched_lane_includes_submitted_claims_with_no_remit(session): _add_claim(session, claim_id="C1", state=ClaimState.SUBMITTED) lanes = compute_lanes(session, dismissed_pairs=set()) assert "C1" in {r["id"] for r in lanes.unmatched} def test_unmatched_remit_appears_with_candidates(session): _add_claim(session, claim_id="C1", pcn="PCN-1", service_date=None) _add_remit(session, remit_id="R1", pcn="PCN-1") # exact match → candidate lanes = compute_lanes(session, dismissed_pairs=set()) assert any(r["id"] == "R1" for r in lanes.candidates) def test_candidate_below_threshold_is_hidden(session): _add_claim(session, claim_id="C1", pcn="A", charge=Decimal("100.00")) _add_remit(session, remit_id="R1", pcn="Z", charge=Decimal("999.00")) # everything mismatched lanes = compute_lanes(session, dismissed_pairs=set()) assert not any(r["id"] == "R1" for r in lanes.candidates) def test_dismissed_pair_excluded_from_candidates(session): _add_claim(session, claim_id="C1", pcn="PCN-1") _add_remit(session, remit_id="R1", pcn="PCN-1") lanes = compute_lanes(session, dismissed_pairs={frozenset({"C1", "R1"})}) assert not any(r["id"] == "R1" for r in lanes.candidates) def test_done_today_includes_recent_terminal_states(session): # Need a recent state_changed_at. Set it explicitly. with session() as s: s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", state=ClaimState.PAID, state_changed_at=datetime.now(timezone.utc) - timedelta(hours=2))) s.commit() _add_claim(session, claim_id="C2", state=ClaimState.PAID) # Force C2's state_changed_at to be old. with session() as s: c = s.get(Claim, "C2") c.state_changed_at = datetime.now(timezone.utc) - timedelta(hours=30) s.commit() lanes = compute_lanes(session, dismissed_pairs=set()) ids = {r["id"] for r in lanes.done_today} assert "C1" in ids assert "C2" not in ids ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -q ``` Expected: ModuleNotFoundError for `cyclone.inbox_lanes`. - [ ] **Step 3: Implement the module** ```python # backend/src/cyclone/inbox_lanes.py """Compute the four Inbox lanes from the DB on read.""" from __future__ import annotations from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from typing import Iterable from sqlalchemy.orm import Session from cyclone.db import Claim, ClaimState, Remittance from cyclone.scoring import score_pair, ScoreBreakdown @dataclass class Lanes: rejected: list[dict] = field(default_factory=list) candidates: list[dict] = field(default_factory=list) unmatched: list[dict] = field(default_factory=list) done_today: list[dict] = field(default_factory=list) def _claim_to_row(c: Claim, *, kind: str, score: ScoreBreakdown | None = None) -> dict: return { "id": c.id, "kind": kind, "patient_control_number": c.patient_control_number, "charge_amount": float(c.charge_amount), "payer_id": c.payer_id, "provider_npi": c.provider_npi, "state": c.state.value if hasattr(c.state, "value") else str(c.state), "rejection_reason": c.rejection_reason, "rejected_at": c.rejected_at.isoformat() if c.rejected_at else None, "service_date_from": c.service_date_from.isoformat() if c.service_date_from else None, "score": score.total if score else None, "score_tier": score.tier if score else None, "score_breakdown": { "patient": score.patient, "date": score.date, "amount": score.amount, "provider": score.provider, } if score else None, } def _remit_to_row(r: Remittance, *, candidates: list[tuple[Claim, ScoreBreakdown]]) -> dict: raw = r.raw_json or {} return { "id": r.id, "kind": "remit", "payer_claim_control_number": r.payer_claim_control_number, "charge_amount": float(r.charge_amount), "payer_id": raw.get("payer_id"), "rendering_provider_npi": raw.get("rendering_provider_npi"), "service_date": raw.get("service_date"), "candidates": [ { "claim_id": c.id, "score": s.total, "tier": s.tier, "breakdown": { "patient": s.patient, "date": s.date, "amount": s.amount, "provider": s.provider, }, } for c, s in candidates[:5] ], } def _remit_score_fields(r: Remittance) -> dict: raw = r.raw_json or {} # The 835 parser stores fields on the ORM model when present. For the # fields not on the model (service_date, rendering_provider_npi, payer_id, # patient_control_number), pull from raw_json. return { "patient_control_number": raw.get("patient_control_number") or r.payer_claim_control_number, "service_date": raw.get("service_date"), "charge_amount": r.charge_amount, "rendering_provider_npi": raw.get("rendering_provider_npi"), } class _RemitScoringShim: """Adapt a Remittance to the duck-typed protocol score_pair expects.""" def __init__(self, r: Remittance): f = _remit_score_fields(r) self.patient_control_number = f["patient_control_number"] self.service_date = _parse_iso_date(f["service_date"]) self.charge_amount = f["charge_amount"] self.rendering_provider_npi = f["rendering_provider_npi"] def _parse_iso_date(s: str | None): if not s: return None try: return datetime.fromisoformat(s).date() except (ValueError, TypeError): return None def compute_lanes(session: Session, *, dismissed_pairs: Iterable[frozenset]) -> Lanes: lanes = Lanes() dismissed = set(dismissed_pairs) # --- Rejected --- for c in session.query(Claim).filter(Claim.state == ClaimState.REJECTED).all(): lanes.rejected.append(_claim_to_row(c, kind="claim")) # --- Done today --- cutoff = datetime.now(timezone.utc) - timedelta(hours=24) terminal_states = { ClaimState.PAID, ClaimState.PARTIAL, ClaimState.DENIED, ClaimState.RECONCILED, ClaimState.REVERSED, } for c in session.query(Claim).filter( Claim.state.in_(terminal_states), Claim.state_changed_at >= cutoff, ).all(): lanes.done_today.append(_claim_to_row(c, kind="claim")) # --- Unmatched (claims still submitted) --- for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all(): lanes.unmatched.append(_claim_to_row(c, kind="claim")) # --- Candidates (remits not yet matched, with scoreable claims) --- unmatched_remits = session.query(Remittance).filter( Remittance.claim_id.is_(None), ).all() submitted_claims = { c.patient_control_number: c for c in session.query(Claim).filter(Claim.state == ClaimState.SUBMITTED).all() } for r in unmatched_remits: if r.claim_id: continue candidates: list[tuple[Claim, ScoreBreakdown]] = [] shim = _RemitScoringShim(r) for c in submitted_claims.values(): if c.payer_id and r.raw_json and (r.raw_json.get("payer_id") != c.payer_id): continue score = score_pair(c, shim) if score.tier == "hidden": continue if frozenset({c.id, r.id}) in dismissed: continue candidates.append((c, score)) candidates.sort(key=lambda cs: -cs[1].total) if not candidates: continue lanes.candidates.append(_remit_to_row(r, candidates=candidates)) return lanes ``` - [ ] **Step 4: Run, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_lanes.py -v ``` Expected: 6 passed (some may need adjustment depending on `state_changed_at` defaults in your model — see note in commit step). - [ ] **Step 5: Run full suite** ```bash cd backend && ../.venv/bin/pytest -q ``` Expected: green. If `Claim.state_changed_at` doesn't exist on the ORM model, add it in `db.py` (mapped `DateTime(timezone=True)`, server default `now()`) via a follow-up commit — but most likely it already exists from SP3/SP4. - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/inbox_lanes.py backend/tests/test_inbox_lanes.py git commit -m "feat(sp6): compute_lanes — 4 lanes on read with scoring" ``` --- ## Phase 4 — API endpoints ## Task 7: `GET /api/inbox/lanes` (TDD) **Files:** - Modify: `backend/src/cyclone/api.py` (add new endpoint near other GET endpoints) - Test: `backend/tests/test_inbox_endpoints.py` (new file) - [ ] **Step 1: Write the failing test** ```python # backend/tests/test_inbox_endpoints.py from fastapi.testclient import TestClient def test_lanes_endpoint_returns_four_keys(client: TestClient): r = client.get("/api/inbox/lanes") assert r.status_code == 200 body = r.json() assert set(body.keys()) == {"rejected", "candidates", "unmatched", "done_today"} for v in body.values(): assert isinstance(v, list) ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q ``` - [ ] **Step 3: Add the endpoint** In `backend/src/cyclone/api.py`, near the other GET endpoints, add: ```python from cyclone.inbox_lanes import compute_lanes @app.get("/api/inbox/lanes") def inbox_lanes(): S = get_sessionmaker()() dismissed_pairs = getattr(app.state, "dismissed_pairs", set()) with S() as session: lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) return { "rejected": lanes.rejected, "candidates": lanes.candidates, "unmatched": lanes.unmatched, "done_today": lanes.done_today, } ``` Also ensure `dismissed_pairs` is initialized on `app.state` in `conftest.py` (or in the lifespan if there is one): ```python # backend/tests/conftest.py — extend the existing autouse fixture: if not hasattr(app.state, "dismissed_pairs"): app.state.dismissed_pairs = set() ``` - [ ] **Step 4: Run, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v ``` - [ ] **Step 5: Full suite** ```bash cd backend && ../.venv/bin/pytest -q ``` - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/api.py backend/tests/conftest.py backend/tests/test_inbox_endpoints.py git commit -m "feat(sp6): GET /api/inbox/lanes" ``` --- ## Task 8: POST match + dismiss endpoints (TDD) **Files:** - Modify: `backend/src/cyclone/api.py` - Modify: `backend/tests/test_inbox_endpoints.py` (append) - [ ] **Step 1: Write the failing tests** Append to `backend/tests/test_inbox_endpoints.py`: ```python def test_match_endpoint_links_remit_to_claim(client: TestClient): # Seed from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState S = get_sessionmaker()() with S() as s: s.add(Claim(id="C1", batch_id="B-1", patient_control_number="PCN-1", state=ClaimState.SUBMITTED)) s.add(Remittance(id="R1", batch_id="B-1", payer_claim_control_number="PCN-1")) s.commit() r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"}) assert r.status_code == 200 with S() as s: c = s.get(Claim, "C1") assert c.matched_remittance_id == "R1" def test_match_409_if_claim_already_matched(client: TestClient): from cyclone.db import init_db, get_sessionmaker, Claim, Remittance, ClaimState S = get_sessionmaker()() with S() as s: s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", state=ClaimState.SUBMITTED, matched_remittance_id="R-OTHER")) s.add(Remittance(id="R1", batch_id="B-1", payer_claim_control_number="X")) s.commit() r = client.post("/api/inbox/candidates/R1/match", json={"claim_id": "C1"}) assert r.status_code == 409 assert "current_state" in r.json() def test_dismiss_endpoint_adds_pair_to_session_set(client: TestClient): r = client.post( "/api/inbox/candidates/dismiss", json={"pairs": [{"claim_id": "C1", "remit_id": "R1"}]}, ) assert r.status_code == 200 dismissed = client.app.state.dismissed_pairs assert frozenset({"C1", "R1"}) in dismissed ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q ``` - [ ] **Step 3: Implement both endpoints** In `backend/src/cyclone/api.py`: ```python from fastapi import HTTPException from cyclone.db import Claim, ClaimState, Remittance @app.post("/api/inbox/candidates/{remit_id}/match") def inbox_match_candidate(remit_id: str, body: dict): claim_id = body.get("claim_id") if not claim_id: raise HTTPException(400, "claim_id required") S = get_sessionmaker()() with S() as s: claim = s.get(Claim, claim_id) remit = s.get(Remittance, remit_id) if claim is None or remit is None: raise HTTPException(404, "claim or remit not found") if claim.matched_remittance_id and claim.matched_remittance_id != remit_id: raise HTTPException( 409, detail={ "error": "claim_already_matched", "current_state": claim.state.value if hasattr(claim.state, "value") else str(claim.state), "matched_remittance_id": claim.matched_remittance_id, }, ) claim.matched_remittance_id = remit_id claim.claim_id = remit_id # back-ref; safe no-op on already linked s.commit() bus = _get_event_bus() bus.publish("claim.matched", {"claim_id": claim_id, "remit_id": remit_id}) return {"ok": True} @app.post("/api/inbox/candidates/dismiss") def inbox_dismiss_candidates(body: dict): pairs = body.get("pairs") or [] if not hasattr(app.state, "dismissed_pairs"): app.state.dismissed_pairs = set() for p in pairs: cid = p.get("claim_id") rid = p.get("remit_id") if cid and rid: app.state.dismissed_pairs.add(frozenset({cid, rid})) return {"ok": True, "dismissed_count": len(pairs)} ``` (Note: depending on the ORM model, `claim.claim_id` may not exist — if so, just omit that line; the `matched_remittance_id` set is what matters.) - [ ] **Step 4: Run, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v ``` - [ ] **Step 5: Full suite** ```bash cd backend && ../.venv/bin/pytest -q ``` - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py git commit -m "feat(sp6): manual match + dismiss endpoints" ``` --- ## Task 9: POST `/api/inbox/rejected/resubmit` (TDD) **Files:** - Modify: `backend/src/cyclone/api.py` - Modify: `backend/tests/test_inbox_endpoints.py` - [ ] **Step 1: Append the failing test** ```python def test_resubmit_moves_rejected_to_submitted_and_increments_count(client: TestClient): from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState S = get_sessionmaker()() with S() as s: s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", state=ClaimState.REJECTED, rejection_reason="old", resubmit_count=2)) s.commit() r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]}) assert r.status_code == 200, r.text with S() as s: c = s.get(Claim, "C1") assert c.state == ClaimState.SUBMITTED assert c.rejection_reason is None assert c.resubmit_count == 3 def test_resubmit_409_for_non_rejected_claim(client: TestClient): from cyclone.db import init_db, get_sessionmaker, Claim, ClaimState S = get_sessionmaker()() with S() as s: s.add(Claim(id="C1", batch_id="B-1", patient_control_number="X", state=ClaimState.SUBMITTED)) s.commit() r = client.post("/api/inbox/rejected/resubmit", json={"claim_ids": ["C1"]}) assert r.status_code == 409 ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q ``` - [ ] **Step 3: Implement** ```python @app.post("/api/inbox/rejected/resubmit") def inbox_resubmit_rejected(body: dict): ids = body.get("claim_ids") or [] if not ids: raise HTTPException(400, "claim_ids required") S = get_sessionmaker()() accepted = [] conflicts = [] with S() as s: for cid in ids: c = s.get(Claim, cid) if c is None: continue if c.state != ClaimState.REJECTED: conflicts.append({ "claim_id": cid, "current_state": c.state.value if hasattr(c.state, "value") else str(c.state), }) continue c.state = ClaimState.SUBMITTED c.rejection_reason = None c.rejected_at = None c.resubmit_count = (c.resubmit_count or 0) + 1 accepted.append(cid) s.commit() bus = _get_event_bus() for cid in accepted: bus.publish("claim.resubmitted", {"claim_id": cid}) return {"ok": True, "resubmitted": accepted, "conflicts": conflicts} ``` - [ ] **Step 4: Run, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v ``` - [ ] **Step 5: Full suite** ```bash cd backend && ../.venv/bin/pytest -q ``` - [ ] **Step 6: Commit** ```bash git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py git commit -m "feat(sp6): bulk resubmit rejected claims" ``` --- ## Task 10: GET `/api/inbox/export.csv` (TDD) **Files:** - Modify: `backend/src/cyclone/api.py` - Modify: `backend/tests/test_inbox_endpoints.py` - [ ] **Step 1: Append the failing test** ```python def test_export_csv_streams_rows_with_csv_content_type(client: TestClient): r = client.get("/api/inbox/export.csv?lane=rejected") assert r.status_code == 200 assert "text/csv" in r.headers["content-type"] # Body should at least include the header row. body = r.text assert "id" in body.splitlines()[0] ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -q ``` - [ ] **Step 3: Implement** ```python import csv import io from fastapi.responses import StreamingResponse @app.get("/api/inbox/export.csv") def inbox_export_csv(lane: str): if lane not in {"rejected", "candidates", "unmatched", "done_today"}: raise HTTPException(400, f"unknown lane: {lane}") S = get_sessionmaker()() dismissed_pairs = getattr(app.state, "dismissed_pairs", set()) with S() as session: lanes = compute_lanes(session, dismissed_pairs=dismissed_pairs) rows = getattr(lanes, lane) buf = io.StringIO() writer = csv.writer(buf) writer.writerow([ "id", "kind", "patient_control_number", "charge_amount", "payer_id", "provider_npi", "state", "rejection_reason", "service_date_from", "score", ]) for r in rows: writer.writerow([ r.get("id") or r.get("payer_claim_control_number"), r.get("kind"), r.get("patient_control_number"), r.get("charge_amount"), r.get("payer_id"), r.get("provider_npi") or r.get("rendering_provider_npi"), r.get("state"), r.get("rejection_reason"), r.get("service_date_from") or r.get("service_date"), r.get("score"), ]) buf.seek(0) return StreamingResponse( iter([buf.getvalue()]), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="inbox-{lane}.csv"'}, ) ``` - [ ] **Step 4: Run, confirm PASS** ```bash cd backend && ../.venv/bin/pytest tests/test_inbox_endpoints.py -v ``` - [ ] **Step 5: Full suite + commit** ```bash cd backend && ../.venv/bin/pytest -q git add backend/src/cyclone/api.py backend/tests/test_inbox_endpoints.py git commit -m "feat(sp6): CSV export endpoint" ``` --- ## Phase 5 — Frontend scaffolding ## Task 11: API client + types **Files:** - Modify: `src/lib/api.ts` (append) - Modify: `src/lib/api.test.ts` (or create `src/lib/inbox-api.test.ts`) - [ ] **Step 1: Write the failing test** ```typescript // src/lib/inbox-api.test.ts import { describe, expect, it, vi } from "vitest"; import { fetchInboxLanes, matchCandidate, dismissCandidates, resubmitRejected, exportInboxCsv } from "./inbox-api"; describe("inbox-api", () => { it("fetchInboxLanes hits /api/inbox/lanes", async () => { const fetchSpy = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }), }); vi.stubGlobal("fetch", fetchSpy); const lanes = await fetchInboxLanes(); expect(fetchSpy).toHaveBeenCalledWith("/api/inbox/lanes", expect.anything()); expect(lanes.rejected).toEqual([]); vi.unstubAllGlobals(); }); }); ``` - [ ] **Step 2: Run, confirm FAIL** ```bash cd . && npx vitest run src/lib/inbox-api.test.ts ``` - [ ] **Step 3: Implement** ```typescript // src/lib/inbox-api.ts export type ScoreBreakdown = { patient: number; date: number; amount: number; provider: number; }; export type ScoreTier = "strong" | "weak" | "hidden"; export type InboxClaimRow = { id: string; kind: "claim"; patient_control_number: string; charge_amount: number; payer_id: string | null; provider_npi: string | null; state: string; rejection_reason: string | null; rejected_at: string | null; service_date_from: string | null; score?: number | null; score_tier?: ScoreTier | null; score_breakdown?: ScoreBreakdown | null; }; export type InboxCandidateRow = { id: string; kind: "remit"; payer_claim_control_number: string; charge_amount: number; payer_id: string | null; rendering_provider_npi: string | null; service_date: string | null; candidates: Array<{ claim_id: string; score: number; tier: ScoreTier; breakdown: ScoreBreakdown; }>; }; export type InboxLanes = { rejected: InboxClaimRow[]; candidates: InboxCandidateRow[]; unmatched: InboxClaimRow[]; done_today: InboxClaimRow[]; }; async function getJson(url: string): Promise { const r = await fetch(url); if (!r.ok) throw new Error(`${r.status} ${url}`); return (await r.json()) as T; } export async function fetchInboxLanes(): Promise { return getJson("/api/inbox/lanes"); } export async function matchCandidate(remitId: string, claimId: string): Promise { const r = await fetch(`/api/inbox/candidates/${encodeURIComponent(remitId)}/match`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ claim_id: claimId }), }); if (!r.ok) throw new Error(`match failed: ${r.status}`); } export async function dismissCandidates(pairs: Array<{ claim_id: string; remit_id: string }>): Promise { const r = await fetch("/api/inbox/candidates/dismiss", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ pairs }), }); if (!r.ok) throw new Error(`dismiss failed: ${r.status}`); } export async function resubmitRejected(claimIds: string[]): Promise { const r = await fetch("/api/inbox/rejected/resubmit", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ claim_ids: claimIds }), }); if (!r.ok) throw new Error(`resubmit failed: ${r.status}`); } export function exportInboxCsvUrl(lane: "rejected" | "candidates" | "unmatched" | "done_today"): string { return `/api/inbox/export.csv?lane=${lane}`; } ``` - [ ] **Step 4: Run, confirm PASS** ```bash npx vitest run src/lib/inbox-api.test.ts ``` - [ ] **Step 5: Commit** ```bash git add src/lib/inbox-api.ts src/lib/inbox-api.test.ts git commit -m "feat(sp6): inbox api client + types" ``` --- ## Task 12: Ticker Tape styling primitives **Files:** - Modify: `src/index.css` (or `src/styles/tokens.css` if it exists — check the project structure) - Modify: `tailwind.config.js` (extend theme) - [ ] **Step 1: Inspect existing token files** ```bash ls src/*.css src/styles 2>/dev/null; cat src/index.css 2>/dev/null | head -30; cat tailwind.config.js 2>/dev/null | head -40 ``` Read enough to see how colors are currently organized. Match the existing pattern. - [ ] **Step 2: Add the Ticker Tape tokens** Add to the appropriate CSS file (or Tailwind theme extension). Three-color accent + dark base: ```css /* src/styles/ticker-tape.css */ :root { --tt-bg: #0a0e14; --tt-bg-elev: #11161f; --tt-ink: #e6e1d3; --tt-ink-dim: #8a8b8e; --tt-amber: #f4b942; /* action needed */ --tt-oxblood: #8b2c2c; /* rejection */ --tt-ink-blue: #2c4a6e; /* neutral / waiting */ --tt-muted: #4a4d54; } @font-face { font-family: "IBM Plex Serif"; font-display: swap; src: url("https://fonts.gstatic.com/s/ibmplexserif/v19/jizDREVItHgc8qDIbSTKq4XKVUaaRA0od9b6XQ.woff2") format("woff2"); font-weight: 400 700; } @font-face { font-family: "IBM Plex Mono"; font-display: swap; src: url("https://fonts.gstatic.com/s/ibmplexmono/v19/-F63fjptAgt5VM-kVkqdyU8n5igg1l9kn-s.woff2") format("woff2"); font-weight: 400 700; } ``` (If the project uses Tailwind theme.extend, prefer extending `theme.extend.colors` and `theme.extend.fontFamily` over CSS variables.) - [ ] **Step 3: Smoke test** ```bash npx tsc --noEmit ``` Expected: no errors. - [ ] **Step 4: Commit** ```bash git add src/index.css src/styles/ticker-tape.css tailwind.config.js git commit -m "feat(sp6): Ticker Tape color + typography tokens" ``` --- ## Task 13: `/inbox` route + nav link **Files:** - Modify: `src/App.tsx` - Modify: `src/components/Sidebar.tsx` (or whatever the nav component is) - [ ] **Step 1: Find the route table and nav links** ```bash grep -n "Route\|path=" src/App.tsx | head -10 grep -n "Dashboard\|/claims\|/remittances" src/components/*.tsx | head -10 ``` - [ ] **Step 2: Add the route and link** In the route table, add (position: second, after Dashboard): ```tsx import Inbox from "./pages/Inbox"; // ... } /> ``` In the sidebar, add a link: ```tsx Inbox ``` - [ ] **Step 3: Create the placeholder Inbox page so the route resolves** ```tsx // src/pages/Inbox.tsx export default function Inbox() { return
Inbox — coming up in T14–T17
; } ``` - [ ] **Step 4: Smoke test** ```bash npx tsc --noEmit && npx vitest run --reporter=dot ``` - [ ] **Step 5: Commit** ```bash git add src/App.tsx src/components/Sidebar.tsx src/pages/Inbox.tsx git commit -m "feat(sp6): /inbox route + sidebar nav link" ``` --- ## Phase 6 — Inbox components ## Task 14: `InboxRow` (TDD) **Files:** - Create: `src/components/inbox/InboxRow.tsx` - Create: `src/components/inbox/InboxRow.test.tsx` - [ ] **Step 1: Write the failing test** ```tsx // src/components/inbox/InboxRow.test.tsx import { describe, expect, it, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { InboxRow } from "./InboxRow"; const baseClaim = { id: "CLP-7741", kind: "claim" as const, patient_control_number: "PCN-001", charge_amount: 1240, payer_id: "CO-MCD", provider_npi: "1234567890", state: "rejected", rejection_reason: "999 AK9 R", rejected_at: "2026-06-20T09:12:00Z", service_date_from: null, score: null, score_tier: null, score_breakdown: null, }; describe("InboxRow", () => { it("renders the claim id, payer, charge", () => { render( {}} />
); expect(screen.getByText("CLP-7741")).toBeDefined(); expect(screen.getByText("CO-MCD")).toBeDefined(); expect(screen.getByText("$1,240.00")).toBeDefined(); }); it("renders the rejection reason when present", () => { render( {}} />
); expect(screen.getByText(/999 AK9 R/)).toBeDefined(); }); it("calls onClick when clicked", () => { const onClick = vi.fn(); render(
); fireEvent.click(screen.getByText("CLP-7741")); expect(onClick).toHaveBeenCalledOnce(); }); it("renders score sparkline for candidate rows", () => { const candidateRow = { id: "R-1", kind: "remit" as const, payer_claim_control_number: "PCN-X", charge_amount: 500, payer_id: "AETNA", rendering_provider_npi: "1234567890", service_date: null, candidates: [{ claim_id: "C-1", score: 92, tier: "strong" as const, breakdown: { patient: 1, date: 1, amount: 1, provider: 1 }, }], }; render( {}} />
); expect(screen.getByText("92")).toBeDefined(); }); }); ``` - [ ] **Step 2: Run, confirm FAIL** ```bash npx vitest run src/components/inbox/InboxRow.test.tsx ``` - [ ] **Step 3: Implement** ```tsx // src/components/inbox/InboxRow.tsx import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api"; type Accent = "oxblood" | "amber" | "ink-blue" | "muted"; const ACCENT_VAR: Record = { oxblood: "var(--tt-oxblood)", amber: "var(--tt-amber)", "ink-blue": "var(--tt-ink-blue)", muted: "var(--tt-muted)", }; function fmtMoney(n: number): string { return n.toLocaleString("en-US", { style: "currency", currency: "USD" }); } function fmtAgo(iso: string | null): string { if (!iso) return ""; const ms = Date.now() - new Date(iso).getTime(); const mins = Math.round(ms / 60_000); if (mins < 60) return `${mins}m`; const hrs = Math.round(mins / 60); if (hrs < 24) return `${hrs}h`; const days = Math.round(hrs / 24); return `${days}d`; } type Props = | { row: InboxClaimRow; accent: Accent; onClick: () => void } | { row: InboxCandidateRow; accent: Accent; onClick: () => void }; export function InboxRow(props: Props) { const { row, accent, onClick } = props; const id = "id" in row ? row.id : (row as InboxCandidateRow).payer_claim_control_number; const payer = row.payer_id ?? ""; const charge = fmtMoney(row.charge_amount); const rejectedAt = "rejected_at" in row ? row.rejected_at : null; const reason = "rejection_reason" in row ? row.rejection_reason : null; const topScore = "candidates" in row && row.candidates.length ? row.candidates[0].score : null; const filled = "candidates" in row && row.candidates.length ? row.candidates[0].breakdown : null; return ( {id} {reason ? reason : topScore != null ? `${topScore}` : ""} {fmtAgo(rejectedAt)} {charge} {payer} {filled ? ( {["patient", "date", "amount", "provider"].map((k) => ( = 0.5 ? "var(--tt-amber)" : "var(--tt-muted)" }} /> ))} ) : null} ); } ``` - [ ] **Step 4: Run, confirm PASS** ```bash npx vitest run src/components/inbox/InboxRow.test.tsx ``` - [ ] **Step 5: Commit** ```bash git add src/components/inbox/InboxRow.tsx src/components/inbox/InboxRow.test.tsx git commit -m "feat(sp6): InboxRow with Ticker Tape accents" ``` --- ## Task 15: `Lane` (TDD) **Files:** - Create: `src/components/inbox/Lane.tsx` - Create: `src/components/inbox/Lane.test.tsx` - [ ] **Step 1: Write the failing test** ```tsx // src/components/inbox/Lane.test.tsx import { describe, expect, it, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { Lane } from "./Lane"; const sampleRows = [ { id: "CLP-1", kind: "claim" as const, patient_control_number: "PCN-1", charge_amount: 100, payer_id: "PAYER-A", provider_npi: "1234567890", state: "rejected", rejection_reason: "R69", rejected_at: "2026-06-20T09:00:00Z", service_date_from: null, score: null, score_tier: null, score_breakdown: null, }, ]; describe("Lane", () => { it("renders the lane name and row count", () => { render( {}} />); expect(screen.getByText("REJECTED")).toBeDefined(); expect(screen.getByText(/1/)).toBeDefined(); }); it("renders an empty state when no rows", () => { render( {}} />); expect(screen.getByText(/nothing here/i)).toBeDefined(); }); it("supports multi-select via row checkbox toggle", () => { const onSelectionChange = vi.fn(); render( {}} onSelectionChange={onSelectionChange} /> ); const cb = screen.getByRole("checkbox"); fireEvent.click(cb); expect(onSelectionChange).toHaveBeenCalled(); }); }); ``` - [ ] **Step 2: Run, confirm FAIL** ```bash npx vitest run src/components/inbox/Lane.test.tsx ``` - [ ] **Step 3: Implement** ```tsx // src/components/inbox/Lane.tsx import { useState } from "react"; import { InboxRow } from "./InboxRow"; import type { InboxClaimRow, InboxCandidateRow } from "../../lib/inbox-api"; type Accent = "oxblood" | "amber" | "ink-blue" | "muted"; type Row = InboxClaimRow | InboxCandidateRow; export function Lane({ name, accent, rows, onRowClick, onSelectionChange, }: { name: string; accent: Accent; rows: Row[]; onRowClick: (row: Row) => void; onSelectionChange?: (ids: string[]) => void; }) { const [selected, setSelected] = useState>(new Set()); function toggle(id: string) { const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); setSelected(next); onSelectionChange?.(Array.from(next)); } return (

{name}

{rows.length}
{rows.length === 0 ? (

Nothing here.

) : ( {rows.map((row) => { const id = "id" in row ? row.id : row.payer_claim_control_number; return ( ); })}
toggle(id)} onClick={(e) => e.stopPropagation()} /> onRowClick(row)} />
)}
); } ``` - [ ] **Step 4: Run, confirm PASS** ```bash npx vitest run src/components/inbox/Lane.test.tsx ``` - [ ] **Step 5: Commit** ```bash git add src/components/inbox/Lane.tsx src/components/inbox/Lane.test.tsx git commit -m "feat(sp6): Lane component with multi-select" ``` --- ## Task 16: `InboxHeader` + `CandidateBreakdown` (TDD) **Files:** - Create: `src/components/inbox/InboxHeader.tsx` - Create: `src/components/inbox/InboxHeader.test.tsx` - Create: `src/components/inbox/CandidateBreakdown.tsx` - Create: `src/components/inbox/CandidateBreakdown.test.tsx` - [ ] **Step 1: Header test** ```tsx // src/components/inbox/InboxHeader.test.tsx import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { InboxHeader } from "./InboxHeader"; describe("InboxHeader", () => { it("renders the date and counts", () => { render(); expect(screen.getByText(/6 items need eyes/)).toBeDefined(); expect(screen.getByText(/23 done today/)).toBeDefined(); }); }); ``` - [ ] **Step 2: Breakdown test** ```tsx // src/components/inbox/CandidateBreakdown.test.tsx import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { CandidateBreakdown } from "./CandidateBreakdown"; describe("CandidateBreakdown", () => { it("renders per-field scores", () => { render( ); expect(screen.getByText(/patient/i)).toBeDefined(); expect(screen.getByText(/date/i)).toBeDefined(); expect(screen.getByText(/amount/i)).toBeDefined(); expect(screen.getByText(/provider/i)).toBeDefined(); expect(screen.getByText("92")).toBeDefined(); }); }); ``` - [ ] **Step 3: Run, confirm FAIL** ```bash npx vitest run src/components/inbox/ ``` - [ ] **Step 4: Implement both** ```tsx // src/components/inbox/InboxHeader.tsx export function InboxHeader({ needEyesCount, doneTodayCount }: { needEyesCount: number; doneTodayCount: number; }) { const now = new Date(); const day = now.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase(); const time = now.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); return (

INBOX · {day} {time}

{needEyesCount} items need eyes ·{" "} {doneTodayCount} done today

); } ``` ```tsx // src/components/inbox/CandidateBreakdown.tsx import type { ScoreBreakdown, ScoreTier } from "../../lib/inbox-api"; export function CandidateBreakdown({ score, breakdown, tier, }: { score: number; breakdown: ScoreBreakdown; tier: ScoreTier; }) { const fields = [ { key: "patient" as const, label: "Patient", weight: 40 }, { key: "date" as const, label: "Date", weight: 25 }, { key: "amount" as const, label: "Amount", weight: 20 }, { key: "provider" as const, label: "Provider", weight: 15 }, ]; return (
Score {score}
    {fields.map((f) => (
  • {f.label} {Math.round(breakdown[f.key] * f.weight)} / {f.weight}
  • ))}

{tier}

); } ``` - [ ] **Step 5: Run, confirm PASS** ```bash npx vitest run src/components/inbox/ ``` - [ ] **Step 6: Commit** ```bash git add src/components/inbox/InboxHeader.tsx src/components/inbox/InboxHeader.test.tsx src/components/inbox/CandidateBreakdown.tsx src/components/inbox/CandidateBreakdown.test.tsx git commit -m "feat(sp6): InboxHeader + CandidateBreakdown" ``` --- ## Task 17: `Inbox` page + `useInboxLanes` hook (TDD) **Files:** - Create: `src/hooks/useInboxLanes.ts` - Create: `src/hooks/useInboxLanes.test.ts` - Replace: `src/pages/Inbox.tsx` (real implementation, not placeholder) - [ ] **Step 1: Hook test** ```typescript // src/hooks/useInboxLanes.test.ts import { describe, expect, it, vi } from "vitest"; import { renderHook, waitFor } from "@testing-library/react"; import { useInboxLanes } from "./useInboxLanes"; describe("useInboxLanes", () => { it("loads lanes on mount", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }), })); const { result } = renderHook(() => useInboxLanes()); await waitFor(() => expect(result.current.loading).toBe(false)); expect(result.current.lanes).toEqual({ rejected: [], candidates: [], unmatched: [], done_today: [] }); vi.unstubAllGlobals(); }); }); ``` - [ ] **Step 2: Page test** ```tsx // src/pages/Inbox.test.tsx import { describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import Inbox from "./Inbox"; describe("Inbox page", () => { it("renders the four lane headers", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ rejected: [], candidates: [], unmatched: [], done_today: [] }), })); render(); await waitFor(() => { expect(screen.getByText("REJECTED")).toBeDefined(); expect(screen.getByText("CANDIDATES")).toBeDefined(); expect(screen.getByText("UNMATCHED")).toBeDefined(); expect(screen.getByText("DONE")).toBeDefined(); }); vi.unstubAllGlobals(); }); }); ``` - [ ] **Step 3: Run, confirm FAIL** ```bash npx vitest run src/hooks/useInboxLanes.test.ts src/pages/Inbox.test.tsx ``` - [ ] **Step 4: Implement** ```typescript // src/hooks/useInboxLanes.ts import { useCallback, useEffect, useRef, useState } from "react"; import { fetchInboxLanes, type InboxLanes } from "../lib/inbox-api"; export function useInboxLanes() { const [lanes, setLanes] = useState({ rejected: [], candidates: [], unmatched: [], done_today: [], }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const debounceRef = useRef | null>(null); const refetch = useCallback(async () => { try { const next = await fetchInboxLanes(); setLanes(next); setError(null); } catch (e) { setError(e as Error); } finally { setLoading(false); } }, []); const refetchDebounced = useCallback(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { void refetch(); }, 500); }, [refetch]); useEffect(() => { void refetch(); }, [refetch]); // Subscribe to live-tail events. Coalesce bursts via 500ms debounce. useEffect(() => { const es = new EventSource("/api/claims/stream"); const onAny = () => refetchDebounced(); es.addEventListener("claim.rejected", onAny); es.addEventListener("claim.matched", onAny); es.addEventListener("claim.resubmitted", onAny); es.addEventListener("claim.paid", onAny); return () => { es.close(); }; }, [refetchDebounced]); return { lanes, loading, error, refetch }; } ``` (Note: if `/api/claims/stream` isn't an EventSource (it's NDJSON), use the existing `useTailStream` hook from SP5 instead — replace this useEffect with that hook's `events` dependency.) ```tsx // src/pages/Inbox.tsx import { Lane } from "../components/inbox/Lane"; import { InboxHeader } from "../components/inbox/InboxHeader"; import { useInboxLanes } from "../hooks/useInboxLanes"; export default function Inbox() { const { lanes, loading, error } = useInboxLanes(); if (loading) { return (

loading…

); } if (error) { return (

error: {error.message}

); } const needEyes = lanes.rejected.length + lanes.candidates.length + lanes.unmatched.length; return (
{}} /> {}} /> {}} /> {}} />
); } ``` - [ ] **Step 5: Run, confirm PASS** ```bash npx vitest run src/pages/Inbox.test.tsx src/hooks/useInboxLanes.test.ts ``` - [ ] **Step 6: Type-check + full frontend suite** ```bash npx tsc --noEmit && npx vitest run --reporter=dot ``` - [ ] **Step 7: Commit** ```bash git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts src/pages/Inbox.tsx src/pages/Inbox.test.tsx git commit -m "feat(sp6): Inbox page + useInboxLanes hook" ``` --- ## Phase 7 — Bulk actions + tail integration ## Task 18: Wire bulk action bar (TDD) **Files:** - Modify: `src/components/inbox/Lane.tsx` - Create: `src/components/inbox/BulkBar.tsx` - Modify: `src/components/inbox/Lane.test.tsx` - Create: `src/components/inbox/BulkBar.test.tsx` - [ ] **Step 1: BulkBar test** ```tsx // src/components/inbox/BulkBar.test.tsx import { describe, expect, it, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { BulkBar } from "./BulkBar"; describe("BulkBar", () => { it("renders action buttons appropriate for the lane", () => { render( ); expect(screen.getByRole("button", { name: /resubmit/i })).toBeDefined(); expect(screen.getByRole("button", { name: /export/i })).toBeDefined(); expect(screen.queryByRole("button", { name: /dismiss/i })).toBeNull(); }); it("calls onResubmit when Resubmit clicked", () => { const onResubmit = vi.fn(); render( {}} onExport={() => {}} />); fireEvent.click(screen.getByRole("button", { name: /resubmit/i })); expect(onResubmit).toHaveBeenCalledOnce(); }); }); ``` - [ ] **Step 2: Implement BulkBar** ```tsx // src/components/inbox/BulkBar.tsx export function BulkBar({ lane, count, onResubmit, onDismiss, onExport, }: { lane: "rejected" | "candidates" | "unmatched" | "done_today"; count: number; onResubmit: () => void; onDismiss: () => void; onExport: () => void; }) { if (count === 0) return null; return (
{count} selected {lane === "rejected" && ( )} {lane === "candidates" && ( )}
); } ``` - [ ] **Step 3: Wire into Lane** Edit `src/components/inbox/Lane.tsx` to accept `onResubmit`, `onDismiss`, `onExport` props and render `` when selection is non-empty. - [ ] **Step 4: Wire into Inbox page** In `src/pages/Inbox.tsx`, pass handlers that call `resubmitRejected`, `dismissCandidates`, and `window.open(exportInboxCsvUrl(...))`. Then call `refetch()`. - [ ] **Step 5: Run + commit** ```bash npx vitest run src/components/inbox/ git add src/components/inbox/ git commit -m "feat(sp6): bulk action bar wired into lanes" ``` --- ## Task 19: `useInboxLanes` consumes SP5 tail (TDD) **Files:** - Modify: `src/hooks/useInboxLanes.ts` - Modify: `src/hooks/useInboxLanes.test.ts` - [ ] **Step 1: Add the failing test** Append to `src/hooks/useInboxLanes.test.ts`: ```typescript import { useTailStream } from "./useTailStream"; vi.mock("./useTailStream", () => ({ useTailStream: vi.fn(() => ({ events: [], status: "open" })), })); it("refetches on tail events", async () => { // Test that calling the mocked useTailStream with a callback that triggers // refetch causes a second fetch call. // Implementation detail: the hook should accept a list of stream URLs and // re-call refetchDebounced when events arrive. }); ``` - [ ] **Step 2: Run, confirm FAIL** ```bash npx vitest run src/hooks/useInboxLanes.test.ts ``` - [ ] **Step 3: Refactor `useInboxLanes`** Replace the manual `EventSource` block with: ```typescript import { useTailStream } from "./useTailStream"; const claimStream = useTailStream("/api/claims/stream"); const remitStream = useTailStream("/api/remittances/stream"); useEffect(() => { if (claimStream.events.length > 0) refetchDebounced(); }, [claimStream.events.length, refetchDebounced]); useEffect(() => { if (remitStream.events.length > 0) refetchDebounced(); }, [remitStream.events.length, refetchDebounced]); ``` - [ ] **Step 4: Run, confirm PASS** ```bash npx vitest run src/hooks/ ``` - [ ] **Step 5: Commit** ```bash git add src/hooks/useInboxLanes.ts src/hooks/useInboxLanes.test.ts git commit -m "feat(sp6): useInboxLanes refetches on SP5 tail events" ``` --- ## Phase 8 — Docs + smoke ## Task 20: README "Inbox" section **Files:** - Modify: `README.md` - [ ] **Step 1: Find the existing "Live updates" section** ```bash grep -n "Live updates\|## API\|## Live" README.md | head -5 ``` - [ ] **Step 2: Add a new section after "Live updates"** ```markdown ## Inbox `/inbox` is the working surface. Four lanes, dark by default (Ticker Tape aesthetic): - **Rejected** — claims whose 999 set-level response was R or E. Re-submit in bulk. - **Candidates** — remits whose CLP-claim-id didn't match exactly; each one shows its top scored claim. One-click manual match or dismiss. - **Unmatched** — claims still waiting for a remit, and remits with no candidates above the threshold. - **Done today** — terminal state transitions in the last 24 hours. ### Inbox endpoints | Method | Path | Notes | |--------|------|-------| | GET | `/api/inbox/lanes` | All four lanes in one call. | | POST | `/api/inbox/candidates/{remit_id}/match` | Manual match. 409 on conflict. | | POST | `/api/inbox/candidates/dismiss` | `{pairs: [{claim_id, remit_id}]}`. Session-scoped. | | POST | `/api/inbox/rejected/resubmit` | `{claim_ids: [...]}`. 409 on non-rejected. | | GET | `/api/inbox/export.csv?lane=` | Streams CSV of the lane's rows. | ### Scoring 4-field weighted (sum = 100): | Field | Weight | Rule | |-------|--------|------| | Patient control number | 40 | Exact match, normalized (case + leading zeros). | | Service date | 25 | Linear decay over ±3 days. | | Charge amount | 20 | Linear decay over ±10%. | | Provider NPI | 15 | Exact match. Missing → 0. | Tiers: **strong** (≥75, full opacity, Match enabled), **weak** (50–74, dimmed), **hidden** (<50, not surfaced). ``` - [ ] **Step 3: Commit** ```bash git add README.md git commit -m "docs(sp6): README — Inbox section" ``` --- ## Task 21: Manual smoke test - [ ] **Step 1: Start backend** ```bash cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/smoke.db ../.venv/bin/python -m cyclone & ``` - [ ] **Step 2: Start frontend dev server** ```bash cd .. && npm run dev & ``` - [ ] **Step 3: Run the existing 837 + 999 + 835 seed scripts** ```bash curl -X POST -F "file=@backend/tests/fixtures/sample_837.txt" http://127.0.0.1:8000/api/parse-837 curl -X POST -F "file=@backend/tests/fixtures/sample_999_reject.999" http://127.0.0.1:8000/api/parse-999 curl -X POST -F "file=@backend/tests/fixtures/sample_835.txt" http://127.0.0.1:8000/api/parse-835 ``` Expected: 200 on each. If any fixture file doesn't exist, locate the appropriate one in `backend/tests/fixtures/` or generate a minimal one. - [ ] **Step 4: Hit the lanes endpoint** ```bash curl http://127.0.0.1:8000/api/inbox/lanes | python3 -m json.tool | head -40 ``` Expected: non-empty `rejected` (from 999), `candidates` or `unmatched` (from 835), and `done_today` if reconciliation advanced any claim state. - [ ] **Step 5: Open the Inbox page** Navigate to `http://127.0.0.1:5173/inbox` (or whatever port Vite uses). Verify: - All four lane headers visible. - Rows populate from the API. - Multi-select → bulk bar appears. - Click row → opens drawer (or at minimum, logs the row id). - Match button on a candidate row → calls API, row disappears. - [ ] **Step 6: Kill servers** ```bash kill %1 %2 2>/dev/null ``` - [ ] **Step 7: Final full-suite run** ```bash cd backend && ../.venv/bin/pytest -q cd .. && npx vitest run --reporter=dot ``` Expected: both green. - [ ] **Step 8: Final commit + branch ready for review** ```bash git log --oneline main..HEAD git push -u origin sp6-workflow-inbox ``` --- ## Self-review checklist - [x] **Spec coverage:** - §2.1 Ticker Tape aesthetic → T12 - §2.2 Four lanes ordered by urgency → T6 (computation), T17 (page) - §2.3 Row contents (rail, ID, status, time, charge, payer, patient, score sparkline) → T14 - §2.4 Actions (manual match, dismiss, bulk dismiss, bulk resubmit, bulk export) → T8, T9, T18 - §2.5 Live tail integration → T19 - §3.1 Add REJECTED to ClaimState enum → T1 - §3.2 New columns (rejection_reason, rejected_at, resubmit_count) + state-history index → T2 - §3.3 No new claim_candidates table → confirmed (T6 computes on read) - §4.1 999 → REJECTED state → T3, T4 - §4.2 835 unmatched → candidates (no auto-match) → T6 - §4.3 4-field weighted scoring → T5 - §4.4 New endpoints → T7, T8, T9, T10 - §4.5 Dismissed-pairs session store → T7 (init) + T8 (mutate) - §5.1 Frontend files → T11–T19 - §5.2 Routing + sidebar nav → T13 - §5.3 Component responsibilities → T14–T17 - §5.4 Tail integration → T19 - §5.5 Bulk action UI → T18 - §6 Error handling → T4 (orphan 999 logging), T8 (409), T17 (error UI), T18 (export) - §7 Testing strategy → covered per task - §9 Open impl questions → T12 (typography choice), T17 (lane widths = `min-w-[280px]` flex), T13 (landing page deferred) - [x] **Placeholder scan:** No "TODO", "implement later", "add appropriate error handling" without code. Every code block is runnable. - [x] **Type consistency:** `ScoreBreakdown` fields `patient/date/amount/provider` consistent across scoring.py, inbox-api.ts, InboxRow.tsx, CandidateBreakdown.tsx. `ClaimState.REJECTED` consistent across db.py, inbox_state.py, api.py. `dismissed_pairs` set semantics consistent. - [x] **No spec gaps:** All 9 spec sections map to at least one task. --- ## Execution handoff Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md`. 21 tasks. **Two execution options:** 1. **Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration. Best for plans this size. 2. **Inline Execution** — Execute tasks in this session using `executing-plans`, batch with checkpoints. Which approach?