# Sub-project 7 — Per-Service-Line Adjustment Audit: 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.

**Date:** 2026-06-20
**Status:** Ready
**Branch:** work continues on `main` (per session convention)
**Spec:** `docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md`

21 tasks across 6 phases. TDD throughout. Each task ends with a green test run + commit.

**Prerequisite:** SP1–SP6 are merged on `main`. No new pip deps, no new npm deps.

**Tech additions:** None.

---

## Task 0: Verify SP6 is on `main`

**Files:** none

- [ ] **Step 1: Confirm SP6 is committed**

```bash
cd /Users/openclaw/dev/cyclone
git log --oneline main | head -5
git log --oneline main | grep -E "sp6.*(BulkBar|InboxHeader|compute_lanes|apply_999|migration 0004)" | head -5
```

Expected: SP6 commits visible on `main`. If not, stop and resolve first.

---

## Phase 1 — Schema

## Task 1: Migration 0005 (line_reconciliation)

**Files:**
- Create: `backend/src/cyclone/migrations/0005_line_reconciliation.sql`

- [ ] **Step 1: Write the migration**

```sql
-- version: 5
-- SP7: per-service-line adjustment audit
-- See spec: docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md

CREATE TABLE service_line_payments (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    remittance_id VARCHAR(64) NOT NULL REFERENCES remittances(id) ON DELETE CASCADE,
    line_number INTEGER NOT NULL,
    procedure_qualifier VARCHAR(4) NOT NULL,
    procedure_code VARCHAR(16) NOT NULL,
    modifiers_json TEXT NOT NULL DEFAULT '[]',
    charge NUMERIC(12, 2) NOT NULL,
    payment NUMERIC(12, 2) NOT NULL,
    units NUMERIC(10, 2),
    unit_type VARCHAR(8),
    service_date DATE,
    ref_benefit_plan VARCHAR(64),
    superseded_by_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL
);

CREATE INDEX ix_service_line_payments_remittance_id ON service_line_payments(remittance_id);
CREATE INDEX ix_service_line_payments_procedure_code_date ON service_line_payments(procedure_code, service_date);

CREATE TABLE line_reconciliations (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    claim_id VARCHAR(64) NOT NULL REFERENCES claims(id) ON DELETE CASCADE,
    claim_service_line_id INTEGER REFERENCES service_lines(id) ON DELETE SET NULL,
    service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
    status VARCHAR(16) NOT NULL,
    match_score INTEGER,
    reconciled_at TIMESTAMP NOT NULL
);

CREATE INDEX ix_line_reconciliations_claim_id ON line_reconciliations(claim_id);
CREATE INDEX ix_line_reconciliations_claim_service_line_id ON line_reconciliations(claim_service_line_id);
CREATE INDEX ix_line_reconciliations_service_line_payment_id ON line_reconciliations(service_line_payment_id);

ALTER TABLE cas_adjustments ADD COLUMN service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL;

ALTER TABLE remittances ADD COLUMN claim_level_adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0;

CREATE INDEX ix_cas_adjustments_service_line_payment_id ON cas_adjustments(service_line_payment_id);
```

(Each `ALTER TABLE` and `CREATE TABLE` in its own statement — SQLite does not support multiple adds in one statement; this is the lesson from SP6 T2.)

- [ ] **Step 2: Commit**

```bash
git add backend/src/cyclone/migrations/0005_line_reconciliation.sql
git commit -m "feat(sp7): migration 0005 — service_line_payments + line_reconciliations + FKs"
```

---

## Task 2: ORM model — `ServiceLinePayment`

**Files:**
- Modify: `backend/src/cyclone/db.py`
- Test: `backend/tests/test_db_models.py` (count bump only — covered by migration tests for shape)

- [ ] **Step 1: Add the model class to `db.py`**

After the existing `CasAdjustment` class, add:

```python
class ServiceLinePayment(Base):
    """One row per 835 SVC composite (loop 2110).

    SP7. Persisted on 835 ingest *before* ``reconcile.run()`` decides
    which claim this line applies to. Independent of claim matching.

    See spec §3.1.
    """
    __tablename__ = "service_line_payments"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    remittance_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("remittances.id", ondelete="CASCADE"), nullable=False
    )
    line_number: Mapped[int] = mapped_column(Integer, nullable=False)
    procedure_qualifier: Mapped[str] = mapped_column(String(4), nullable=False)
    procedure_code: Mapped[str] = mapped_column(String(16), nullable=False)
    modifiers_json: Mapped[str] = mapped_column(
        JSONText, nullable=False, default=lambda: "[]"
    )
    charge: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
    payment: Mapped[Decimal] = mapped_column(Numeric(12, 2), nullable=False)
    units: Mapped[Optional[Decimal]] = mapped_column(Numeric(10, 2), nullable=True)
    unit_type: Mapped[Optional[str]] = mapped_column(String(8), nullable=True)
    service_date: Mapped[Optional[date]] = mapped_column(Date, nullable=True)
    ref_benefit_plan: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
    superseded_by_id: Mapped[Optional[int]] = mapped_column(
        Integer,
        ForeignKey("service_line_payments.id", ondelete="SET NULL"),
        nullable=True,
    )

    __table_args__ = (
        Index("ix_service_line_payments_remittance_id", "remittance_id"),
        Index(
            "ix_service_line_payments_procedure_code_date",
            "procedure_code",
            "service_date",
        ),
    )
```

- [ ] **Step 2: Commit**

```bash
git add backend/src/cyclone/db.py
git commit -m "feat(sp7): ServiceLinePayment ORM model"
```

---

## Task 3: ORM model — `LineReconciliation`

**Files:**
- Modify: `backend/src/cyclone/db.py`

- [ ] **Step 1: Add the model class**

After `ServiceLinePayment`, add:

```python
class LineReconciliation(Base):
    """One row per matched (or explicitly unmatched) 837 service line within a claim.

    SP7. Created during ``reconcile.run()`` after the claim↔remit match.
    At least one of ``claim_service_line_id`` / ``service_line_payment_id``
    must be non-null; the application enforces this.

    See spec §3.2.
    """
    __tablename__ = "line_reconciliations"

    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
    claim_id: Mapped[str] = mapped_column(
        String(64), ForeignKey("claims.id", ondelete="CASCADE"), nullable=False
    )
    claim_service_line_id: Mapped[Optional[int]] = mapped_column(
        Integer,
        ForeignKey("service_lines.id", ondelete="SET NULL"),
        nullable=True,
    )
    service_line_payment_id: Mapped[Optional[int]] = mapped_column(
        Integer,
        ForeignKey("service_line_payments.id", ondelete="SET NULL"),
        nullable=True,
    )
    status: Mapped[str] = mapped_column(String(16), nullable=False)
    match_score: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
    reconciled_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), nullable=False
    )

    __table_args__ = (
        Index("ix_line_reconciliations_claim_id", "claim_id"),
        Index(
            "ix_line_reconciliations_claim_service_line_id", "claim_service_line_id"
        ),
        Index(
            "ix_line_reconciliations_service_line_payment_id",
            "service_line_payment_id",
        ),
    )
```

- [ ] **Step 2: Commit**

```bash
git add backend/src/cyclone/db.py
git commit -m "feat(sp7): LineReconciliation ORM model"
```

---

## Task 4: Add FK + column to existing tables

**Files:**
- Modify: `backend/src/cyclone/db.py`

- [ ] **Step 1: Add `service_line_payment_id` to `CasAdjustment`**

Find the `CasAdjustment` class. Add this column after `quantity`:

```python
    service_line_payment_id: Mapped[Optional[int]] = mapped_column(
        Integer,
        ForeignKey("service_line_payments.id", ondelete="SET NULL"),
        nullable=True,
    )
```

And add to `__table_args__`:

```python
        Index("ix_cas_adjustments_service_line_payment_id", "service_line_payment_id"),
```

- [ ] **Step 2: Add `claim_level_adjustment_amount` to `Remittance`**

Find the `Remittance` class. Add this column after `adjustment_amount`:

```python
    claim_level_adjustment_amount: Mapped[Decimal] = mapped_column(
        Numeric(12, 2), nullable=False, default=Decimal("0"), server_default=text("0")
    )
```

- [ ] **Step 3: Commit**

```bash
git add backend/src/cyclone/db.py
git commit -m "feat(sp7): CasAdjustment.service_line_payment_id + Remittance.claim_level_adjustment_amount"
```

---

## Task 5: Update existing test fixtures for the migration

**Files:**
- Modify: `backend/tests/test_acks.py`
- Modify: `backend/tests/test_db_models.py`

- [ ] **Step 1: Update `test_acks.py` version assertion**

Find the version assertion (currently 4). Change to 5:

```python
assert version == 5  # SP7 migration 0005
```

- [ ] **Step 2: Update `test_db_models.py` enum count**

Find the model count assertion. The exact assertion depends on the file; bump by +2 (for `ServiceLinePayment` and `LineReconciliation`). If the test asserts the exact set of class names, add the two new class names.

- [ ] **Step 3: Run tests**

```bash
cd backend && pytest tests/test_acks.py tests/test_db_models.py -v
```

Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add backend/tests/test_acks.py backend/tests/test_db_models.py
git commit -m "test(sp7): update migration version + model count assertions"
```

---

## Phase 2 — Persistence

## Task 6: Persist `ServiceLinePayment` on 835 ingest

**Files:**
- Modify: `backend/src/cyclone/store.py`
- Test: `backend/tests/test_service_line_payments.py`

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

Append to `backend/tests/test_service_line_payments.py`:

```python
"""SP7 — 835 ingest persists ServiceLinePayment rows + links CAS rows.

See spec §3.1, §3.3.
"""
from datetime import date
from decimal import Decimal

from sqlalchemy import select

from cyclone.db import (
    Batch,
    CasAdjustment,
    Remittance,
    ServiceLinePayment,
    SessionLocal,
)
from cyclone.parsers.models_835 import (
    ClaimAdjustment,
    ClaimPayment,
    ServicePayment,
)
from cyclone.store import _persist_835_remit


def _make_batch() -> str:
    """Persist a bare Batch row and return its id."""
    from datetime import datetime, timezone
    import uuid
    bid = str(uuid.uuid4())
    with SessionLocal()() as s:
        s.add(Batch(
            id=bid, source_filename="x.835",
            parsed_at=datetime.now(timezone.utc),
            summary_json={"claim_count": 1, "total_charge": "0", "total_paid": "0"},
            claim_count=1,
        ))
        s.commit()
    return bid


def test_persist_835_creates_one_service_line_payment_per_svc_composite():
    bid = _make_batch()
    cp = ClaimPayment(
        payer_claim_control_number="PCN-1",
        status_code="1",
        total_charge=Decimal("200.00"),
        total_paid=Decimal("150.00"),
        service_payments=[
            ServicePayment(
                line_number=1, procedure_qualifier="HC",
                procedure_code="99213", charge=Decimal("100.00"),
                payment=Decimal("80.00"),
                adjustments=[ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("20.00"))],
            ),
            ServicePayment(
                line_number=2, procedure_qualifier="HC",
                procedure_code="99214", charge=Decimal("100.00"),
                payment=Decimal("70.00"),
                adjustments=[],
            ),
        ],
    )
    with SessionLocal()() as s:
        from cyclone.store import _remittance_835_row
        remit = _remittance_835_row(cp, bid)
        s.add(remit)
        s.flush()
        _persist_835_remit(s, cp, remit.id)
        s.commit()

        slps = list(s.execute(select(ServiceLinePayment).order_by(ServiceLinePayment.line_number)).scalars().all())
        assert len(slps) == 2
        assert slps[0].procedure_code == "99213"
        assert slps[0].payment == Decimal("80.00")
        assert slps[1].procedure_code == "99214"
        assert slps[1].payment == Decimal("70.00")
        assert slps[0].remittance_id == remit.id


def test_cas_adjustment_rows_get_service_line_payment_id_set():
    """The SVC-level CAS rows must link back to their ServiceLinePayment."""
    bid = _make_batch()
    cp = ClaimPayment(
        payer_claim_control_number="PCN-1",
        status_code="1",
        total_charge=Decimal("100.00"),
        total_paid=Decimal("80.00"),
        service_payments=[
            ServicePayment(
                line_number=1, procedure_qualifier="HC",
                procedure_code="99213", charge=Decimal("100.00"),
                payment=Decimal("80.00"),
                adjustments=[ClaimAdjustment(group_code="CO", reason_code="45", amount=Decimal("20.00"))],
            ),
        ],
    )
    with SessionLocal()() as s:
        from cyclone.store import _remittance_835_row
        remit = _remittance_835_row(cp, bid)
        s.add(remit)
        s.flush()
        _persist_835_remit(s, cp, remit.id)
        s.commit()

        cas = list(s.execute(select(CasAdjustment)).scalars().all())
        assert len(cas) == 1
        assert cas[0].service_line_payment_id is not None
```

(Note: the test references `_persist_835_remit` which doesn't exist yet — that's the red.)

- [ ] **Step 2: Run, confirm FAIL**

```bash
cd backend && pytest tests/test_service_line_payments.py -v
```

Expected: ImportError on `_persist_835_remit` or `ServiceLinePayment`.

- [ ] **Step 3: Implement `_persist_835_remit`**

In `backend/src/cyclone/store.py`, add:

```python
def _persist_835_remit(session, cp: "ClaimPayment", remittance_id: str) -> None:
    """Persist ServiceLinePayment + CasAdjustment rows for one CLP composite.

    Spec §4.4: runs BEFORE ``reconcile.run()``. The CAS rows get their
    ``service_line_payment_id`` set here so the line match step can
    aggregate per-line adjustments later.

    Returns nothing; the caller controls the transaction.
    """
    import json as _json
    from cyclone.db import ServiceLinePayment, CasAdjustment

    for svc in cp.service_payments:
        slp = ServiceLinePayment(
            remittance_id=remittance_id,
            line_number=svc.line_number,
            procedure_qualifier=svc.procedure_qualifier,
            procedure_code=svc.procedure_code,
            modifiers_json=_json.dumps(svc.modifiers or []),
            charge=Decimal(str(svc.charge)),
            payment=Decimal(str(svc.payment)),
            units=Decimal(str(svc.units)) if svc.units is not None else None,
            unit_type=svc.unit_type,
            service_date=svc.service_date,
            ref_benefit_plan=svc.ref_benefit_plan,
        )
        session.add(slp)
        session.flush()  # populate slp.id for the FK below

        for adj in svc.adjustments:
            session.add(CasAdjustment(
                remittance_id=remittance_id,
                group_code=adj.group_code,
                reason_code=adj.reason_code,
                amount=Decimal(str(adj.amount)),
                quantity=Decimal(str(adj.quantity)) if getattr(adj, "quantity", None) is not None else None,
                service_line_payment_id=slp.id,
            ))

    # CLP-level CAS adjustments (no SVC composite to attach to): persist
    # with service_line_payment_id IS NULL. Today the 835 parser only
    # produces svc.adjustments, so this is a no-op for current fixtures
    # but is here for forward compatibility.
    for adj in getattr(cp, "claim_adjustments", []) or []:
        session.add(CasAdjustment(
            remittance_id=remittance_id,
            group_code=adj.group_code,
            reason_code=adj.reason_code,
            amount=Decimal(str(adj.amount)),
            quantity=Decimal(str(adj.quantity)) if getattr(adj, "quantity", None) is not None else None,
            service_line_payment_id=None,
        ))
```

- [ ] **Step 4: Wire `_persist_835_remit` into the existing 835 ingest loop**

In `backend/src/cyclone/store.py`, find the 835 ingest block (around line 905 — the loop that iterates `cp.service_payments` and inserts `CasAdjustment` rows). Replace the inner CAS loop with a call to `_persist_835_remit`. The existing code does:

```python
                    for svc in cp.service_payments:
                        for adj in svc.adjustments:
                            s.add(_cas_adjustment_row(adj, remit_row.id))
```

Replace with:

```python
                    _persist_835_remit(s, cp, remit_row.id)
```

Then remove the now-unused `_cas_adjustment_row` helper (or leave it if you prefer minimal churn — see note below).

**Note:** leaving `_cas_adjustment_row` in place is fine; tests don't care if it's unused. Removing it is cleaner. Pick whichever keeps the diff small. If you remove it, also remove its import.

- [ ] **Step 5: Run tests, confirm PASS**

```bash
cd backend && pytest tests/test_service_line_payments.py tests/test_prodfiles_smoke.py tests/test_api.py -v
```

Expected: PASS.

- [ ] **Step 6: Full backend suite**

```bash
cd backend && pytest -q
```

Expected: same baseline as before this task (the 8 SP5 stream flakes + 3 TA1 flakes remain; everything else green).

- [ ] **Step 7: Commit**

```bash
git add backend/src/cyclone/store.py backend/tests/test_service_line_payments.py
git commit -m "feat(sp7): persist ServiceLinePayment + link CAS rows on 835 ingest"
```

---

## Task 7: `reconcile.match_service_lines()` pure function

**Files:**
- Modify: `backend/src/cyclone/reconcile.py`
- Test: `backend/tests/test_line_reconciliation.py`

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

Create `backend/tests/test_line_reconciliation.py`:

```python
"""SP7 — pure-function tests for match_service_lines().

Spec §4.
"""
from dataclasses import dataclass
from datetime import date
from decimal import Decimal

import pytest

from cyclone.reconcile import (
    LineMatch,
    match_service_lines,
)


@dataclass
class FakeLine:
    line_number: int
    procedure_code: str
    modifiers: list[str]
    service_date: date | None
    units: Decimal | None
    payment: Decimal = Decimal("0")  # 835 only


@dataclass
class FakeClaimLine(FakeLine):
    charge: Decimal = Decimal("0")  # 837 only


def _claim(*lines: FakeClaimLine) -> list[FakeClaimLine]:
    return list(lines)


def _remit(*lines: FakeLine) -> list[FakeLine]:
    return list(lines)


def test_exact_match_on_all_four_criteria():
    claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), Decimal("1")))
    remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 1), Decimal("1"), payment=Decimal("80")))
    matches = match_service_lines(claim_lines, remit_lines)
    assert len(matches) == 1
    assert matches[0].claim_line.line_number == 1
    assert matches[0].service_payment.line_number == 1
    assert matches[0].status == "matched"


def test_procedure_code_mismatch_both_unmatched():
    claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
    remit_lines = _remit(FakeLine(1, "99214", [], None, None))
    matches = match_service_lines(claim_lines, remit_lines)
    assert len(matches) == 2
    statuses = sorted(m.status for m in matches)
    assert statuses == ["unmatched_835_only", "unmatched_837_only"]


def test_modifiers_set_equal_regardless_of_order():
    claim_lines = _claim(FakeClaimLine(1, "99213", ["25", "59"], None, None))
    remit_lines = _remit(FakeLine(1, "99213", ["59", "25"], None, None))
    matches = match_service_lines(claim_lines, remit_lines)
    assert len(matches) == 1
    assert matches[0].status == "matched"


def test_service_date_mismatch():
    claim_lines = _claim(FakeClaimLine(1, "99213", [], date(2026, 6, 1), None))
    remit_lines = _remit(FakeLine(1, "99213", [], date(2026, 6, 2), None))
    matches = match_service_lines(claim_lines, remit_lines)
    assert len(matches) == 2
    statuses = sorted(m.status for m in matches)
    assert statuses == ["unmatched_835_only", "unmatched_837_only"]


def test_units_mismatch():
    claim_lines = _claim(FakeClaimLine(1, "99213", [], None, Decimal("1")))
    remit_lines = _remit(FakeLine(1, "99213", [], None, Decimal("2")))
    matches = match_service_lines(claim_lines, remit_lines)
    assert len(matches) == 2
    statuses = sorted(m.status for m in matches)
    assert statuses == ["unmatched_835_only", "unmatched_837_only"]


def test_procedure_code_case_normalized():
    claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
    remit_lines = _remit(FakeLine(1, "99213", [], None, None))  # case irrelevant when both upper
    matches = match_service_lines(claim_lines, remit_lines)
    assert matches[0].status == "matched"


def test_claim_has_more_lines_than_remit():
    claim_lines = _claim(
        FakeClaimLine(1, "99213", [], None, None),
        FakeClaimLine(2, "99214", [], None, None),
    )
    remit_lines = _remit(FakeLine(1, "99213", [], None, None))
    matches = match_service_lines(claim_lines, remit_lines)
    statuses = sorted(m.status for m in matches)
    assert statuses == ["matched", "unmatched_837_only"]


def test_remit_has_more_lines_than_claim():
    claim_lines = _claim(FakeClaimLine(1, "99213", [], None, None))
    remit_lines = _remit(
        FakeLine(1, "99213", [], None, None),
        FakeLine(2, "99214", [], None, None),
    )
    matches = match_service_lines(claim_lines, remit_lines)
    statuses = sorted(m.status for m in matches)
    assert statuses == ["matched", "unmatched_835_only"]


def test_empty_837_lines_no_reconciliation_rows():
    matches = match_service_lines([], _remit(FakeLine(1, "99213", [], None, None)))
    assert len(matches) == 1
    assert matches[0].status == "unmatched_835_only"


def test_empty_835_lines_no_reconciliation_rows():
    matches = match_service_lines(_claim(FakeClaimLine(1, "99213", [], None, None)), [])
    assert len(matches) == 1
    assert matches[0].status == "unmatched_837_only"


def test_both_empty():
    assert match_service_lines([], []) == []
```

- [ ] **Step 2: Run, confirm FAIL**

```bash
cd backend && pytest tests/test_line_reconciliation.py -v
```

Expected: ImportError on `LineMatch` / `match_service_lines`.

- [ ] **Step 3: Implement `match_service_lines()`**

In `backend/src/cyclone/reconcile.py`, add:

```python
@dataclass
class LineMatch:
    """One row of a line-level reconciliation result.

    At least one of ``claim_line`` / ``service_payment`` is non-None.
    Spec §4.1.
    """
    claim_line: Optional["_ClaimLineLike"] = None
    service_payment: Optional["_ServicePaymentLike"] = None
    status: str = ""  # matched | unmatched_835_only | unmatched_837_only


class _ClaimLineLike(Protocol):
    line_number: int
    procedure_code: str
    modifiers: list[str]
    service_date: Optional[date]
    units: Optional[Decimal]


class _ServicePaymentLike(Protocol):
    line_number: int
    procedure_code: str
    modifiers: list[str]
    service_date: Optional[date]
    units: Optional[Decimal]


def _norm_code(code: str) -> str:
    return (code or "").strip().upper()


def _norm_mods(mods: list[str] | None) -> frozenset[str]:
    return frozenset((m or "").strip().upper() for m in (mods or []) if m)


def _svc_matches_claim(svc, claim) -> bool:
    """Strict match per spec §4.2."""
    return (
        _norm_code(svc.procedure_code) == _norm_code(claim.procedure_code)
        and _norm_mods(svc.modifiers) == _norm_mods(claim.modifiers)
        and svc.service_date == claim.service_date
        and svc.units == claim.units
    )


def match_service_lines(
    claim_lines: list[_ClaimLineLike],
    service_payments: list[_ServicePaymentLike],
) -> list[LineMatch]:
    """Pair each 837 SV1 line with the first 835 SVC composite that matches.

    Spec §4. Unmatched lines on either side produce their own LineMatch
    rows so every line is accounted for in the persistence step.
    """
    matches: list[LineMatch] = []
    used_svc_ids: set[int] = set()

    for claim in sorted(claim_lines, key=lambda c: c.line_number):
        match_idx: Optional[int] = None
        for i, svc in enumerate(service_payments):
            if i in used_svc_ids:
                continue
            if _svc_matches_claim(svc, claim):
                match_idx = i
                break
        if match_idx is not None:
            used_svc_ids.add(match_idx)
            matches.append(LineMatch(
                claim_line=claim,
                service_payment=service_payments[match_idx],
                status="matched",
            ))
        else:
            matches.append(LineMatch(
                claim_line=claim,
                service_payment=None,
                status="unmatched_837_only",
            ))

    for i, svc in enumerate(service_payments):
        if i in used_svc_ids:
            continue
        matches.append(LineMatch(
            claim_line=None,
            service_payment=svc,
            status="unmatched_835_only",
        ))

    return matches
```

- [ ] **Step 4: Run, confirm PASS**

```bash
cd backend && pytest tests/test_line_reconciliation.py -v
```

Expected: 11 passed.

- [ ] **Step 5: Commit**

```bash
git add backend/src/cyclone/reconcile.py backend/tests/test_line_reconciliation.py
git commit -m "feat(sp7): match_service_lines() pure function + tests"
```

---

## Task 8: Wire `match_service_lines()` into `reconcile.run()`

**Files:**
- Modify: `backend/src/cyclone/reconcile.py`
- Test: `backend/tests/test_reconcile_line_level.py`

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

Create `backend/tests/test_reconcile_line_level.py`:

```python
"""SP7 — integration test: reconcile.run() persists LineReconciliation rows.

Spec §4.4.
"""
from datetime import date, datetime, timezone
from decimal import Decimal

import pytest
from sqlalchemy import select

from cyclone.db import (
    Batch,
    Claim,
    LineReconciliation,
    Remittance,
    ServiceLinePayment,
    ServiceLine,
    SessionLocal,
)
from cyclone.reconcile import run
from cyclone.store import _remittance_835_row, _persist_835_remit
from cyclone.parsers.models_835 import ClaimPayment, ServicePayment


def _setup_db() -> tuple[str, str]:
    """Build a minimal 837 claim + 835 remit that share PCN. Returns (claim_id, remittance_id)."""
    from cyclone.parsers.models_837 import (
        ClaimOutput, ServiceLine as ParseServiceLine, Procedure,
    )
    import uuid

    bid = str(uuid.uuid4())
    claim_id = "CLM-T1"

    with SessionLocal()() as s:
        # 1. Batch
        s.add(Batch(
            id=bid, source_filename="x.835",
            parsed_at=datetime.now(timezone.utc),
            summary_json={"claim_count": 1, "total_charge": "200", "total_paid": "150"},
            claim_count=1,
        ))
        # 2. Claim with two service lines
        s.add(Claim(
            id=claim_id,
            patient_control_number="PCN-1",
            charge_amount=Decimal("200.00"),
            provider_npi="1234567890",
            payer_id="SKCO0",
            submission_date=date(2026, 6, 1),
            state="submitted",
            batch_id=bid,
        ))
        s.flush()
        s.add(ServiceLine(
            claim_id=claim_id, line_number=1,
            procedure_code="99213",
            modifiers_json="[]",
            charge=Decimal("100.00"),
            service_date=date(2026, 6, 1),
        ))
        s.add(ServiceLine(
            claim_id=claim_id, line_number=2,
            procedure_code="99214",
            modifiers_json="[]",
            charge=Decimal("100.00"),
            service_date=date(2026, 6, 1),
        ))
        # 3. Remit with two SVC composites, one matching claim line 1, one not
        cp = ClaimPayment(
            payer_claim_control_number="PCN-1",
            status_code="1",
            total_charge=Decimal("200.00"),
            total_paid=Decimal("150.00"),
            service_payments=[
                ServicePayment(
                    line_number=1, procedure_qualifier="HC",
                    procedure_code="99213",
                    charge=Decimal("100.00"),
                    payment=Decimal("80.00"),
                    service_date=date(2026, 6, 1),
                ),
                ServicePayment(
                    line_number=2, procedure_qualifier="HC",
                    procedure_code="90837",
                    charge=Decimal("100.00"),
                    payment=Decimal("70.00"),
                    service_date=date(2026, 6, 1),
                ),
            ],
        )
        remit = _remittance_835_row(cp, bid)
        s.add(remit)
        s.flush()
        _persist_835_remit(s, cp, remit.id)
        s.commit()

    return claim_id, bid


def test_run_persists_line_reconciliation_rows():
    claim_id, bid = _setup_db()
    with SessionLocal()() as s:
        result = run(s, bid)
        s.commit()
        assert result.matched == 1

        lrs = list(
            s.execute(select(LineReconciliation).where(LineReconciliation.claim_id == claim_id))
            .scalars().all()
        )
        # Expect 3 rows: 99213 matched, 99214 unmatched_837_only, 90837 unmatched_835_only
        assert len(lrs) == 3
        by_status = {lr.status for lr in lrs}
        assert by_status == {"matched", "unmatched_837_only", "unmatched_835_only"}
```

- [ ] **Step 2: Run, confirm FAIL**

```bash
cd backend && pytest tests/test_reconcile_line_level.py -v
```

Expected: failure — `run()` doesn't persist `LineReconciliation` rows yet.

- [ ] **Step 3: Extend `reconcile.run()`**

In `backend/src/cyclone/reconcile.py`, inside `run()`, AFTER the existing claim↔remit match loop and BEFORE the CAS aggregate recompute, insert:

```python
    # SP7: line-level reconciliation. For each matched (claim, remit) pair,
    # find each 837 service line's matching 835 SVC composite and persist
    # a LineReconciliation row per line on either side (matched or
    # explicitly unmatched). See spec §4.4.
    from cyclone.db import LineReconciliation, ServiceLinePayment, ServiceLine as ClaimServiceLine
    for m in matches:
        if m.is_reversal:
            continue  # reversals are handled by a separate path (see §7.3)
        claim_id = m.claim.id
        remit_id = m.remittance.id

        claim_lines = list(
            session.execute(
                select(ClaimServiceLine)
                .where(ClaimServiceLine.claim_id == claim_id)
                .order_by(ClaimServiceLine.line_number)
            ).scalars().all()
        )
        svc_payments = list(
            session.execute(
                select(ServiceLinePayment)
                .where(ServiceLinePayment.remittance_id == remit_id)
                .order_by(ServiceLinePayment.line_number)
            ).scalars().all()
        )

        line_matches = match_service_lines(
            [_ClaimLineShim(line) for line in claim_lines],
            [_SvcPaymentShim(svc) for svc in svc_payments],
        )

        now = datetime.now(timezone.utc)
        for lm in line_matches:
            session.add(LineReconciliation(
                claim_id=claim_id,
                claim_service_line_id=lm.claim_line.line_id if lm.claim_line else None,
                service_line_payment_id=lm.service_payment.id if lm.service_payment else None,
                status=lm.status,
                reconciled_at=now,
            ))

    # SP7: claim-level adjustment aggregate. Sum CAS rows whose
    # service_line_payment_id IS NULL — these are CLP-level adjustments,
    # not per-line. Persist on the remit for fast reads.
    from sqlalchemy import func as _func
    for r in new_remits:
        cl_total = session.execute(
            select(_func.sum(CasAdjustment.amount))
            .where(CasAdjustment.remittance_id == r.id)
            .where(CasAdjustment.service_line_payment_id.is_(None))
        ).scalar_one() or Decimal("0")
        r.claim_level_adjustment_amount = cl_total
```

Then add the shims at module top level (after the existing protocol definitions):

```python
@dataclass
class _ClaimLineShim:
    """Adapt an ORM ClaimServiceLine to the _ClaimLineLike protocol.

    Used by ``reconcile.run()`` to call ``match_service_lines()``.
    """
    line_id: int
    line_number: int
    procedure_code: str
    modifiers: list[str]
    service_date: Optional[date]
    units: Optional[Decimal]

    @classmethod
    def from_orm(cls, line) -> "_ClaimLineShim":
        import json as _json
        return cls(
            line_id=line.id,
            line_number=line.line_number,
            procedure_code=line.procedure_code,
            modifiers=_json.loads(line.modifiers_json or "[]"),
            service_date=line.service_date,
            units=Decimal(str(line.units)) if line.units is not None else None,
        )

    def __init__(self, line):  # accept ORM directly for convenience
        import json as _json
        self.line_id = line.id
        self.line_number = line.line_number
        self.procedure_code = line.procedure_code
        self.modifiers = _json.loads(line.modifiers_json or "[]")
        self.service_date = line.service_date
        self.units = Decimal(str(line.units)) if line.units is not None else None


@dataclass
class _SvcPaymentShim:
    line_id: int
    line_number: int
    procedure_code: str
    modifiers: list[str]
    service_date: Optional[date]
    units: Optional[Decimal]

    def __init__(self, svc):
        import json as _json
        self.line_id = svc.id
        self.line_number = svc.line_number
        self.procedure_code = svc.procedure_code
        self.modifiers = _json.loads(svc.modifiers_json or "[]"),
        self.service_date = svc.service_date
        self.units = Decimal(str(svc.units)) if svc.units is not None else None
```

- [ ] **Step 4: Run, confirm PASS**

```bash
cd backend && pytest tests/test_reconcile_line_level.py -v
```

Expected: PASS.

- [ ] **Step 5: Full backend suite**

```bash
cd backend && pytest -q
```

Expected: same baseline.

- [ ] **Step 6: Commit**

```bash
git add backend/src/cyclone/reconcile.py backend/tests/test_reconcile_line_level.py
git commit -m "feat(sp7): wire match_service_lines into reconcile.run"
```

---

## Phase 3 — API

## Task 9: `GET /api/claims/{id}/line-reconciliation` endpoint

**Files:**
- Modify: `backend/src/cyclone/api.py`
- Test: `backend/tests/test_api_line_reconciliation.py`

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

Create `backend/tests/test_api_line_reconciliation.py`:

```python
"""SP7 — endpoint test for GET /api/claims/{id}/line-reconciliation.

Spec §5.1.
"""
from datetime import date, datetime, timezone
from decimal import Decimal

import pytest
from fastapi.testclient import TestClient

from cyclone import db
from cyclone.api import app


@pytest.fixture
def client():
    return TestClient(app)


def _seed(client):
    """Insert one claim + matching remit + 4 service lines (3 matched, 1 unmatched 837).
    Returns the claim id."""
    import uuid, json as _json
    from sqlalchemy import select
    bid = str(uuid.uuid4())
    claim_id = "CLM-API-1"

    with db.SessionLocal()() as s:
        s.add(db.Batch(
            id=bid, source_filename="x.835",
            parsed_at=datetime.now(timezone.utc),
            summary_json={"claim_count": 1, "total_charge": "300", "total_paid": "200"},
            claim_count=1,
        ))
        s.add(db.Claim(
            id=claim_id,
            patient_control_number="PCN-API",
            charge_amount=Decimal("300.00"),
            provider_npi="1234567890",
            payer_id="SKCO0",
            submission_date=date(2026, 6, 1),
            state="submitted",
            batch_id=bid,
        ))
        s.flush()
        # Three matching lines + one 837-only
        for i, code in enumerate(["99213", "99214", "99215", "90837"], start=1):
            s.add(db.ServiceLine(
                claim_id=claim_id, line_number=i,
                procedure_code=code, modifiers_json="[]",
                charge=Decimal("100.00"),
                service_date=date(2026, 6, i),
            ))
        # Remit pays the first 3 lines
        from cyclone.parsers.models_835 import ClaimPayment, ServicePayment
        cp = ClaimPayment(
            payer_claim_control_number="PCN-API",
            status_code="1",
            total_charge=Decimal("300.00"),
            total_paid=Decimal("240.00"),
            service_payments=[
                ServicePayment(line_number=1, procedure_qualifier="HC",
                               procedure_code=c, charge=Decimal("100.00"),
                               payment=Decimal("80.00"), service_date=date(2026, 6, i))
                for i, c in enumerate(["99213", "99214", "99215"], start=1)
            ],
        )
        from cyclone.store import _remittance_835_row, _persist_835_remit
        remit = _remittance_835_row(cp, bid)
        s.add(remit)
        s.flush()
        _persist_835_remit(s, cp, remit.id)
        s.commit()

        # Run reconcile so matched_remittance_id gets set + LineReconciliation rows persist
        from cyclone.reconcile import run
        run(s, bid)
        s.commit()

    return claim_id


def test_endpoint_returns_documented_shape(client):
    claim_id = _seed(client)
    r = client.get(f"/api/claims/{claim_id}/line-reconciliation")
    assert r.status_code == 200, r.text
    body = r.json()
    assert body["claim_id"] == claim_id
    assert body["summary"]["total_lines"] == 4
    assert body["summary"]["matched_lines"] == 3
    statuses = sorted(row["status"] for row in body["lines"])
    assert statuses == ["matched", "matched", "matched", "unmatched_837_only"]


def test_endpoint_404_for_unknown_claim(client):
    r = client.get("/api/claims/UNKNOWN/line-reconciliation")
    assert r.status_code == 404
```

- [ ] **Step 2: Run, confirm FAIL**

```bash
cd backend && pytest tests/test_api_line_reconciliation.py -v
```

Expected: 404 from FastAPI (route not registered).

- [ ] **Step 3: Implement the endpoint**

In `backend/src/cyclone/api.py`, add:

```python
@app.get("/api/claims/{claim_id}/line-reconciliation")
def get_claim_line_reconciliation(claim_id: str):
    """Per-line reconciliation view for the ClaimDrawer tab.

    Spec §5.1. Returns the 837 service lines and 835 SVC composites
    side-by-side, with per-line CAS adjustments.
    """
    from sqlalchemy import select
    from cyclone.db import (
        LineReconciliation, ServiceLine, ServiceLinePayment, CasAdjustment,
    )
    import json as _json
    from decimal import Decimal

    with db.SessionLocal()() as s:
        claim = s.get(db.Claim, claim_id)
        if claim is None:
            raise HTTPException(404, f"claim {claim_id} not found")

        # Load 837 lines + 835 svc payments + line reconciliation rows
        claim_lines = list(
            s.execute(
                select(ServiceLine).where(ServiceLine.claim_id == claim_id)
            ).scalars().all()
        )
        lrs = list(
            s.execute(
                select(LineReconciliation).where(LineReconciliation.claim_id == claim_id)
            ).scalars().all()
        )

        # Index by claim_service_line_id and service_line_payment_id for lookup
        lr_by_claim_line: dict[int, LineReconciliation] = {
            lr.claim_service_line_id: lr for lr in lrs if lr.claim_service_line_id is not None
        }
        lr_by_svc: dict[int, LineReconciliation] = {
            lr.service_line_payment_id: lr for lr in lrs if lr.service_line_payment_id is not None
        }

        # Load all relevant svc payments in one go
        svc_ids = [lr.service_line_payment_id for lr in lrs if lr.service_line_payment_id is not None]
        svc_by_id: dict[int, ServiceLinePayment] = {}
        if svc_ids:
            for svc in s.execute(select(ServiceLinePayment).where(ServiceLinePayment.id.in_(svc_ids))).scalars().all():
                svc_by_id[svc.id] = svc

        # Load CAS adjustments grouped by service_line_payment_id
        cas_by_svc: dict[int, list[CasAdjustment]] = {}
        if svc_ids:
            for cas in s.execute(select(CasAdjustment).where(CasAdjustment.service_line_payment_id.in_(svc_ids))).scalars().all():
                cas_by_svc.setdefault(cas.service_line_payment_id, []).append(cas)

        # Build the lines[] array, preserving claim-line ordering plus unmatched 835 lines at the end
        lines_out: list[dict] = []
        billed_total = Decimal("0")
        paid_total = Decimal("0")
        adjustment_total = Decimal("0")
        matched_count = 0

        for cl in sorted(claim_lines, key=lambda c: c.line_number):
            billed_total += Decimal(str(cl.charge))
            lr = lr_by_claim_line.get(cl.id)
            if lr is None:
                # Claim line exists but no LineReconciliation row (rare).
                lines_out.append({
                    "claim_service_line": _claim_line_to_dict(cl),
                    "service_line_payment": None,
                    "status": "unmatched_837_only",
                    "adjustments": [],
                })
                continue
            svc = svc_by_id.get(lr.service_line_payment_id) if lr.service_line_payment_id else None
            cas_list = cas_by_svc.get(lr.service_line_payment_id, []) if lr.service_line_payment_id else []
            cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
            if svc:
                paid_total += Decimal(str(svc.payment))
            adjustment_total += cas_total
            if lr.status == "matched":
                matched_count += 1
            lines_out.append({
                "claim_service_line": _claim_line_to_dict(cl),
                "service_line_payment": _svc_to_dict(svc) if svc else None,
                "status": lr.status,
                "adjustments": [
                    {"group_code": c.group_code, "reason_code": c.reason_code,
                     "amount": str(Decimal(str(c.amount)))}
                    for c in cas_list
                ],
            })

        # Unmatched 835 lines (no claim_service_line)
        for lr in lrs:
            if lr.claim_service_line_id is not None:
                continue
            svc = svc_by_id.get(lr.service_line_payment_id)
            if svc:
                paid_total += Decimal(str(svc.payment))
            cas_list = cas_by_svc.get(lr.service_line_payment_id, []) if lr.service_line_payment_id else []
            cas_total = sum((Decimal(str(c.amount)) for c in cas_list), Decimal("0"))
            adjustment_total += cas_total
            lines_out.append({
                "claim_service_line": None,
                "service_line_payment": _svc_to_dict(svc) if svc else None,
                "status": lr.status,
                "adjustments": [
                    {"group_code": c.group_code, "reason_code": c.reason_code,
                     "amount": str(Decimal(str(c.amount)))}
                    for c in cas_list
                ],
            })

        total_lines = len(claim_lines)  # billed line count

    return {
        "claim_id": claim_id,
        "summary": {
            "billed_total": str(billed_total),
            "paid_total": str(paid_total),
            "adjustment_total": str(adjustment_total),
            "matched_lines": matched_count,
            "total_lines": total_lines,
        },
        "lines": lines_out,
    }


def _claim_line_to_dict(cl) -> dict:
    import json as _json
    return {
        "id": cl.id,
        "line_number": cl.line_number,
        "procedure_qualifier": cl.procedure_qualifier if hasattr(cl, "procedure_qualifier") else "HC",
        "procedure_code": cl.procedure_code,
        "modifiers": _json.loads(cl.modifiers_json or "[]"),
        "charge": str(Decimal(str(cl.charge))),
        "units": str(Decimal(str(cl.units))) if cl.units is not None else None,
        "unit_type": cl.unit_type if hasattr(cl, "unit_type") else None,
        "service_date": cl.service_date.isoformat() if cl.service_date else None,
    }


def _svc_to_dict(svc) -> dict:
    import json as _json
    return {
        "id": svc.id,
        "line_number": svc.line_number,
        "procedure_qualifier": svc.procedure_qualifier,
        "procedure_code": svc.procedure_code,
        "modifiers": _json.loads(svc.modifiers_json or "[]"),
        "charge": str(Decimal(str(svc.charge))),
        "payment": str(Decimal(str(svc.payment))),
        "units": str(Decimal(str(svc.units))) if svc.units is not None else None,
        "unit_type": svc.unit_type,
        "service_date": svc.service_date.isoformat() if svc.service_date else None,
    }
```

- [ ] **Step 4: Run, confirm PASS**

```bash
cd backend && pytest tests/test_api_line_reconciliation.py -v
```

Expected: 2 passed.

- [ ] **Step 5: Commit**

```bash
git add backend/src/cyclone/api.py backend/tests/test_api_line_reconciliation.py
git commit -m "feat(sp7): GET /api/claims/{id}/line-reconciliation endpoint"
```

---

## Task 10: Augment `GET /api/claims/{id}` with `lineReconciliation` slim projection

**Files:**
- Modify: `backend/src/cyclone/api.py`
- Test: existing `test_api_claim_detail.py`

- [ ] **Step 1: Find the existing claim detail endpoint**

In `backend/src/cyclone/api.py`, find the function decorated with `@app.get("/api/claims/{claim_id}")` (NOT the new line-reconciliation one from T9). Find where it builds the response dict.

- [ ] **Step 2: Add the `lineReconciliation` field**

After the existing fields, add:

```python
        # SP7: slim per-line projection — enough for the ServiceLinesTable
        # columns without forcing a second fetch.
        from cyclone.db import LineReconciliation as LR
        from sqlalchemy import select as _select
        slim_lrs = list(
            s.execute(
                _select(LR).where(LR.claim_id == claim_id)
            ).scalars().all()
        )
        # Index for O(1) lookup by claim_service_line_id
        slim_by_cl = {lr.claim_service_line_id: lr for lr in slim_lrs if lr.claim_service_line_id is not None}
        # Aggregate per-line CAS sums
        from cyclone.db import CasAdjustment as CA
        cas_sums_by_svc: dict[int, str] = {}
        svc_ids_for_cas = [lr.service_line_payment_id for lr in slim_lrs if lr.service_line_payment_id is not None]
        if svc_ids_for_cas:
            cas_rows = s.execute(
                _select(CA.service_line_payment_id, CA.amount)
                .where(CA.service_line_payment_id.in_(svc_ids_for_cas))
            ).all()
            from collections import defaultdict
            agg = defaultdict(lambda: Decimal("0"))
            for svc_id, amount in cas_rows:
                agg[svc_id] += Decimal(str(amount))
            cas_sums_by_svc = {k: str(v) for k, v in agg.items()}

        # Load svc payments for paid amounts
        from cyclone.db import ServiceLinePayment as SLP
        svc_by_id_slim: dict[int, SLP] = {}
        if svc_ids_for_cas:
            for svc in s.execute(_select(SLP).where(SLP.id.in_(svc_ids_for_cas))).scalars().all():
                svc_by_id_slim[svc.id] = svc

        line_reconciliation_slim = []
        for cl in sorted(service_lines, key=lambda c: c.line_number):
            lr = slim_by_cl.get(cl.id)
            if lr is None:
                line_reconciliation_slim.append({
                    "claim_service_line_id": cl.id,
                    "line_number": cl.line_number,
                    "status": "unmatched_837_only",
                    "paid": None,
                    "adjustments_sum": None,
                })
                continue
            svc = svc_by_id_slim.get(lr.service_line_payment_id) if lr.service_line_payment_id else None
            line_reconciliation_slim.append({
                "claim_service_line_id": cl.id,
                "line_number": cl.line_number,
                "status": lr.status,
                "paid": str(Decimal(str(svc.payment))) if svc else None,
                "adjustments_sum": cas_sums_by_svc.get(lr.service_line_payment_id) if lr.service_line_payment_id else None,
            })

        response["lineReconciliation"] = line_reconciliation_slim
```

(If `service_lines` is named differently in the existing function, use whatever the local variable is. Read the existing endpoint to find the right name.)

- [ ] **Step 3: Run claim-detail tests**

```bash
cd backend && pytest tests/test_api_claim_detail.py -v
```

Expected: PASS. (Existing tests don't assert on the absence of `lineReconciliation`, so they'll pass with the new field present.)

- [ ] **Step 4: Add a test asserting the new field exists**

Append to `tests/test_api_claim_detail.py`:

```python
def test_claim_detail_includes_line_reconciliation_slim_projection(client, seeded_claim):
    r = client.get(f"/api/claims/{seeded_claim['id']}")
    assert r.status_code == 200
    body = r.json()
    assert "lineReconciliation" in body
    assert isinstance(body["lineReconciliation"], list)
```

If the fixture isn't called `seeded_claim`, use the equivalent. Read the file's existing fixtures first.

- [ ] **Step 5: Commit**

```bash
git add backend/src/cyclone/api.py backend/tests/test_api_claim_detail.py
git commit -m "feat(sp7): claim detail includes lineReconciliation slim projection"
```

---

## Task 11: Augment `GET /api/remittances/{id}` with `serviceLinePayments` + `claimLevelAdjustments`

**Files:**
- Modify: `backend/src/cyclone/api.py`
- Test: existing remit detail tests (find them via `pytest --collect-only`)

- [ ] **Step 1: Find the remit detail endpoint**

In `backend/src/cyclone/api.py`, find the function decorated with `@app.get("/api/remittances/{remittance_id}")`.

- [ ] **Step 2: Add the two new fields**

Before `return`:

```python
        # SP7: per-line SVC composites + claim-level CAS bucket
        from cyclone.db import ServiceLinePayment as SLP2, CasAdjustment as CA2
        from sqlalchemy import select as _select2

        slps = list(
            s.execute(
                _select2(SLP2).where(SLP2.remittance_id == remittance_id)
                .order_by(SLP2.line_number)
            ).scalars().all()
        )
        slp_dicts = [_svc_to_dict(svc) for svc in slps]

        cl_cas = list(
            s.execute(
                _select2(CA2).where(
                    (CA2.remittance_id == remittance_id)
                    & (CA2.service_line_payment_id.is_(None))
                )
            ).scalars().all()
        )

        response["serviceLinePayments"] = slp_dicts
        response["claimLevelAdjustments"] = [
            {
                "id": c.id,
                "group_code": c.group_code,
                "reason_code": c.reason_code,
                "amount": str(Decimal(str(c.amount))),
                "quantity": str(Decimal(str(c.quantity))) if c.quantity is not None else None,
            }
            for c in cl_cas
        ]
```

(Use the local response variable name as appropriate.)

- [ ] **Step 3: Run remit detail tests**

```bash
cd backend && pytest -k "remit" -q
```

Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add backend/src/cyclone/api.py
git commit -m "feat(sp7): remit detail includes serviceLinePayments + claimLevelAdjustments"
```

---

## Task 12: Augment `GET /api/inbox/lanes` `matched_remittance` payload

**Files:**
- Modify: `backend/src/cyclone/api.py`
- Test: `backend/tests/test_inbox_endpoints.py`

- [ ] **Step 1: Find the inbox lanes endpoint**

In `backend/src/cyclone/api.py`, find `inbox_lanes()` (or whatever the inbox lanes route function is named).

- [ ] **Step 2: Add line counts to the `matched_remittance` payload**

Inside the loop that builds the rejected/candidates rows that include a matched remittance, find where `matched_remittance` dict is built. Add:

```python
                    # SP7: matched/total line counts for the MatchedRemitCard badge
                    from cyclone.db import LineReconciliation as LR3, ServiceLine as ClaimLine2
                    from sqlalchemy import select as _select3
                    total_lines_for_claim = s.execute(
                        _select3(_select3.func.count())
                        .select_from(ClaimLine2)
                        .where(ClaimLine2.claim_id == claim.id)
                    ).scalar_one() or 0
                    matched_lines_for_claim = s.execute(
                        _select3(_select3.func.count())
                        .select_from(LR3)
                        .where((LR3.claim_id == claim.id) & (LR3.status == "matched"))
                    ).scalar_one() or 0
                    matched_remittance_dict["matched_lines"] = int(matched_lines_for_claim)
                    matched_remittance_dict["total_lines"] = int(total_lines_for_claim)
```

If the existing code uses a different variable name for the dict, use that. Read the function carefully first.

- [ ] **Step 3: Run inbox endpoint tests**

```bash
cd backend && pytest tests/test_inbox_endpoints.py -v
```

Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add backend/src/cyclone/api.py
git commit -m "feat(sp7): inbox lanes matched_remittance includes matched/total line counts"
```

---

## Phase 4 — Frontend types & API client

## Task 13: Add types to `src/types/index.ts`

**Files:**
- Modify: `src/types/index.ts`

- [ ] **Step 1: Add new types**

Append to `src/types/index.ts`:

```typescript
// ---------------------------------------------------------------------------
// SP7 — Per-Service-Line Adjustment Audit types.
// See docs/superpowers/specs/2026-06-20-cyclone-line-reconciliation-design.md
// ---------------------------------------------------------------------------

export interface ServiceLinePayment {
  id: number;
  lineNumber: number;
  procedureQualifier: string;
  procedureCode: string;
  modifiers: string[];
  charge: string;
  payment: string;
  units: string | null;
  unitType: string | null;
  serviceDate: string | null;
}

export type LineReconciliationStatus =
  | "matched"
  | "unmatched_835_only"
  | "unmatched_837_only"
  | "superseded";

export interface ClaimDetailLineReconciliation {
  claimServiceLineId: number;
  lineNumber: number;
  status: LineReconciliationStatus;
  paid: string | null;
  adjustmentsSum: string | null;
}

export interface LineReconciliationSummary {
  billedTotal: string;
  paidTotal: string;
  adjustmentTotal: string;
  matchedLines: number;
  totalLines: number;
}

export interface LineReconciliationRow {
  claimServiceLine: {
    id: number;
    lineNumber: number;
    procedureQualifier: string;
    procedureCode: string;
    modifiers: string[];
    charge: string;
    units: string | null;
    unitType: string | null;
    serviceDate: string | null;
  } | null;
  serviceLinePayment: ServiceLinePayment | null;
  status: LineReconciliationStatus;
  adjustments: Array<{
    groupCode: string;
    reasonCode: string;
    amount: string;
  }>;
}

export interface LineReconciliationResponse {
  claimId: string;
  summary: LineReconciliationSummary;
  lines: LineReconciliationRow[];
}

export interface RemittanceClaimLevelAdjustment {
  id: number;
  groupCode: string;
  reasonCode: string;
  amount: string;
  quantity: string | null;
}

export interface MatchedRemittanceLineCounts {
  matchedLines: number;
  totalLines: number;
}
```

- [ ] **Step 2: Update `ClaimDetail` to include `lineReconciliation`**

Find `ClaimDetail` in `src/types/index.ts`. Add the field:

```typescript
  lineReconciliation: ClaimDetailLineReconciliation[];
```

- [ ] **Step 3: Update `RemittanceDetail`**

Find the remittance detail type (probably named `RemittanceDetail` or similar — read the file to find it). Add:

```typescript
  serviceLinePayments: ServiceLinePayment[];
  claimLevelAdjustments: RemittanceClaimLevelAdjustment[];
```

(If the type uses a different name like `Remittance` for the detail payload, use that. Read first.)

- [ ] **Step 4: Run typecheck**

```bash
pnpm tsc --noEmit
```

Expected: errors in places that use the old types without the new fields. We'll fix those in subsequent tasks.

- [ ] **Step 5: Commit**

```bash
git add src/types/index.ts
git commit -m "feat(sp7): frontend types — ServiceLinePayment, LineReconciliation, MatchedRemittance"
```

---

## Task 14: Update `src/lib/inbox-api.ts` with `matched_lines`/`total_lines`

**Files:**
- Modify: `src/lib/inbox-api.ts`

- [ ] **Step 1: Update `InboxMatchedRemittance` type**

Find the type for the matched-remittance object in `inbox-api.ts`. Add:

```typescript
  matchedLines: number;
  totalLines: number;
```

- [ ] **Step 2: Update `fetchInboxLanes` test fixtures**

If `inbox-api.test.ts` has mock fixtures with `matched_remittance`, add the two fields with sensible defaults (e.g. `matchedLines: 4, totalLines: 4`).

- [ ] **Step 3: Run tests**

```bash
pnpm vitest run src/lib/inbox-api.test.ts
```

Expected: PASS.

- [ ] **Step 4: Commit**

```bash
git add src/lib/inbox-api.ts src/lib/inbox-api.test.ts
git commit -m "feat(sp7): inbox API types include matched/total line counts"
```

---

## Phase 5 — UI

## Task 15: `ServiceLinesTable` — Paid + Adjustments columns

**Files:**
- Modify: `src/components/ClaimDrawer/ServiceLinesTable.tsx`
- Test: `src/components/ClaimDrawer/ServiceLinesTable.test.tsx`

- [ ] **Step 1: Read existing component**

Read `src/components/ClaimDrawer/ServiceLinesTable.tsx` to understand the current 5-column structure and the row data shape.

- [ ] **Step 2: Add the two new columns**

Modify the table to render two more `<th>` and `<td>` cells per row. The component should accept an optional `lineReconciliation: ClaimDetailLineReconciliation[]` prop (default to `[]`). For each row, look up the entry by `lineNumber` and render:

- Paid: `fmt(lineReconciliation.paid)` if non-null, else `—`
- Adjustments: `fmt(lineReconciliation.adjustmentsSum)` if non-null, else `—`

The cell style should match the existing "charge" column (mono, tabular-nums, right-aligned, dim ink for `—`).

- [ ] **Step 3: Update existing tests**

If `ServiceLinesTable.test.tsx` uses a non-optional prop or fixture, update the fixture to include `lineReconciliation: []` so existing assertions still hold.

- [ ] **Step 4: Add new tests**

Append to `ServiceLinesTable.test.tsx`:

```tsx
it("renders Paid and Adjustments columns from lineReconciliation prop", () => {
  const lineReconciliation = [
    { claimServiceLineId: 1, lineNumber: 1, status: "matched" as const,
      paid: "80.00", adjustmentsSum: "20.00" },
    { claimServiceLineId: 2, lineNumber: 2, status: "unmatched_837_only" as const,
      paid: null, adjustmentsSum: null },
  ];
  const { container } = render(
    <ServiceLinesTable
      serviceLines={[/* existing fixtures with line_number 1 and 2 */]}
      lineReconciliation={lineReconciliation}
    />
  );
  // line 1: matched
  expect(container.textContent).toContain("$80.00");
  expect(container.textContent).toContain("$20.00");
  // line 2: empty cells render em-dash
  // (look for "—" in the relevant <td> cells)
});

it("renders em-dash for missing lineReconciliation entries", () => {
  const { container } = render(
    <ServiceLinesTable
      serviceLines={[/* one line fixture */]}
      lineReconciliation={[]}
    />
  );
  // Expect at least two em-dashes (Paid + Adjustments for the one row)
  expect((container.textContent ?? "").split("—").length - 1).toBeGreaterThanOrEqual(2);
});
```

(Fill in the serviceLines fixture with whatever shape the existing tests use.)

- [ ] **Step 5: Run tests**

```bash
pnpm vitest run src/components/ClaimDrawer/ServiceLinesTable.test.tsx
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/components/ClaimDrawer/ServiceLinesTable.tsx src/components/ClaimDrawer/ServiceLinesTable.test.tsx
git commit -m "feat(sp7): ServiceLinesTable — Paid + Adjustments columns"
```

---

## Task 16: `CasAdjustmentsPanel` — Line column

**Files:**
- Modify: `src/components/RemitDrawer/CasAdjustmentsPanel.tsx`
- Test: `src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx`

- [ ] **Step 1: Read existing component**

Read `src/components/RemitDrawer/CasAdjustmentsPanel.tsx` to understand the current row shape and how it groups claim-level adjustments.

- [ ] **Step 2: Add a Line column to each row**

The component receives a list of CAS adjustments. Each row should now show `proc · #N` for the SVC-level CAS rows (where the row's `serviceLinePaymentId` is non-null) and `—` for claim-level rows. Cluster the claim-level rows at the bottom under a "Claim-level adjustments" sub-header.

If the component currently groups by `serviceLinePaymentId`, the Line column just renders that group's header (procedure code · line number). If it doesn't, you'll need to add the grouping.

- [ ] **Step 3: Update fixture data**

Existing tests likely pass rows like `{ group_code, reason_code, amount }`. Add `service_line_payment: { procedure_code, line_number } | null` and a `groupBySvc: boolean` flag if needed.

- [ ] **Step 4: Add a new test**

```tsx
it("renders line column with procedure code + line number for SVC-level CAS", () => {
  const rows = [
    { id: 1, groupCode: "CO", reasonCode: "45", amount: "20.00",
      serviceLinePayment: { lineNumber: 1, procedureCode: "99213" } },
  ];
  const { container } = render(<CasAdjustmentsPanel rows={rows} />);
  expect(container.textContent).toMatch(/99213.*#1/);
});

it("separates claim-level adjustments into a sub-cluster", () => {
  const rows = [
    { id: 1, groupCode: "CO", reasonCode: "45", amount: "20.00",
      serviceLinePayment: { lineNumber: 1, procedureCode: "99213" } },
    { id: 2, groupCode: "PR", reasonCode: "1", amount: "10.00",
      serviceLinePayment: null },
  ];
  const { container } = render(<CasAdjustmentsPanel rows={rows} />);
  expect(container.textContent).toMatch(/Claim-level adjustments/i);
});
```

(Adjust the prop names to match whatever the existing component uses.)

- [ ] **Step 5: Run tests**

```bash
pnpm vitest run src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/components/RemitDrawer/CasAdjustmentsPanel.tsx src/components/RemitDrawer/CasAdjustmentsPanel.test.tsx
git commit -m "feat(sp7): CasAdjustmentsPanel — Line column + claim-level cluster"
```

---

## Task 17: `MatchedRemitCard` — line-count badge

**Files:**
- Modify: `src/components/ClaimDrawer/MatchedRemitCard.tsx`
- Test: `src/components/ClaimDrawer/MatchedRemitCard.test.tsx`

- [ ] **Step 1: Read existing component**

Read `MatchedRemitCard.tsx` to see the current card structure and the `matchedRemittance` prop type.

- [ ] **Step 2: Add the badge**

At the bottom of the card, add a small mono line:

```tsx
const matched = matchedRemittance.matchedLines;
const total = matchedRemittance.totalLines;
const allMatched = matched === total;
const badgeColor = allMatched ? "var(--tt-amber)" /* or green if available */ : "var(--tt-amber)";
// ...
<div className="text-xs font-mono" style={{ color: badgeColor }}>
  lines: {matched}/{total} matched
  {!allMatched && ` (${total - matched} unmatched)`}
</div>
```

Use whatever ink color tokens match the rest of the design. If there's a dedicated "success" token (e.g. `--tt-success`), use it for the full-match case.

- [ ] **Step 3: Update fixture**

Existing test fixtures for `MatchedRemitCard` need to include `matchedLines: 4, totalLines: 4`. Add the fields with default values.

- [ ] **Step 4: Add new tests**

```tsx
it("renders full-match badge when matchedLines equals totalLines", () => {
  const rem = { /* existing fixture */, matchedLines: 4, totalLines: 4 };
  const { container } = render(<MatchedRemitCard matchedRemittance={rem} />);
  expect(container.textContent).toMatch(/4\/4 matched/);
});

it("renders partial-match badge with unmatched count", () => {
  const rem = { /* existing fixture */, matchedLines: 3, totalLines: 4 };
  const { container } = render(<MatchedRemitCard matchedRemittance={rem} />);
  expect(container.textContent).toMatch(/3\/4 matched/);
  expect(container.textContent).toMatch(/1 unmatched/);
});
```

- [ ] **Step 5: Run tests**

```bash
pnpm vitest run src/components/ClaimDrawer/MatchedRemitCard.test.tsx
```

Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add src/components/ClaimDrawer/MatchedRemitCard.tsx src/components/ClaimDrawer/MatchedRemitCard.test.tsx
git commit -m "feat(sp7): MatchedRemitCard — line-count badge"
```

---

## Task 18: `LineReconciliationTab` — new component

**Files:**
- Create: `src/components/ClaimDrawer/LineReconciliationTab.tsx`
- Create: `src/components/ClaimDrawer/LineReconciliationTab.test.tsx`

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

Create `src/components/ClaimDrawer/LineReconciliationTab.test.tsx`:

```tsx
// @vitest-environment happy-dom
import { afterEach, describe, expect, it } from "vitest";
import { cleanup, render } from "@testing-library/react";
import { LineReconciliationTab } from "./LineReconciliationTab";
import type { LineReconciliationResponse } from "@/types";

afterEach(() => cleanup());

const matchedAll: LineReconciliationResponse = {
  claimId: "CLM-1",
  summary: {
    billedTotal: "200.00", paidTotal: "160.00",
    adjustmentTotal: "40.00", matchedLines: 2, totalLines: 2,
  },
  lines: [
    {
      claimServiceLine: { id: 1, lineNumber: 1, procedureQualifier: "HC",
        procedureCode: "99213", modifiers: [], charge: "100.00",
        units: "1", unitType: "UN", serviceDate: "2026-06-01" },
      serviceLinePayment: { id: 1, lineNumber: 1, procedureQualifier: "HC",
        procedureCode: "99213", modifiers: [], charge: "100.00",
        payment: "80.00", units: "1", unitType: "UN", serviceDate: "2026-06-01" },
      status: "matched",
      adjustments: [
        { groupCode: "CO", reasonCode: "45", amount: "20.00" },
      ],
    },
  ],
};

describe("LineReconciliationTab", () => {
  it("renders summary totals", () => {
    const { container } = render(<LineReconciliationTab data={matchedAll} />);
    expect(container.textContent).toMatch(/billed/i);
    expect(container.textContent).toMatch(/paid/i);
    expect(container.textContent).toMatch(/adjustments/i);
  });

  it("renders full-match summary line", () => {
    const { container } = render(<LineReconciliationTab data={matchedAll} />);
    expect(container.textContent).toMatch(/matched 2 of 2 lines/i);
  });

  it("renders partial-match summary line when lines are unmatched", () => {
    const partial: LineReconciliationResponse = {
      ...matchedAll,
      summary: { ...matchedAll.summary, matchedLines: 1, totalLines: 2 },
      lines: [
        matchedAll.lines[0]!,
        {
          claimServiceLine: { id: 2, lineNumber: 2, procedureQualifier: "HC",
            procedureCode: "99214", modifiers: [], charge: "100.00",
            units: "1", unitType: "UN", serviceDate: "2026-06-01" },
          serviceLinePayment: null,
          status: "unmatched_837_only",
          adjustments: [],
        },
      ],
    };
    const { container } = render(<LineReconciliationTab data={partial} />);
    expect(container.textContent).toMatch(/matched 1 of 2 lines/i);
  });

  it("renders unmatched 835 line with oxblood accent", () => {
    const with835Only: LineReconciliationResponse = {
      ...matchedAll,
      lines: [
        matchedAll.lines[0]!,
        {
          claimServiceLine: null,
          serviceLinePayment: { id: 2, lineNumber: 2, procedureQualifier: "HC",
            procedureCode: "90837", modifiers: [], charge: "100.00",
            payment: "70.00", units: "1", unitType: "UN", serviceDate: "2026-06-01" },
          status: "unmatched_835_only",
          adjustments: [],
        },
      ],
    };
    const { container } = render(<LineReconciliationTab data={with835Only} />);
    expect(container.textContent).toMatch(/90837/);
    expect(container.textContent).toMatch(/no 837 line matched/i);
  });
});
```

- [ ] **Step 2: Run, confirm FAIL**

```bash
pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx
```

Expected: ImportError.

- [ ] **Step 3: Implement the component**

Create `src/components/ClaimDrawer/LineReconciliationTab.tsx`:

```tsx
import { fmt } from "@/lib/format";
import type {
  LineReconciliationResponse,
  LineReconciliationRow,
} from "@/types";

/**
 * Per-line reconciliation tab for the ClaimDrawer (SP7).
 *
 * Two-column layout: left = "Billed (837 SV1)", right = "Adjudicated (835 SVC)".
 * Each row is a service line, matched when both sides are present; unmatched
 * 835 lines appear at the bottom with an oxblood accent rail. Per-line CAS
 * adjustments are listed under each row.
 *
 * Spec §6.2.
 */
export function LineReconciliationTab({
  data,
}: {
  data: LineReconciliationResponse;
}) {
  const { summary, lines } = data;
  const allMatched = summary.matchedLines === summary.totalLines;

  // Separate matched+837_only (left column) from 835_only (right column tail)
  const leftRows = lines.filter((l) => l.status !== "unmatched_835_only");
  const rightRows = lines; // all rows, in their original order

  return (
    <section className="flex flex-col gap-4 px-6 py-4">
      <header
        className="flex items-baseline justify-between"
        data-testid="line-reconciliation-summary"
      >
        <div className="flex gap-6 font-mono tabular-nums text-sm">
          <span data-testid="billed-total">
            <span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
              Billed
            </span>
            {fmt(summary.billedTotal)}
          </span>
          <span data-testid="paid-total">
            <span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
              Paid
            </span>
            {fmt(summary.paidTotal)}
          </span>
          <span data-testid="adjustments-total">
            <span className="text-[color:var(--m-ink-tertiary)] text-xs uppercase mr-2">
              Adjustments
            </span>
            {fmt(summary.adjustmentTotal)}
          </span>
        </div>
        <span
          className="font-mono text-xs uppercase tracking-wider"
          style={{
            color: allMatched ? "var(--tt-amber)" : "var(--tt-oxblood)",
          }}
          data-testid="match-summary"
        >
          {allMatched
            ? `matched ${summary.matchedLines} of ${summary.totalLines} lines`
            : `matched ${summary.matchedLines} of ${summary.totalLines} lines (${summary.totalLines - summary.matchedLines} unmatched)`}
        </span>
      </header>

      <div className="grid grid-cols-2 gap-4">
        <div data-testid="left-column">
          <h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
            Billed (837 SV1)
          </h3>
          {leftRows.map((row) => (
            <LineCard key={rowKey(row, "left")} row={row} side="left" />
          ))}
        </div>
        <div data-testid="right-column">
          <h3 className="text-[10.5px] font-semibold uppercase tracking-[0.18em] text-[color:var(--m-ink-tertiary)] mb-2">
            Adjudicated (835 SVC)
          </h3>
          {rightRows.map((row) => (
            <LineCard key={rowKey(row, "right")} row={row} side="right" />
          ))}
        </div>
      </div>
    </section>
  );
}

function rowKey(row: LineReconciliationRow, side: "left" | "right"): string {
  const ln = row.claimServiceLine?.lineNumber ?? row.serviceLinePayment?.lineNumber ?? -1;
  return `${side}-${row.status}-${ln}`;
}

function LineCard({
  row,
  side,
}: {
  row: LineReconciliationRow;
  side: "left" | "right";
}) {
  const cl = row.claimServiceLine;
  const svc = row.serviceLinePayment;
  const isUnmatched835 = row.status === "unmatched_835_only";
  const accentColor = isUnmatched835 ? "var(--tt-oxblood)" : "var(--tt-ink-blue)";
  const procCode = cl?.procedureCode ?? svc?.procedureCode ?? "—";
  const lineNumber = cl?.lineNumber ?? svc?.lineNumber ?? null;

  return (
    <div
      className="flex flex-col gap-1 py-2 px-3 mb-2"
      style={{
        borderLeft: `3px solid ${accentColor}`,
        background: "var(--tt-bg-elev, rgba(255,255,255,0.02))",
      }}
      data-line-status={row.status}
    >
      <div className="flex items-baseline justify-between">
        <div className="font-mono text-sm">
          <span className="font-semibold">{procCode}</span>
          {lineNumber !== null && (
            <span className="text-[color:var(--m-ink-tertiary)] ml-2">#{lineNumber}</span>
          )}
          {isUnmatched835 && (
            <span
              className="ml-2 text-xs italic"
              style={{ color: "var(--tt-oxblood)" }}
            >
              no 837 line matched
            </span>
          )}
          {row.status === "unmatched_837_only" && (
            <span
              className="ml-2 text-xs italic"
              style={{ color: "var(--tt-oxblood)" }}
            >
              no 835 line adjudicated this line
            </span>
          )}
        </div>
        <div className="font-mono tabular-nums text-xs">
          {side === "left" && cl && <span>{fmt(cl.charge)}</span>}
          {side === "right" && svc && <span>{fmt(svc.payment)}</span>}
        </div>
      </div>
      {row.adjustments.length > 0 && (
        <ul className="text-xs font-mono text-[color:var(--m-ink-tertiary)] mt-1 space-y-0.5">
          {row.adjustments.map((a, i) => (
            <li key={i}>
              {a.groupCode} · {a.reasonCode} · {fmt(a.amount)}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}
```

- [ ] **Step 4: Run, confirm PASS**

```bash
pnpm vitest run src/components/ClaimDrawer/LineReconciliationTab.test.tsx
```

Expected: 4 passed.

- [ ] **Step 5: Commit**

```bash
git add src/components/ClaimDrawer/LineReconciliationTab.tsx src/components/ClaimDrawer/LineReconciliationTab.test.tsx
git commit -m "feat(sp7): LineReconciliationTab component"
```

---

## Task 19: Wire `LineReconciliationTab` into `ClaimDrawer`

**Files:**
- Create: `src/hooks/useLineReconciliation.ts`
- Create: `src/hooks/useLineReconciliation.test.ts`
- Modify: `src/components/ClaimDrawer/ClaimDrawer.tsx`
- Modify: `src/components/ClaimDrawer/index.ts`

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

Create `src/hooks/useLineReconciliation.test.ts`:

```tsx
// @vitest-environment happy-dom
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { useLineReconciliation } from "./useLineReconciliation";

afterEach(() => {
  cleanup();
  vi.unstubAllGlobals();
});

describe("useLineReconciliation", () => {
  it("fetches the line reconciliation data on mount", async () => {
    const fixture = {
      claimId: "CLM-1",
      summary: { billedTotal: "100", paidTotal: "80",
        adjustmentTotal: "20", matchedLines: 1, totalLines: 1 },
      lines: [],
    };
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
      ok: true,
      json: async () => fixture,
    }));
    const { result } = renderHook(() => useLineReconciliation("CLM-1"));
    await waitFor(() => expect(result.current.loading).toBe(false));
    expect(result.current.data).toEqual(fixture);
  });

  it("returns null when claimId is null", () => {
    const { result } = renderHook(() => useLineReconciliation(null));
    expect(result.current.data).toBeNull();
    expect(result.current.loading).toBe(false);
  });

  it("exposes an error when fetch fails", async () => {
    vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
      ok: false, status: 500, json: async () => ({}),
    }));
    const { result } = renderHook(() => useLineReconciliation("CLM-1"));
    await waitFor(() => expect(result.current.error).not.toBeNull());
  });
});
```

- [ ] **Step 2: Run, confirm FAIL**

```bash
pnpm vitest run src/hooks/useLineReconciliation.test.ts
```

Expected: ImportError.

- [ ] **Step 3: Implement the hook**

Create `src/hooks/useLineReconciliation.ts`:

```typescript
import { useEffect, useState } from "react";
import type { LineReconciliationResponse } from "@/types";

/**
 * Data hook for the ClaimDrawer's Line Reconciliation tab.
 *
 * Calls GET /api/claims/{claimId}/line-reconciliation on mount and when
 * claimId changes. When claimId is null, returns a no-data state.
 */
export function useLineReconciliation(claimId: string | null) {
  const [data, setData] = useState<LineReconciliationResponse | null>(null);
  const [loading, setLoading] = useState<boolean>(claimId !== null);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    if (claimId === null) {
      setData(null);
      setLoading(false);
      setError(null);
      return;
    }
    let cancelled = false;
    setLoading(true);
    const baseUrl = import.meta.env.VITE_API_BASE_URL ?? "";
    fetch(`${baseUrl}/api/claims/${claimId}/line-reconciliation`)
      .then(async (r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return (await r.json()) as LineReconciliationResponse;
      })
      .then((json) => {
        if (cancelled) return;
        setData(json);
        setError(null);
      })
      .catch((e) => {
        if (cancelled) return;
        setError(e as Error);
      })
      .finally(() => {
        if (cancelled) return;
        setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [claimId]);

  return { data, loading, error };
}
```

- [ ] **Step 4: Run, confirm PASS**

```bash
pnpm vitest run src/hooks/useLineReconciliation.test.ts
```

Expected: 3 passed.

- [ ] **Step 5: Wire the tab into `ClaimDrawer.tsx`**

Read `src/components/ClaimDrawer/ClaimDrawer.tsx`. Find where the drawer renders its body sections (StateHistoryTimeline, RawSegmentsPanel, etc.).

Add state for the active tab:

```tsx
const [activeTab, setActiveTab] = useState<"details" | "line-reconciliation">("details");
```

Add a tab strip:

```tsx
<div className="flex gap-2 px-6 pt-4 border-b" style={{ borderColor: "var(--tt-bg)" }}>
  <button
    onClick={() => setActiveTab("details")}
    className="font-mono text-xs uppercase tracking-wider px-3 py-2"
    style={{
      color: activeTab === "details" ? "var(--tt-ink)" : "var(--tt-ink-dim)",
      borderBottom: activeTab === "details" ? "2px solid var(--tt-amber)" : "2px solid transparent",
    }}
  >
    Details
  </button>
  <button
    onClick={() => setActiveTab("line-reconciliation")}
    className="font-mono text-xs uppercase tracking-wider px-3 py-2"
    style={{
      color: activeTab === "line-reconciliation" ? "var(--tt-ink)" : "var(--tt-ink-dim)",
      borderBottom: activeTab === "line-reconciliation" ? "2px solid var(--tt-amber)" : "2px solid transparent",
    }}
  >
    Line Reconciliation
  </button>
</div>
```

Conditionally render the tab body:

```tsx
{activeTab === "line-reconciliation" ? (
  claimId ? (
    <LineReconciliationBody claimId={claimId} />
  ) : null
) : (
  // existing details body
)}
```

Where `LineReconciliationBody` is:

```tsx
function LineReconciliationBody({ claimId }: { claimId: string }) {
  const { data, loading, error } = useLineReconciliation(claimId);
  if (loading) return <div className="px-6 py-4 font-mono text-sm">loading…</div>;
  if (error) return <div className="px-6 py-4 font-mono text-sm" style={{ color: "var(--tt-oxblood)" }}>error: {error.message}</div>;
  if (!data) return null;
  return <LineReconciliationTab data={data} />;
}
```

Add the imports at the top:

```tsx
import { useState } from "react";
import { LineReconciliationTab } from "./LineReconciliationTab";
import { useLineReconciliation } from "@/hooks/useLineReconciliation";
```

- [ ] **Step 6: Update `ClaimDrawer.test.tsx` if needed**

If the existing tests render the drawer body in a way that breaks (e.g. test snapshot expects "details" to be the default and only tab), update accordingly. Most tests should pass since "Details" is the default tab.

- [ ] **Step 7: Run tests**

```bash
pnpm vitest run src/components/ClaimDrawer/ src/hooks/useLineReconciliation.test.ts
```

Expected: PASS.

- [ ] **Step 8: Run typecheck**

```bash
pnpm tsc --noEmit
```

Expected: 0 errors.

- [ ] **Step 9: Commit**

```bash
git add src/components/ClaimDrawer/ClaimDrawer.tsx src/components/ClaimDrawer/ClaimDrawer.test.tsx src/components/ClaimDrawer/index.ts src/hooks/useLineReconciliation.ts src/hooks/useLineReconciliation.test.ts
git commit -m "feat(sp7): Line Reconciliation tab wired into ClaimDrawer"
```

---

## Phase 6 — Docs + smoke

## Task 20: README "Per-Line Adjustment Audit" section

**Files:**
- Modify: `README.md`

- [ ] **Step 1: Find a good insertion point**

```bash
grep -n "^## " README.md
```

Insert the new section after "## Inbox" (added in SP6) and before "## Persistence".

- [ ] **Step 2: Add the section**

```markdown
## Per-Line Adjustment Audit

Every 835 CAS segment is tied back to the specific 837 service line it
adjudicates, instead of being aggregated to the claim level. The match
runs **eagerly** at 835 ingest time so the audit trail is stable across
re-reads (a re-migration is required if the algorithm changes).

### Where to find it

- **ClaimDrawer → "Line Reconciliation" tab** — side-by-side view of the
  837 SV1 lines and 835 SVC composites with per-line CAS reasons.
- **ClaimDrawer → ServiceLinesTable** — every billed line now shows its
  paid amount and adjustment sum on the right.
- **RemitDrawer → CasAdjustmentsPanel** — each CAS row carries a
  `proc · #N` reference; claim-level CAS clusters separately.
- **Inbox → MatchedRemitCard** — a `lines: N/M matched` badge lights up
  amber when at least one line is unmatched.

### Match criteria (strict)

A 835 SVC composite matches a 837 SV1 line iff all four criteria align:

| Field         | Rule                                            |
| ------------- | ----------------------------------------------- |
| Procedure     | Exact match (case-insensitive, uppercased).     |
| Modifiers     | Set-equal (order-independent).                  |
| Service date  | Exact match (both null counts as a match).      |
| Units         | Exact match (both null counts as a match).      |

Unmatched lines surface as a **soft warning** — the claim still posts to
PAID/PARTIAL/RECEIVED, but the Inbox shows the unmatched count and the
drawer surfaces a per-line "no 837 line matched" note.

### Line-level endpoints

| Method | Path                                                | Notes                                          |
| ------ | --------------------------------------------------- | ---------------------------------------------- |
| GET    | `/api/claims/{claim_id}/line-reconciliation`        | Dedicated view for the drawer tab.             |
| GET    | `/api/claims/{claim_id}`                            | Now includes a slim `lineReconciliation[]`.    |
| GET    | `/api/remittances/{remittance_id}`                  | Now includes `serviceLinePayments[]` + `claimLevelAdjustments[]`. |
| GET    | `/api/inbox/lanes`                                  | `matched_remittance` payload gains `matched_lines` + `total_lines`. |
```

- [ ] **Step 3: Commit**

```bash
git add README.md
git commit -m "docs(sp7): README — Per-Line Adjustment Audit section"
```

---

## Task 21: Manual smoke test

**Files:** none

- [ ] **Step 1: Start backend**

```bash
cd backend && CYCLONE_DB_URL=sqlite:///$(mktemp -d)/sp7-smoke.db .venv/bin/python -m cyclone serve &
```

- [ ] **Step 2: Parse a sample 837 + 835 with overlapping service lines**

```bash
curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_837p.txt" http://127.0.0.1:8000/api/parse-837
curl -s -X POST -F "file=@backend/tests/fixtures/co_medicaid_835.txt" http://127.0.0.1:8000/api/parse-835
```

(If these fixtures don't align on PCN / lines, build a small synthetic pair that does — copy `co_medicaid_837p.txt` and adjust the PCN + a single line's procedure_code to a value the 835 doesn't pay. Save the result in `backend/tests/fixtures/sp7_smoke/` if the existing fixtures don't fit.)

- [ ] **Step 3: Hit the new endpoint**

```bash
curl -s http://127.0.0.1:8000/api/inbox/lanes | python3 -m json.tool | head -40
# Find a paid claim's id, then:
curl -s http://127.0.0.1:8000/api/claims/<claim_id>/line-reconciliation | python3 -m json.tool
```

Expected: a payload with `summary.matched_lines` + a `lines[]` array showing each 837 line paired with its 835 SVC composite (or null on the unmatched side), with per-line CAS adjustments.

- [ ] **Step 4: Verify the soft-warning surface**

In the response, check for:

- A line with `status: "unmatched_837_only"` — confirms an 837 line the payer didn't pay.
- A line with `status: "unmatched_835_only"` — confirms an 835 line the 837 didn't bill (synthetic setup needed for this).

- [ ] **Step 5: Hit the claim detail endpoint to confirm the slim projection**

```bash
curl -s http://127.0.0.1:8000/api/claims/<claim_id> | python3 -m json.tool | grep -A 5 lineReconciliation
```

Expected: a `lineReconciliation` array with one entry per billed line.

- [ ] **Step 6: Kill servers**

```bash
lsof -nP -iTCP:8000 -sTCP:LISTEN -t | xargs -r kill
```

- [ ] **Step 7: Final full-suite run**

```bash
cd backend && pytest -q
cd .. && pnpm vitest run --reporter=dot
```

Expected: backend green (same baseline as before SP7 — the 8 SP5 stream flakes + 3 TA1 flakes remain). Frontend green (328+ tests).

- [ ] **Step 8: Final commit + push**

```bash
git log --oneline main -25
```

(Branch was kept on `main` per session convention; no push step.)

---

## Self-review checklist

- [x] **Spec coverage:**
  - §2.1 Line Reconciliation tab → T18, T19
  - §2.2 Augmented ServiceLinesTable → T15
  - §2.3 Per-line attribution in RemitDrawer → T16
  - §2.4 Inbox badge → T17, T12
  - §3.1 `service_line_payments` table → T1, T2
  - §3.2 `line_reconciliations` table → T1, T3
  - §3.3 `CasAdjustment.service_line_payment_id` → T4
  - §3.4 No change to `service_lines` → implicit (no task needed)
  - §4.1 `match_service_lines()` signature → T7
  - §4.2 Strict match criteria → T7
  - §4.3 Tie-breaker → T7
  - §4.4 Insertion order in `reconcile.run()` → T8
  - §5.1 New endpoint → T9
  - §5.2 Modified claim detail endpoint → T10
  - §5.3 Modified remit detail endpoint → T11
  - §5.4 Modified inbox lanes → T12
  - §6.1 ServiceLinesTable columns → T15
  - §6.2 LineReconciliationTab → T18, T19
  - §6.3 CasAdjustmentsPanel column → T16
  - §6.4 MatchedRemitCard badge → T17
  - §7.1 Unmatched 835 lines → T7 (test), T18 (rendered)
  - §7.2 Unmatched 837 lines → T7 (test), T18 (rendered)
  - §7.3 Reversals → T8 (`run` skips reversals for now; spec §7.3 is a follow-on, documented but not implemented in v1)
  - §7.4 CLP-level CAS → T6, T11
  - §7.5 Multi-line remits → implicit (no schema constraint)
  - §7.6 Remit-level CAS without service payment → T6 (no-op path)
  - §7.7 Empty service_payments → T7 (test case)
  - §7.8 Test isolation → all tests use the existing pattern; no SP5 fixture imports in new tests
  - §9.1 Migration → T1
  - §9.2 ORM models → T2, T3, T4
  - §9.3 No backfill → implicit
  - §9.4 Frontend types → T13
  - §9.5 Rollout → T21

- [x] **Placeholder scan:** no TBD / TODO / "implement later" / "similar to Task N" patterns. All test files contain actual test code.

- [x] **Type consistency:**
  - `match_service_lines` returns `list[LineMatch]` (T7) and T8 uses the same signature via the shims.
  - `_persist_835_remit` signature matches across T6 and its tests.
  - The endpoint `/api/claims/{claim_id}/line-reconciliation` is referenced consistently in T9 (backend), T19 (frontend hook), and the spec.
  - `matchedLines` / `totalLines` appear in the API response (T12), the `InboxMatchedRemittance` type (T14), and the `MatchedRemitCard` component (T17).

- [x] **Scope:** 21 tasks, one branch, one PR. Within a single plan.

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-06-20-cyclone-line-reconciliation.md`.**

Per session convention, executing inline rather than dispatching subagents (per the user's "yolo" directive on the design phase — assumed to carry through to plan execution).

Executing T1 → T21 in order, committing each, running the relevant subset of the suite per task.
