# Parse → Detect → Decide Workflow Implementation Plan

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

**Goal:** Replace the 409-on-persist workflow with parse-first, pre-flight dedup, structured 409 with full parse result + collision summary, `?force=true` to skip-and-continue, and `DELETE /api/batches/{id}` for cascade cleanup. Frontend gets an error panel with four actions (force-insert, open prior, delete prior, pick different file).

**Architecture:** Pre-flight dedup runs between validation and persist in both `parse_837` and `parse_835` endpoints. New `dedup.py` module owns `CollisionReport` + `preflight_837`/`preflight_835`. New `?force=true` query param bypasses pre-flight; the existing per-row `s.get(Claim, claim_id)` dedup in `store.add` silently skips colliding rows, and the 200 response includes `skipped_claim_ids`. New `DELETE /api/batches/{id}` endpoint hard-deletes with cascade; refuses if any claim is past `submitted` state. Frontend `ApiError` carries `collisions` + `parseResult`; `Upload.tsx` panel renders the full parse result and the four actions.

**Tech Stack:** Python 3.11+, SQLAlchemy 2.x, FastAPI, SQLite (sqlcipher3 optional), React 18 + TanStack Query + Radix UI + sonner, Vitest + React Testing Library, pytest.

**Spec:** `docs/superpowers/specs/2026-06-21-cyclone-parse-decide-workflow-design.md` — read fully before starting.

**Worktree setup (one-time):**

```bash
cd /Users/openclaw/dev/cyclone
# claims-unique-fix worktree already exists from prior SP work; Tasks 1.1 and 1.2 are done on it.
cd .worktrees/claims-unique-fix
# venv is symlinked to main repo's .venv
# Use PYTHONPATH override to make Python import the worktree's source, not the main repo's:
export PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src:$PYTHONPATH
```

All commits happen in this worktree. Merge to main via fast-forward when each phase ends.

---

## Phase 1 — Backend pre-flight dedup module + schema foundation (mostly done; **Task 1.3 is new foundation work added after spec/plan review revealed that `claims.id` PK prevents the workflow from firing**)

### Task 1.1: Migration 0013 drops inline UNIQUE — DONE

**Files:**
- `backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql` (created)
- `backend/tests/test_db_migrate.py` (modified)

Commit `b6efd0e` on `claims-unique-fix`. No further action.

### Task 1.2: Store helpers `find_existing_batch_for_claim` / `find_existing_batch_for_remit` — DONE

**Files:**
- `backend/src/cyclone/store.py` (modified)
- `backend/tests/test_store.py` (modified)

Commit `890207f` on `claims-unique-fix`. No further action.

### Task 1.3: Migration 0014 — relax `claims` and `remittances` PKs to composite `(batch_id, id)`

The current schema has `claims.id` and `remittances.id` as single-column
PRIMARY KEYs. This makes the spec'd "cross-batch CLM01 collision" workflow
unreachable: the same CLM01 cannot exist in two batches, so the pre-flight
409 path can never fire, force-insert can never skip anything, and resubmits
are impossible. Migration 0014 relaxes the PKs to composite `(batch_id, id)`
and updates every FK that referenced the old single-column PK.

After 0014, the pre-flight dedup is genuinely exercisable end-to-end and
the entire workflow in the spec actually fires on real data.

**Files:**
- Create: `backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql`
- Modify: `backend/src/cyclone/db.py` (drop the redundant single-column `unique=True` on `id`, if any; add composite PK markers)
- Modify: `backend/tests/test_db_migrate.py` (0014 tests)

- [ ] **Step 1: Inspect the live schema and identify every FK**

Before writing the migration, enumerate every FK that points at `claims(id)` or `remittances(id)`. Run:

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -c "
from cyclone import db
from sqlalchemy import inspect
ins = inspect(db.engine())
for tbl in ins.get_table_names():
    for fk in ins.get_foreign_keys(tbl):
        if 'claims' in (fk.get('referred_table') or '') or 'remittances' in (fk.get('referred_table') or ''):
            print(tbl, fk['constrained_columns'], '->', fk['referred_table'], fk['referred_columns'])
"
```

You should see (at minimum) FKs from `remittances`, `matches`, `cas_adjustments`, `line_reconciliations`, `activity_events` (if FK-declared there), and `claims.matched_remittance_id`. Capture this list — the migration must update each one to include `batch_id`.

- [ ] **Step 2: Write the failing migration tests**

Add to `backend/tests/test_db_migrate.py`:

```python
def test_migration_0014_relaxes_claims_pk_to_composite(tmp_path, monkeypatch):
    """Migration 0014 changes claims PK from single-column id to (batch_id, id).

    After 0014, two Claim rows with the same id can coexist if they are
    in different batches. Same for Remittance. The dedup story moves to
    the application layer (preflight_837 / preflight_835) instead of the
    schema.
    """
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    from cyclone import db, migrations

    # Synthesize 0001 + 0013 + 0014 by calling the migration runner with
    # only those versions. Use the existing migrate runner pattern from
    # test_db_migrate.py.
    db._reset_for_tests()
    db.init_db()  # runs all migrations up to current

    # After full migration: try to insert two claims with the same id
    # in different batches.
    from datetime import datetime, timezone
    from cyclone.db import Batch, Claim

    with db.SessionLocal()() as s:
        s.add(Batch(id="B1", kind="837p", input_filename="b1.txt",
                    parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
                    raw_result_json={}))
        s.add(Batch(id="B2", kind="837p", input_filename="b2.txt",
                    parsed_at=datetime(2026, 1, 2, tzinfo=timezone.utc),
                    raw_result_json={}))
        s.add(Claim(id="CLM-A", batch_id="B1", patient_control_number="M1",
                    state_changed_at=datetime(2026, 1, 1, tzinfo=timezone.utc)))
        s.add(Claim(id="CLM-A", batch_id="B2", patient_control_number="M1",
                    state_changed_at=datetime(2026, 1, 2, tzinfo=timezone.utc)))
        s.commit()  # must NOT raise

    with db.SessionLocal()() as s:
        rows = s.execute(db.text("SELECT id, batch_id FROM claims WHERE id='CLM-A' ORDER BY batch_id")).all()
        assert rows == [("CLM-A", "B1"), ("CLM-A", "B2")]


def test_migration_0014_relaxes_remittances_pk_to_composite(tmp_path, monkeypatch):
    """Same shape for remittances: same CLP01 can exist in two batches."""
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    from cyclone import db
    from datetime import datetime, timezone
    from cyclone.db import Batch, Remittance

    db._reset_for_tests()
    db.init_db()

    with db.SessionLocal()() as s:
        s.add(Batch(id="B1", kind="835", input_filename="b1.txt",
                    parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
                    raw_result_json={}))
        s.add(Batch(id="B2", kind="835", input_filename="b2.txt",
                    parsed_at=datetime(2026, 1, 2, tzinfo=timezone.utc),
                    raw_result_json={}))
        s.add(Remittance(id="CLP-A", batch_id="B1",
                         payer_claim_control_number="CLP-A",
                         status_code="1",
                         received_at=datetime(2026, 1, 1, tzinfo=timezone.utc)))
        s.add(Remittance(id="CLP-A", batch_id="B2",
                         payer_claim_control_number="CLP-A",
                         status_code="1",
                         received_at=datetime(2026, 1, 2, tzinfo=timezone.utc)))
        s.commit()

    with db.SessionLocal()() as s:
        rows = s.execute(db.text("SELECT id, batch_id FROM remittances WHERE id='CLP-A' ORDER BY batch_id")).all()
        assert rows == [("CLP-A", "B1"), ("CLP-A", "B2")]


def test_migration_0014_preserves_existing_data(tmp_path, monkeypatch):
    """If the DB already has claim/remittance rows when 0014 runs, the rows
    survive the table recreation (INSERT INTO new SELECT * FROM old).
    """
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    # Apply migrations 0001 through 0013, insert a row, then apply 0014.
    # (See test_db_migrate.py for the migrate-to-version helper.)
    from cyclone import db
    from datetime import datetime, timezone
    from cyclone.db import Batch, Claim

    db._reset_for_tests()
    db.init_db()  # up to 0014

    with db.SessionLocal()() as s:
        s.add(Batch(id="B-OLD", kind="837p", input_filename="x.txt",
                    parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
                    raw_result_json={}))
        s.add(Claim(id="CLM-OLD", batch_id="B-OLD", patient_control_number="M",
                    state_changed_at=datetime(2026, 1, 1, tzinfo=timezone.utc)))
        s.commit()

    # Verify row still exists.
    with db.SessionLocal()() as s:
        row = s.execute(db.text("SELECT id, batch_id FROM claims WHERE id='CLM-OLD'")).first()
    assert row == ("CLM-OLD", "B-OLD")
```

- [ ] **Step 3: Run the new tests, expect FAIL**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_db_migrate.py -k "0014" --no-header -q
```

Expected: FAIL with `IntegrityError: UNIQUE constraint failed: claims.id` (the same row insertion that succeeds after 0014 fails before it).

- [ ] **Step 4: Write migration 0014**

Create `backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql`:

```sql
-- version: 14
-- Relax PRIMARY KEYs on `claims` and `remittances` from single-column (id)
-- to composite (batch_id, id). Enables resubmits (same CLM01 / CLP01 in
-- different batches) and makes the pre-flight dedup workflow exercisable.
--
-- Strategy (mirrors 0013): table recreation with PRAGMA
-- defer_foreign_keys. We must recreate every table that has an FK pointing
-- at `claims(id)` or `remittances(id)` so that FK constraints can be
-- updated to point at the new composite PK.
--
-- FKs that need updating (verified via inspect(db.engine()) on the live
-- schema before this migration was written):
--   - claims.matched_remittance_id -> remittances(id)            becomes -> (batch_id, id)
--   - remittances.claim_id          -> claims(id)                 becomes -> (batch_id, id)
--   - matches.claim_id              -> claims(id) ON DELETE CASCADE   becomes -> (batch_id, id)
--   - matches.remittance_id         -> remittances(id) ON DELETE CASCADE  becomes -> (batch_id, id)
--   - cas_adjustments.remittance_id -> remittances(id) ON DELETE CASCADE  becomes -> (batch_id, id)
--   - line_reconciliations.remittance_id -> remittances(id) ON DELETE CASCADE  becomes -> (batch_id, id)
--   - line_reconciliations.claim_id      -> claims(id) ON DELETE CASCADE   becomes -> (batch_id, id)
--
-- activity_events has no FKs to claims/remittances in 0001 (verified), so
-- we don't recreate it.

PRAGMA defer_foreign_keys = ON;
PRAGMA foreign_keys = OFF;  -- required for table recreation in SQLite

-- Step 1: recreate `remittances` with composite PK
CREATE TABLE remittances_new (
    id TEXT NOT NULL,
    batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
    payer_claim_control_number TEXT NOT NULL,
    claim_id TEXT,  -- FK to claims(batch_id, id) added after claims is recreated
    status_code TEXT NOT NULL,
    status_label TEXT,
    total_charge NUMERIC(12, 2) NOT NULL DEFAULT 0,
    total_paid NUMERIC(12, 2) NOT NULL DEFAULT 0,
    patient_responsibility NUMERIC(12, 2),
    adjustment_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
    received_at DATETIME NOT NULL,
    service_date DATE,
    is_reversal INTEGER NOT NULL DEFAULT 0,
    raw_json TEXT,
    PRIMARY KEY (batch_id, id)
);
INSERT INTO remittances_new
    SELECT id, batch_id, payer_claim_control_number, claim_id, status_code,
           status_label, total_charge, total_paid, patient_responsibility,
           adjustment_amount, received_at, service_date, is_reversal, raw_json
    FROM remittances;
DROP TABLE remittances;
ALTER TABLE remittances_new RENAME TO remittances;
CREATE INDEX ix_remittances_claim_id ON remittances(claim_id);
CREATE INDEX ix_remittances_payer_claim_control_number ON remittances(payer_claim_control_number);
CREATE INDEX ix_remittances_status_code ON remittances(status_code);

-- Step 2: recreate `claims` with composite PK
CREATE TABLE claims_new (
    id TEXT NOT NULL,
    batch_id TEXT NOT NULL REFERENCES batches(id) ON DELETE CASCADE,
    patient_control_number TEXT NOT NULL,
    service_date_from DATE,
    service_date_to DATE,
    charge_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
    provider_npi TEXT,
    payer_id TEXT,
    state TEXT NOT NULL DEFAULT 'submitted',
    state_before_reversal TEXT,
    matched_remittance_id TEXT,  -- FK added after remittances is recreated
    raw_json TEXT,
    PRIMARY KEY (batch_id, id)
);
INSERT INTO claims_new
    SELECT id, batch_id, patient_control_number, service_date_from,
           service_date_to, charge_amount, provider_npi, payer_id, state,
           state_before_reversal, matched_remittance_id, raw_json
    FROM claims;
DROP TABLE claims;
ALTER TABLE claims_new RENAME TO claims;
CREATE INDEX ix_claims_state ON claims(state);
CREATE INDEX ix_claims_patient_control_number ON claims(patient_control_number);
CREATE INDEX ix_claims_service_date_from ON claims(service_date_from);

-- Step 3: recreate `matches`, `cas_adjustments`, `line_reconciliations` so
-- their FKs point at the new composite PKs.

CREATE TABLE matches_new (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    claim_id TEXT NOT NULL,
    batch_id TEXT NOT NULL,  -- NEW: paired with claim_id for the composite FK
    remittance_id TEXT NOT NULL,
    remittance_batch_id TEXT NOT NULL,  -- NEW: paired with remittance_id
    strategy TEXT NOT NULL,
    matched_at DATETIME NOT NULL,
    prior_claim_state TEXT,
    is_reversal INTEGER NOT NULL DEFAULT 0,
    FOREIGN KEY (batch_id, claim_id) REFERENCES claims(batch_id, id) ON DELETE CASCADE,
    FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
);
INSERT INTO matches_new
    SELECT m.id, m.claim_id, c.batch_id, m.remittance_id, r.batch_id,
           m.strategy, m.matched_at, m.prior_claim_state, m.is_reversal
    FROM matches m
    JOIN claims c ON c.id = m.claim_id
    JOIN remittances r ON r.id = m.remittance_id;
DROP TABLE matches;
ALTER TABLE matches_new RENAME TO matches;
CREATE INDEX ix_matches_claim_id ON matches(claim_id);
CREATE INDEX ix_matches_remittance_id ON matches(remittance_id);
CREATE INDEX ix_matches_matched_at ON matches(matched_at);

CREATE TABLE cas_adjustments_new (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    remittance_id TEXT NOT NULL,
    remittance_batch_id TEXT NOT NULL,
    group_code TEXT NOT NULL,
    reason_code TEXT NOT NULL,
    amount NUMERIC(12, 2) NOT NULL,
    quantity NUMERIC(10, 2),
    FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE
);
INSERT INTO cas_adjustments_new
    SELECT ca.id, ca.remittance_id, r.batch_id, ca.group_code, ca.reason_code,
           ca.amount, ca.quantity
    FROM cas_adjustments ca
    JOIN remittances r ON r.id = ca.remittance_id;
DROP TABLE cas_adjustments;
ALTER TABLE cas_adjustments_new RENAME TO cas_adjustments;
CREATE INDEX ix_cas_adjustments_remittance_id ON cas_adjustments(remittance_id);

-- line_reconciliations: see 0006. Its columns are remittance_id, claim_id,
-- service_line_payment_id, superseded_by_id, service_line_payment_id. We
-- need to add remittance_batch_id and batch_id columns for the new FKs.
-- (The full column list must match 0006.)
CREATE TABLE line_reconciliations_new (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    remittance_id VARCHAR(64) NOT NULL,
    remittance_batch_id VARCHAR(64) NOT NULL,
    claim_id VARCHAR(64) NOT NULL,
    batch_id VARCHAR(64) NOT NULL,
    service_line_payment_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
    superseded_by_id INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
    service_line_payment_id_new INTEGER REFERENCES service_line_payments(id) ON DELETE SET NULL,
    -- ... (carry over every column from 0006 + service_line_payments column if added in 0006)
    FOREIGN KEY (remittance_batch_id, remittance_id) REFERENCES remittances(batch_id, id) ON DELETE CASCADE,
    FOREIGN KEY (batch_id, claim_id) REFERENCES claims(batch_id, id) ON DELETE CASCADE
);
INSERT INTO line_reconciliations_new
    SELECT lr.id, lr.remittance_id, r.batch_id, lr.claim_id, c.batch_id,
           lr.service_line_payment_id, lr.superseded_by_id, lr.service_line_payment_id_new
    FROM line_reconciliations lr
    JOIN remittances r ON r.id = lr.remittance_id
    JOIN claims c ON c.id = lr.claim_id;
DROP TABLE line_reconciliations;
ALTER TABLE line_reconciliations_new RENAME TO line_reconciliations;

-- Step 4: add the back-references between claims and remittances now that
-- both tables exist with composite PKs.
-- (SQLite does not support adding FK constraints via ALTER TABLE, so the
--  back-references are part of the recreation in Steps 1 and 2 only as
--  nullable columns. Application code is responsible for keeping them
--  consistent; FK enforcement on these two columns is dropped intentionally.)

PRAGMA foreign_keys = ON;
PRAGMA defer_foreign_keys = OFF;
```

**Important:** before committing this migration, **read `0006_line_reconciliation.sql`** and ensure the `line_reconciliations_new` schema matches the actual production schema. The snippet above shows the expected columns but may need adjustment if 0006 has additional columns. Also verify the column list for `matches_new` and `cas_adjustments_new` against `0001_initial.sql`.

- [ ] **Step 5: Update `backend/src/cyclone/db.py` to reflect composite PK**

In `db.py`, the `Claim.id` and `Remittance.id` columns are mapped with `primary_key=True`. With composite PKs, the markers change:

- `Claim.id`: `Mapped[str] = mapped_column(String(64), primary_key=True)` → `Mapped[str] = mapped_column(String(64))` (composite PK declared via `__table_args__`)
- `Claim.batch_id`: `Mapped[str] = mapped_column(String(64), ForeignKey("batches.id"), primary_key=True)` (keep `primary_key=True` since it's part of the composite)
- Same for `Remittance`.

Add `__table_args__` to each class to declare `CompositePrimaryKey` constraints:

```python
from sqlalchemy import PrimaryKeyConstraint

class Claim(...):
    __table_args__ = (PrimaryKeyConstraint("batch_id", "id"),)
    # ...
```

SQLAlchemy 2.x syntax: use `mapped_column(..., primary_key=True)` for both columns and SQLAlchemy infers the composite. Or use `__table_args__ = (PrimaryKeyConstraint(...),)`.

In addition to the column changes above, four SQLAlchemy ORM
`before_insert` event listeners were added at the bottom of `db.py`
(see the `# Migration 0014: ORM `before_insert` events auto-populate the
batch side of composite FKs` block). They auto-populate the ``batch_id``
/ ``remittance_batch_id`` column on ``Match`` / ``CasAdjustment`` /
``ServiceLinePayment`` / ``LineReconciliation`` rows by looking up the
parent ``Claim`` / ``Remittance`` in the session. This is necessary
because the composite FK columns are ``NOT NULL`` and most call sites
only have the parent's id (not its batch_id) in scope — threading
``batch_id`` through every ingest/match path would be error-prone. The
event block in `db.py` has a docstring explaining what / why / when it
fails (parent id not findable in the session or DB → misleading
``NOT NULL constraint failed`` IntegrityError on INSERT). Application
code may still explicitly set the batch side — the events only fill in
``None`` columns.

- [ ] **Step 6: Run migration tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_db_migrate.py -k "0014" --no-header -q
```

Expected: 3 passed.

- [ ] **Step 7: Run the full `test_db_migrate.py`, expect all PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_db_migrate.py --no-header -q
```

Expected: all pass. If any test that previously relied on single-PK semantics fails, update it (this should be rare).

- [ ] **Step 8: Run the broader test suite to catch schema regressions**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest --no-header -q
```

Expected: all pass. Composite PK changes might surface in store / API tests that assume `Claim.id` is unique. Fix those tests by using `(batch_id, id)` lookups where needed.

**Note on production-code changes during this step:** the original plan
text said "Do not modify production code to make old tests pass;
update the tests." In practice some production code DID need to change
because composite-PK semantics make single-id lookups ambiguous — the
same `Claim.id` can now exist in multiple batches, so `s.get(Claim, X)`
returns at most one row (whichever the DB picks first) and is no longer
the right way to find a specific claim. Call sites that did
`s.get(Claim, X)` were updated to filter by `(batch_id, id)` (e.g.
`s.query(Claim).filter(Claim.batch_id == b, Claim.id == X).first()`).
This is a forced consequence of the schema change, not test-driven
product churn. The corrected rule is: **do not modify production code
to make old tests pass UNLESS the schema change forces it; update the
tests for everything else.**

- [ ] **Step 9: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/src/cyclone/migrations/0014_relax_claims_remits_pk.sql \
        backend/src/cyclone/db.py \
        backend/tests/test_db_migrate.py
git commit -m "feat(db): migration 0014 relaxes claims/remittances PK to (batch_id, id)"
```

---

### Task 1.4: Tighten `find_existing_batch_for_claim` to return the most-recent batch

The current implementation returns *some* batch (no `ORDER BY`). The spec says we must return the most-recent. Update the helper.

**Files:**
- Modify: `backend/src/cyclone/store.py` (`find_existing_batch_for_claim`, `find_existing_batch_for_remit`)
- Modify: `backend/tests/test_store.py` (add a "most recent" assertion)

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

Add to `backend/tests/test_store.py`:

```python
def test_find_existing_batch_for_claim_returns_most_recent():
    """When the same claim_id is in multiple prior batches, the helper
    returns the most-recent (highest state_changed_at) batch."""
    from datetime import datetime, timedelta, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    base = datetime(2026, 1, 1, tzinfo=timezone.utc)
    with _db.SessionLocal()() as s:
        # Older batch with the claim
        s.add(Batch(
            id="B_OLD", kind="837p", input_filename="old.txt",
            parsed_at=base, raw_result_json={},
        ))
        s.add(Claim(
            id="CLM-A", batch_id="B_OLD", patient_control_number="M",
            state_changed_at=base,
        ))
        # Newer batch with the same claim
        s.add(Batch(
            id="B_NEW", kind="837p", input_filename="new.txt",
            parsed_at=base + timedelta(days=1), raw_result_json={},
        ))
        s.add(Claim(
            id="CLM-A", batch_id="B_NEW", patient_control_number="M",
            state_changed_at=base + timedelta(days=1),
        ))
        s.commit()

    from cyclone import store as store_mod
    assert store_mod.find_existing_batch_for_claim("CLM-A") == "B_NEW"


def test_find_existing_batch_for_remit_returns_most_recent():
    """Same shape for 835: most-recent (highest received_at) batch wins."""
    from datetime import datetime, timedelta, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Remittance

    base = datetime(2026, 1, 1, tzinfo=timezone.utc)
    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="B_OLD835", kind="835", input_filename="old.txt",
            parsed_at=base, raw_result_json={},
        ))
        s.add(Remittance(
            id="CLP-A", batch_id="B_OLD835",
            payer_claim_control_number="CLP-A",
            status_code="1",
            received_at=base,
        ))
        s.add(Batch(
            id="B_NEW835", kind="835", input_filename="new.txt",
            parsed_at=base + timedelta(days=1), raw_result_json={},
        ))
        s.add(Remittance(
            id="CLP-A", batch_id="B_NEW835",
            payer_claim_control_number="CLP-A",
            status_code="1",
            received_at=base + timedelta(days=1),
        ))
        s.commit()

    from cyclone import store as store_mod
    assert store_mod.find_existing_batch_for_remit("CLP-A") == "B_NEW835"
```

- [ ] **Step 2: Run the new tests, expect FAIL**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest \
  tests/test_store.py::test_find_existing_batch_for_claim_returns_most_recent \
  tests/test_store.py::test_find_existing_batch_for_remit_returns_most_recent \
  --no-header -q
```

Expected: FAIL — the helper does not have `ORDER BY ... DESC LIMIT 1` so it returns `B_OLD` (or `B_OLD835`) instead of the most-recent.

- [ ] **Step 3: Update the helpers**

In `backend/src/cyclone/store.py`, replace `find_existing_batch_for_claim`:

```python
def find_existing_batch_for_claim(claim_id: str) -> str | None:
    """Return the batch_id of the most-recent batch containing this CLM01, or None.

    "Most-recent" is by ``Claim.state_changed_at`` (latest touch of the claim
    row, including resubmits). When the same CLM01 exists in N prior batches,
    the newest one wins. Returns ``None`` if no batch contains the claim.

    Pure read; opens a short-lived session. Used by the 837 409 handler to
    surface which prior batch already holds the same CLM01.
    """
    from sqlalchemy import select
    from cyclone.db import Claim
    with db.SessionLocal()() as s:
        row = s.execute(
            select(Claim.batch_id)
            .where(Claim.id == claim_id)
            .order_by(Claim.state_changed_at.desc())
            .limit(1)
        ).first()
        return row[0] if row else None
```

And replace `find_existing_batch_for_remit`:

```python
def find_existing_batch_for_remit(remit_id: str) -> str | None:
    """Return the batch_id of the most-recent batch containing this CLP01, or None.

    "Most-recent" is by ``Remittance.received_at``. ``remit_id`` is the PK on
    ``remittances.id`` (= payer_claim_control_number = CLP01).
    """
    from sqlalchemy import select
    from cyclone.db import Remittance
    with db.SessionLocal()() as s:
        row = s.execute(
            select(Remittance.batch_id)
            .where(Remittance.id == remit_id)
            .order_by(Remittance.received_at.desc())
            .limit(1)
        ).first()
        return row[0] if row else None
```

- [ ] **Step 4: Run all `test_store.py` tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_store.py --no-header -q
```

Expected: 12 passed (10 existing + 2 new).

- [ ] **Step 5: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(store): find_existing_batch_for_claim/remit return most-recent"
```

---

## Phase 2 — Backend pre-flight dedup module + endpoint changes

### Task 2.1: New `dedup.py` module with `CollisionReport` and pre-flight helpers

**Files:**
- Create: `backend/src/cyclone/dedup.py`
- Create: `backend/tests/test_dedup.py`

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

Create `backend/tests/test_dedup.py`:

```python
"""Tests for the pre-flight dedup helpers in cyclone.dedup.

The dedup module is the source of truth for whether a parsed batch would
collide with existing data. It returns a ``CollisionReport`` that the API
serializes into the 409 body.
"""
from __future__ import annotations

from datetime import date, datetime, timezone
from decimal import Decimal

import pytest

from cyclone import db
from cyclone.dedup import (
    CollisionReport,
    preflight_837,
    preflight_835,
)
from cyclone.parsers.models import (
    Address,
    BillingProvider,
    ClaimHeader,
    ClaimOutput,
    Envelope,
    Payer,
    ParseResult,
    Subscriber,
    ValidationReport,
)
from cyclone.parsers.models_835 import (
    ClaimPayment,
    ParseResult835,
)


@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
    monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
    db._reset_for_tests()
    db.init_db()
    yield
    db._reset_for_tests()


def _claim(claim_id: str, member_id: str = "M1") -> ClaimOutput:
    return ClaimOutput(
        claim_id=claim_id,
        control_number="0001",
        transaction_date=date(2026, 6, 19),
        billing_provider=BillingProvider(name="Test", npi="1234567890"),
        subscriber=Subscriber(
            first_name="X", last_name="Y", member_id=member_id, dob="1970-01-01", gender="M",
        ),
        payer=Payer(name="Test", id="TST"),
        claim=ClaimHeader(claim_id=claim_id, total_charge=Decimal("100")),
        diagnoses=[],
        service_lines=[],
        validation=ValidationReport(passed=True, errors=[], warnings=[]),
        raw_segments=[],
    )


def _result(*claims: ClaimOutput) -> ParseResult:
    return ParseResult(
        envelope=Envelope(
            sender_id="S", receiver_id="R", control_number="1",
            transaction_date=date(2026, 6, 19), transaction_time="1200",
            implementation_guide="005010X222A1", transaction_type_code="CH",
        ),
        claims=list(claims),
        summary={
            "input_file": "x.txt", "control_number": "1",
            "transaction_date": "2026-06-19", "total_claims": len(claims),
            "passed": len(claims), "failed": 0, "failed_claim_ids": [],
            "issues_by_rule": {}, "output_dir": None,
        },
    )


def test_preflight_837_no_collisions_on_empty_db():
    """Empty DB → no collisions, no existing_batch_id."""
    r = _result(_claim("A"), _claim("B"))
    report = preflight_837(r)
    assert report.colliding_claim_ids == []
    assert report.existing_batch_id is None
    assert report.within_file_duplicate_ids == []
    assert report.total_claims == 2


def test_preflight_837_finds_cross_batch_collision():
    """A claim already in the DB surfaces as a collision with the prior batch."""
    from cyclone.db import Batch, Claim
    with db.SessionLocal()() as s:
        s.add(Batch(
            id="PRIOR", kind="837p", input_filename="prior.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(
            id="A", batch_id="PRIOR", patient_control_number="M",
            state_changed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        ))
        s.commit()

    r = _result(_claim("A"), _claim("B"))  # A collides, B is new
    report = preflight_837(r)
    assert report.colliding_claim_ids == ["A"]
    assert report.existing_batch_id == "PRIOR"
    assert report.within_file_duplicate_ids == []
    assert report.total_claims == 2


def test_preflight_837_finds_within_file_duplicate():
    """Same CLM01 twice in the file → within_file_duplicate_ids and no batch_id."""
    r = _result(_claim("A"), _claim("A"), _claim("B"))
    report = preflight_837(r)
    assert report.within_file_duplicate_ids == ["A"]
    assert report.colliding_claim_ids == ["A"]  # also flagged as a collision
    assert report.existing_batch_id is None
    assert report.total_claims == 3


def test_preflight_837_returns_most_recent_batch_id():
    """When CLM01=A is in 3 prior batches, return the most-recent one."""
    from datetime import timedelta
    from cyclone.db import Batch, Claim

    base = datetime(2026, 1, 1, tzinfo=timezone.utc)
    for i, bid in enumerate(["B1", "B2", "B3"]):
        with db.SessionLocal()() as s:
            s.add(Batch(
                id=bid, kind="837p", input_filename=f"{bid}.txt",
                parsed_at=base, raw_result_json={},
            ))
            s.add(Claim(
                id="A", batch_id=bid, patient_control_number="M",
                state_changed_at=base + timedelta(days=i),
            ))
            s.commit()

    r = _result(_claim("A"))
    report = preflight_837(r)
    assert report.colliding_claim_ids == ["A"]
    assert report.existing_batch_id == "B3"  # most-recent by state_changed_at


def test_preflight_835_mirrors_837():
    """Same shape for 835: payer_claim_control_number is the dedup key."""
    from cyclone.db import Batch, Remittance
    with db.SessionLocal()() as s:
        s.add(Batch(
            id="PRIOR835", kind="835", input_filename="p.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Remittance(
            id="CLP-A", batch_id="PRIOR835",
            payer_claim_control_number="CLP-A",
            status_code="1",
            received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        ))
        s.commit()

    r835 = ParseResult835(
        envelope=Envelope(
            sender_id="S", receiver_id="R", control_number="1",
            transaction_date=date(2026, 6, 19), transaction_time="1200",
            implementation_guide="005010X221A1", transaction_type_code="CH",
        ),
        payer=Payer(name="Test", id="TST"),
        claims=[
            ClaimPayment(
                payer_claim_control_number="CLP-A",
                status_code="1",
                total_charge=Decimal("100"), total_paid=Decimal("100"),
            ),
            ClaimPayment(
                payer_claim_control_number="CLP-B",
                status_code="1",
                total_charge=Decimal("50"), total_paid=Decimal("50"),
            ),
        ],
    )
    report = preflight_835(r835)
    assert report.colliding_claim_ids == ["CLP-A"]
    assert report.existing_batch_id == "PRIOR835"
    assert report.within_file_duplicate_ids == []
    assert report.total_claims == 2
```

- [ ] **Step 2: Run tests, expect FAIL with "No module named 'cyclone.dedup'"**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_dedup.py --no-header -q
```

Expected: `ModuleNotFoundError: No module named 'cyclone.dedup'`.

- [ ] **Step 3: Implement `dedup.py`**

Create `backend/src/cyclone/dedup.py`:

```python
"""Pre-flight dedup for parsed 837P/835 batches.

Splits the parsed result into "would-insert" and "would-skip" sets by
querying the DB for any ``claim_id`` (837) or ``payer_claim_control_number``
(835) already present. Also detects within-file duplicates by counting
``claim_id`` / ``payer_claim_control_number`` frequencies.

Used by the ``parse-837`` and ``parse-835`` endpoints between validation
and persist, so the user sees the parse result + collision summary
before any DB write.
"""
from __future__ import annotations

from collections import Counter
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

from sqlalchemy import select
from sqlalchemy.orm import Session

from cyclone import db

if TYPE_CHECKING:
    from cyclone.parsers.models import ParseResult
    from cyclone.parsers.models_835 import ParseResult835


@dataclass(frozen=True)
class CollisionReport:
    """What the parse endpoint needs to render a 409 body.

    ``within_file_duplicate_ids`` is a subset of ``colliding_claim_ids``:
    the same CLM01 appearing twice in the file is also flagged as a
    collision (because the second instance cannot be inserted).

    ``existing_batch_id`` is the most-recent prior batch that contains
    one of the colliding claim_ids, or None when the collision is purely
    within-file.
    """
    colliding_claim_ids: list[str] = field(default_factory=list)
    existing_batch_id: str | None = None
    within_file_duplicate_ids: list[str] = field(default_factory=list)
    total_claims: int = 0

    @property
    def has_collisions(self) -> bool:
        return bool(self.colliding_claim_ids or self.within_file_duplicate_ids)

    @property
    def new_claims_after_skip(self) -> int:
        """How many claims WOULD be inserted on a force-insert."""
        # A within-file duplicate still occupies one of the ``total_claims`` slots;
        # the force-insert keeps the first occurrence and skips the rest, so
        # ``new_claims_after_skip`` = total_claims - within_file_dup_count.
        return self.total_claims - len(self.within_file_duplicate_ids)


def preflight_837(result: "ParseResult", session: Session | None = None) -> CollisionReport:
    """Detect 837 collisions: within-file dupes + cross-batch CLM01 dupes."""
    claim_ids = [c.claim_id for c in result.claims]
    counts = Counter(claim_ids)
    within_file_duplicate_ids = sorted(
        cid for cid, n in counts.items() if n > 1
    )
    seen: set[str] = set(claim_ids)

    if not seen:
        return CollisionReport(
            colliding_claim_ids=[],
            existing_batch_id=None,
            within_file_duplicate_ids=[],
            total_claims=0,
        )

    own_session = session is None
    if own_session:
        session = db.SessionLocal()()
    try:
        from cyclone.db import Claim
        rows = session.execute(
            select(Claim.id, Claim.batch_id)
            .where(Claim.id.in_(seen))
            .order_by(Claim.state_changed_at.desc())
        ).all()
    finally:
        if own_session:
            session.close()

    db_collisions: dict[str, str] = {cid: bid for cid, bid in rows}
    # A claim_id "collides" if it's in the DB (cross-batch) OR appears more than
    # once in this file (within-file). The UI uses colliding_claim_ids to count
    # the problem set; within_file_duplicate_ids is the subset that is purely
    # within-file. existing_batch_id stays None when every collision is within-file.
    colliding = sorted(
        cid for cid, n in counts.items()
        if cid in db_collisions or n > 1
    )
    existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
    return CollisionReport(
        colliding_claim_ids=colliding,
        existing_batch_id=existing_batch_id,
        within_file_duplicate_ids=within_file_duplicate_ids,
        total_claims=len(claim_ids),
    )


def preflight_835(result: "ParseResult835", session: Session | None = None) -> CollisionReport:
    """Same shape for 835 remittances. Payer claim control number = CLP01 = remittance.id."""
    pcns = [c.payer_claim_control_number for c in result.claims]
    counts = Counter(pcns)
    within_file_duplicate_ids = sorted(p for p, n in counts.items() if n > 1)
    seen: set[str] = set(pcns)

    if not seen:
        return CollisionReport(
            colliding_claim_ids=[],
            existing_batch_id=None,
            within_file_duplicate_ids=[],
            total_claims=0,
        )

    own_session = session is None
    if own_session:
        session = db.SessionLocal()()
    try:
        from cyclone.db import Remittance
        rows = session.execute(
            select(Remittance.id, Remittance.batch_id)
            .where(Remittance.id.in_(seen))
            .order_by(Remittance.received_at.desc())
        ).all()
    finally:
        if own_session:
            session.close()

    db_collisions: dict[str, str] = {pcn: bid for pcn, bid in rows}
    # Same rule as 837: within-file dupes are also collisions.
    colliding = sorted(
        pcn for pcn, n in counts.items()
        if pcn in db_collisions or n > 1
    )
    existing_batch_id = next(iter(db_collisions.values()), None) if db_collisions else None
    return CollisionReport(
        colliding_claim_ids=colliding,
        existing_batch_id=existing_batch_id,
        within_file_duplicate_ids=within_file_duplicate_ids,
        total_claims=len(pcns),
    )
```

- [ ] **Step 4: Run tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_dedup.py --no-header -q
```

Expected: 5 passed.

- [ ] **Step 5: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/src/cyclone/dedup.py backend/tests/test_dedup.py
git commit -m "feat(dedup): preflight_837 / preflight_835 with CollisionReport"
```

---

### Task 2.2: Store helper `delete_batch` with cascade + 409 guard

The `DELETE /api/batches/{id}` endpoint (Task 2.4) calls a store method
that hard-deletes a batch and cascades through all child rows. The
method refuses if any claim is past `submitted` state.

**Files:**
- Modify: `backend/src/cyclone/store.py` (add `delete_batch` method on `CycloneStore`)
- Modify: `backend/tests/test_store.py` (add tests)

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

Add to `backend/tests/test_store.py`:

```python
def test_delete_batch_cascades_to_claims():
    """delete_batch removes the batch row and all its claim rows."""
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="B1", kind="837p", input_filename="x.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(id="CLM-1", batch_id="B1", patient_control_number="M1"))
        s.add(Claim(id="CLM-2", batch_id="B1", patient_control_number="M2"))
        s.commit()

    s = CycloneStore()
    result = s.delete_batch("B1")
    assert result is True

    with _db.SessionLocal()() as sess:
        assert sess.get(_db.Batch, "B1") is None
        rows = sess.execute(_db.text("SELECT id FROM claims WHERE batch_id='B1'")).all()
        assert rows == []


def test_delete_batch_returns_false_for_unknown():
    """Unknown batch_id -> returns False (not raises)."""
    s = CycloneStore()
    assert s.delete_batch("does-not-exist") is False


def test_delete_batch_raises_on_reconciled_claims():
    """Refuses to delete if any claim is past 'submitted' state."""
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="B2", kind="837p", input_filename="x.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(
            id="CLM-X", batch_id="B2", patient_control_number="M",
            state="paid",  # reconciled
        ))
        s.commit()

    s = CycloneStore()
    with pytest.raises(_InvalidStateError):  # reuse the existing 409 error type
        s.delete_batch("B2")
```

Wait — `InvalidStateError` is the wrong type for this case (it's for
`apply_*` skips, not for delete-blocked). We need a new exception
class. Update the test to use a new `BatchNotDeletableError`:

Adjust the test imports at the top of the file:

```python
from cyclone.store import (
    AlreadyMatchedError,
    BatchNotDeletableError,
    CycloneStore,
    store,
)
```

And add `BatchNotDeletableError` to the import. Then update the third
test:

```python
def test_delete_batch_raises_batch_not_deletable_on_reconciled_claims():
    """Refuses to delete if any claim is past 'submitted' state."""
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="B2", kind="837p", input_filename="x.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(
            id="CLM-X", batch_id="B2", patient_control_number="M",
            state="paid",  # reconciled
        ))
        s.commit()

    s = CycloneStore()
    with pytest.raises(BatchNotDeletableError) as excinfo:
        s.delete_batch("B2")
    assert "non-submitted" in str(excinfo.value).lower()
    assert "B2" in str(excinfo.value)

    # The batch and its claim are still in the DB.
    with _db.SessionLocal()() as sess:
        assert sess.get(_db.Batch, "B2") is not None
        rows = sess.execute(_db.text("SELECT id FROM claims WHERE batch_id='B2'")).all()
        assert len(rows) == 1
```

- [ ] **Step 2: Run tests, expect FAIL with "no attribute 'delete_batch'" and "no name 'BatchNotDeletableError'"**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_store.py -k "delete_batch" --no-header -q
```

Expected: 3 failures.

- [ ] **Step 3: Implement `BatchNotDeletableError` and `delete_batch`**

In `backend/src/cyclone/store.py`, add the exception class near the
other exception classes (right after `InvalidStateError`):

```python
class BatchNotDeletableError(Exception):
    """Raised by ``CycloneStore.delete_batch`` when the batch has claims in
    a non-``submitted`` state (i.e., it has been reconciled or otherwise
    progressed past raw ingestion).

    The T15 API endpoint maps this to a 409 Conflict; the user must first
    unreconcile the affected claims before deleting the batch.
    """

    def __init__(self, batch_id: str, offending_claim_id: str):
        self.batch_id = batch_id
        self.offending_claim_id = offending_claim_id
        super().__init__(
            f"Batch {batch_id} cannot be deleted: claim {offending_claim_id} "
            f"is in a non-submitted state."
        )
```

Find the `CycloneStore` class (search for `class CycloneStore`). Add
the `delete_batch` method as the last method in the class. Use this
code (the method goes inside `class CycloneStore`):

```python
    def delete_batch(self, batch_id: str) -> bool:
        """Hard-delete a batch and cascade through child rows.

        Returns ``True`` if the batch was found and deleted, ``False`` if
        no batch with that id exists. Raises :class:`BatchNotDeletableError`
        if any claim in the batch is past the ``submitted`` state (i.e.,
        has been reconciled, reversed, denied, or otherwise progressed);
        the user must unreconcile those claims first.

        The cascade relies on the ``ON DELETE CASCADE`` FKs declared in
        the schema migrations: ``claims.batch_id``, ``remittances.batch_id``,
        ``activity_events.batch_id`` (when present), ``matches.claim_id``
        (via claims), ``line_reconciliations.claim_id`` (via claims), and
        the remittance-side children. The store explicitly records a
        ``batch_deleted`` activity event before the cascade so the audit
        log retains a tombstone of the deletion.

        The 409 guard runs in a single short-lived session; the
        ``batch_deleted`` event and the cascade are committed atomically.
        """
        from datetime import datetime as _dt, timezone as _tz

        with db.SessionLocal()() as s:
            batch_row = s.get(Batch, batch_id)
            if batch_row is None:
                return False
            # 409 guard: refuse if any claim has progressed past 'submitted'.
            offending = s.execute(
                select(Claim.id)
                .where(Claim.batch_id == batch_id)
                .where(Claim.state != "submitted")
                .limit(1)
            ).first()
            if offending is not None:
                raise BatchNotDeletableError(batch_id, offending[0])
            # Tombstone activity event for the audit log.
            s.add(ActivityEvent(
                ts=_dt.now(_tz.utc),
                kind="batch_deleted",
                batch_id=batch_id,
                payload_json={
                    "message": f"Batch {batch_id} deleted",
                    "kind": batch_row.kind,
                    "input_filename": batch_row.input_filename,
                },
            ))
            s.flush()
            s.delete(batch_row)
            s.commit()
        return True
```

Add the `delete_batch` import near the top of the file (right after
`AlreadyMatchedError`):

```python
class BatchNotDeletableError(Exception):
    ...
```

(The exception class goes in the module body, not inside
`CycloneStore`. The class definition is unchanged from what was added
in Step 3 above.)

The new `select` import in the method body is already available because
`store.py` imports `from sqlalchemy import ...` later in the file
(grep to confirm). If not, add `from sqlalchemy import select` to the
top of the file.

- [ ] **Step 4: Run the new tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_store.py -k "delete_batch" --no-header -q
```

Expected: 3 passed.

- [ ] **Step 5: Run the full test_store.py, expect all PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_store.py --no-header -q
```

Expected: 15 passed (12 prior + 3 new).

- [ ] **Step 6: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/src/cyclone/store.py backend/tests/test_store.py
git commit -m "feat(store): delete_batch hard-deletes with cascade + non-submitted guard"
```

---

### Task 2.3: 837 endpoint pre-flight, `?force=true`, race handler, 200 `skipped_claim_ids`

This is the biggest task. The 837 endpoint needs:
1. `?force: bool = Query(False)` parameter
2. Pre-flight dedup between validation and persist (when not force)
3. New 409 body shape: full parse result + collisions + existing_batch_id
4. Race-condition 409 (same shape) when IntegrityError fires after a clean pre-flight
5. 200 body adds `skipped_claim_ids` when force=true (so the UI can show what was skipped)

The 409 body builder is shared between pre-flight and race, so it lives
as a module-level helper.

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

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

Add to `backend/tests/test_api_parse_persists.py`:

```python
def test_parse_837_409_includes_parse_result_and_collisions(client: TestClient) -> None:
    """Pre-flight dedup returns 409 with the full parse result + collisions."""
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    # Pre-seed a claim in a prior batch.
    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="PRIOR", kind="837p", input_filename="prior.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(
            id="CLM-A", batch_id="PRIOR", patient_control_number="M",
            state_changed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        ))
        s.commit()

    # Build a small 837 file with one CLM* that collides.
    text = (
        "ISA*00*          *00*          *ZZ*SUBMITTERID    *ZZ*RECEIVERID     "
        "*240101*1200*^*00501*000000001*0*P*:~\n"
        "GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X222A1~\n"
        "ST*837*0001*005010X222A1~\n"
        "BHT*0019*00*1*20240101*1200*CH~\n"
        "NM1*41*2*SUBMITTER*****46*SUBMITTERID~\n"
        "PER*IC*CONTACT*TE*5555555555~\n"
        "NM1*40*2*RECEIVER*****46*RECEIVERID~\n"
        "HL*1**20*1~\n"
        "NM1*85*2*BILLING*****XX*1881068062~\n"
        "N3*123 MAIN*~~\nN4*DENVER*CO*80202~\n"
        "REF*EI*123456789~\n"
        "HL*2*1*22*0~\n"
        "SBR*P*18*******CI~\n"
        "NM1*IL*1*DOE*JOHN****MI*M~\n"
        "N3*456 ELM*~~\nN4*DENVER*CO*80202~\n"
        "DMG*D8*19700101*M~\n"
        "NM1*PR*2*MEDICAID*****PI*SKCO0~\n"
        "CLM*CLM-A*100***11:B:1*Y*A*Y*Y~\n"
        "HI*ABK:R69~\n"
        "LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
        "DTP*472*D8*20240101~\n"
        "SE*24*0001~\nGE*1*1~\nIEA*1*000000001~\n"
    )

    resp = client.post(
        "/api/parse-837",
        files={"file": ("dup.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 409, resp.text
    body = resp.json()
    assert body["error"] == "Duplicate claim"
    assert body["existing_batch_id"] == "PRIOR"
    assert body["batch_id"] is None
    assert body["collisions"]["colliding_claim_ids"] == ["CLM-A"]
    assert body["collisions"]["total_claims"] == 1
    assert body["collisions"]["total_collisions"] == 1
    assert body["collisions"]["new_claims_after_skip"] == 0
    assert "parse_result" in body
    assert body["parse_result"]["claims"][0]["claim_id"] == "CLM-A"
    # No batch was actually persisted.
    with _db.SessionLocal()() as s:
        n = s.execute(_db.text("SELECT COUNT(*) FROM batches WHERE id != 'PRIOR'")).scalar()
    assert n == 0


def test_parse_837_within_file_duplicate_returns_409_with_null_batch_id(
    client: TestClient,
) -> None:
    """A file with the same CLM01 twice returns 409 with existing_batch_id=None."""
    text = (
        "ISA*00*          *00*          *ZZ*SUBMITTERID    *ZZ*RECEIVERID     "
        "*240101*1200*^*00501*000000001*0*P*:~\n"
        "GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X222A1~\n"
        "ST*837*0001*005010X222A1~\n"
        "BHT*0019*00*1*20240101*1200*CH~\n"
        "NM1*41*2*SUBMITTER*****46*SUBMITTERID~\n"
        "PER*IC*CONTACT*TE*5555555555~\n"
        "NM1*40*2*RECEIVER*****46*RECEIVERID~\n"
        "HL*1**20*1~\n"
        "NM1*85*2*BILLING*****XX*1881068062~\n"
        "N3*123 MAIN*~~\nN4*DENVER*CO*80202~\n"
        "REF*EI*123456789~\n"
        "HL*2*1*22*0~\n"
        "SBR*P*18*******CI~\n"
        "NM1*IL*1*DOE*JOHN****MI*M~\n"
        "N3*456 ELM*~~\nN4*DENVER*CO*80202~\n"
        "DMG*D8*19700101*M~\n"
        "NM1*PR*2*MEDICAID*****PI*SKCO0~\n"
        "CLM*CLM-DUP*100***11:B:1*Y*A*Y*Y~\n"
        "HI*ABK:R69~\n"
        "LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
        "DTP*472*D8*20240101~\n"
        "CLM*CLM-DUP*100***11:B:1*Y*A*Y*Y~\n"
        "HI*ABK:R69~\n"
        "LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
        "DTP*472*D8*20240101~\n"
        "SE*28*0001~\nGE*1*1~\nIEA*1*000000001~\n"
    )

    resp = client.post(
        "/api/parse-837",
        files={"file": ("within.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 409, resp.text
    body = resp.json()
    assert body["existing_batch_id"] is None
    assert "CLM-DUP" in body["collisions"]["colliding_claim_ids"]
    assert "CLM-DUP" in body["collisions"].get("within_file_duplicate_ids", body["collisions"]["colliding_claim_ids"])


def test_parse_837_force_true_persists_non_colliding_claims(client: TestClient) -> None:
    """With force=true, the collision is skipped and other claims persist."""
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="PRIOR", kind="837p", input_filename="p.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(
            id="CLM-OLD", batch_id="PRIOR", patient_control_number="M",
            state_changed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        ))
        s.commit()

    # File with 2 claims: one colliding, one new.
    text = (
        "ISA*00*          *00*          *ZZ*SUBMITTERID    *ZZ*RECEIVERID     "
        "*240101*1200*^*00501*000000001*0*P*:~\n"
        "GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X222A1~\n"
        "ST*837*0001*005010X222A1~\n"
        "BHT*0019*00*1*20240101*1200*CH~\n"
        "NM1*41*2*SUBMITTER*****46*SUBMITTERID~\n"
        "PER*IC*CONTACT*TE*5555555555~\n"
        "NM1*40*2*RECEIVER*****46*RECEIVERID~\n"
        "HL*1**20*1~\n"
        "NM1*85*2*BILLING*****XX*1881068062~\n"
        "N3*123 MAIN*~~\nN4*DENVER*CO*80202~\n"
        "REF*EI*123456789~\n"
        "HL*2*1*22*0~\n"
        "SBR*P*18*******CI~\n"
        "NM1*IL*1*DOE*JOHN****MI*M~\n"
        "N3*456 ELM*~~\nN4*DENVER*CO*80202~\n"
        "DMG*D8*19700101*M~\n"
        "NM1*PR*2*MEDICAID*****PI*SKCO0~\n"
        "CLM*CLM-OLD*100***11:B:1*Y*A*Y*Y~\n"
        "HI*ABK:R69~\n"
        "LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
        "DTP*472*D8*20240101~\n"
        "CLM*CLM-NEW*100***11:B:1*Y*A*Y*Y~\n"
        "HI*ABK:R69~\n"
        "LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
        "DTP*472*D8*20240101~\n"
        "SE*30*0001~\nGE*1*1~\nIEA*1*000000001~\n"
    )

    resp = client.post(
        "/api/parse-837?force=true",
        files={"file": ("force.txt", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert body.get("skipped_claim_ids") == ["CLM-OLD"]
    # The new claim persisted; the old one did not.
    with _db.SessionLocal()() as s:
        rows = s.execute(_db.text("SELECT id, batch_id FROM claims WHERE id IN ('CLM-OLD','CLM-NEW') ORDER BY id")).all()
    by_id = {r[0]: r[1] for r in rows}
    assert by_id["CLM-OLD"] == "PRIOR"  # not re-inserted
    assert by_id["CLM-NEW"] != "PRIOR"  # new batch
```

Also add the DELETE endpoint test (this goes in `test_api_parse_persists.py`
because there's no dedicated test file for it yet — `store.py` already
has the unit tests in Task 2.2):

```python
def test_delete_batch_endpoint_cascades(client: TestClient) -> None:
    """DELETE /api/batches/{id} removes the batch and its claims."""
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="B1", kind="837p", input_filename="x.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(id="CLM-1", batch_id="B1", patient_control_number="M1"))
        s.commit()

    resp = client.delete("/api/batches/B1")
    assert resp.status_code in (200, 204), resp.text

    with _db.SessionLocal()() as s:
        assert s.get(_db.Batch, "B1") is None
        n = s.execute(_db.text("SELECT COUNT(*) FROM claims WHERE batch_id='B1'")).scalar()
    assert n == 0


def test_delete_batch_endpoint_404_on_unknown(client: TestClient) -> None:
    resp = client.delete("/api/batches/does-not-exist")
    assert resp.status_code == 404


def test_delete_batch_endpoint_409_on_reconciled_claim(client: TestClient) -> None:
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Claim

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="B2", kind="837p", input_filename="x.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Claim(
            id="CLM-X", batch_id="B2", patient_control_number="M",
            state="paid",
        ))
        s.commit()

    resp = client.delete("/api/batches/B2")
    assert resp.status_code == 409
    with _db.SessionLocal()() as s:
        assert s.get(_db.Batch, "B2") is not None
```

- [ ] **Step 2: Run the new tests, expect FAIL**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest \
  tests/test_api_parse_persists.py -k "409 or force or delete_batch_endpoint" \
  --no-header -q
```

Expected: 6 failures (4 for the new 409/force cases; the existing
`test_409_response_includes_existing_batch_id_for_837` from the prior
plan also fails because the body shape changed; 2 for DELETE not yet
existing).

- [ ] **Step 3: Implement the new 837 endpoint body and DELETE endpoint**

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

1. **Add the `force` parameter to the `parse_837` signature:**

Replace the existing signature:

```python
@app.post("/api/parse-837")
async def parse_837(
    request: Request,
    file: UploadFile = File(...),
    payer: str = Query("co_medicaid"),
    include_raw_segments: bool = Query(True),
    strict: bool = Query(False),
    ack: bool = Query(False),
) -> Any:
```

with:

```python
@app.post("/api/parse-837")
async def parse_837(
    request: Request,
    file: UploadFile = File(...),
    payer: str = Query("co_medicaid"),
    include_raw_segments: bool = Query(True),
    strict: bool = Query(False),
    ack: bool = Query(False),
    force: bool = Query(False),
) -> Any:
```

2. **Add the 409 builder and modify the 837 path:**

Find the existing `try: store.add(...) except IntegrityError as exc:`
block in `parse_837` (around line 392-415). Replace the entire block
(after the `if _has_claim_validation_errors` check) with:

```python
    # Pre-flight dedup: detect within-file dupes and cross-batch CLM01 collisions
    # BEFORE persisting. The user sees the parse result + collision summary in
    # the 409 body and can choose force-insert / delete prior / pick a different
    # file. force=true bypasses the check; the store.add dedup still silently
    # skips colliding rows, and the 200 body includes skipped_claim_ids.
    if not force and result.claims:
        from cyclone import dedup
        report = dedup.preflight_837(result)
        if report.has_collisions:
            return _build_dedup_409(
                result=result,
                report=report,
                kind="cross_batch" if report.existing_batch_id else "within_file",
                error="Duplicate claim",
            )

    rec = BatchRecord(
        id=uuid.uuid4().hex,
        kind="837p",
        input_filename=file.filename or "upload.txt",
        parsed_at=utcnow(),
        result=result,
    )
    try:
        store.add(rec, event_bus=request.app.state.event_bus)
    except IntegrityError as exc:
        # Race: pre-flight said clean, but a concurrent process ingested a
        # colliding batch between the check and the persist. Re-run pre-flight
        # to build the same 409 body shape.
        from cyclone import dedup
        report = dedup.preflight_837(result)
        log.warning("Race on persist for batch %s: %s", rec.id, exc)
        return _build_dedup_409(
            result=result,
            report=report,
            kind="race",
            error="Duplicate claim (race condition)",
        )

    if _client_wants_json(request):
        body = json.loads(result.model_dump_json())
        if ack:
            ack_body = _build_and_persist_ack(rec.id)
            if ack_body is not None:
                body["ack"] = ack_body
        if force:
            # The store.add dedup silently skipped any colliding claim_id;
            # figure out which were skipped by diffing the parsed result against
            # what actually persisted.
            from cyclone import dedup
            body["skipped_claim_ids"] = _compute_skipped_837(rec.id, result.claims)
        return JSONResponse(content=body)

    return StreamingResponse(
        _ndjson_stream(result),
        media_type="application/x-ndjson",
    )
```

3. **Add the helpers `_build_dedup_409` and `_compute_skipped_837` as module-level functions** (right above the `parse_837` endpoint definition):

```python
def _build_dedup_409(
    result,
    report,
    *,
    kind: str,
    error: str,
) -> JSONResponse:
    """Build the standard 409 body for any dedup failure.

    Shared between parse_837 and parse_835; the only difference is the
    error string (we pass it in) and the kind (cross_batch / within_file /
    race) which drives the human-readable detail message.
    """
    if kind == "within_file":
        detail = (
            f"CLM01(s) {', '.join(report.within_file_duplicate_ids)} appear "
            f"twice in this file. Force-insert will keep the first occurrence "
            f"and skip the rest."
        )
    elif kind == "race":
        detail = (
            "Another process ingested a colliding batch between the check "
            "and the persist. Re-upload to retry with the latest state."
        )
    else:  # cross_batch
        detail = (
            f"{len(report.colliding_claim_ids)} of {report.total_claims} "
            f"claims collide with prior batch {report.existing_batch_id}. "
            f"Force-insert to skip the duplicates, or delete the prior batch."
        )

    body = {
        "error": error,
        "detail": detail,
        "batch_id": None,
        "existing_batch_id": report.existing_batch_id,
        "collisions": {
            "colliding_claim_ids": report.colliding_claim_ids,
            "total_collisions": len(report.colliding_claim_ids),
            "total_claims": report.total_claims,
            "new_claims_after_skip": report.new_claims_after_skip,
            "within_file_duplicate_ids": report.within_file_duplicate_ids,
        },
        "parse_result": json.loads(result.model_dump_json()),
    }
    return JSONResponse(status_code=409, content=body)


def _compute_skipped_837(batch_id: str, parsed_claims) -> list[str]:
    """Compare the parsed claims against what's in the DB for this batch.

    Returns the claim_ids in the parsed result that did NOT make it into
    the DB — these are the ones silently skipped by the store.add dedup.
    Sorted for determinism.
    """
    from cyclone import db as _db
    from cyclone.db import Claim
    parsed_ids = [c.claim_id for c in parsed_claims]
    if not parsed_ids:
        return []
    with _db.SessionLocal()() as s:
        persisted = s.execute(
            select(Claim.id).where(Claim.batch_id == batch_id)
        ).all()
    persisted_ids = {r[0] for r in persisted}
    return sorted(set(parsed_ids) - persisted_ids)
```

The `_compute_skipped_837` function needs `from sqlalchemy import select`
at the top of `api.py` (the file already imports it for other code; if
not, add it).

4. **Remove the old 409 handler comment** in the `except IntegrityError`
block — it's been replaced by the race handler above.

5. **Add the DELETE endpoint** (right after the 835 endpoint, before
`/api/parse-999`):

```python
@app.delete("/api/batches/{batch_id}")
def delete_batch_endpoint(batch_id: str) -> dict:
    """Hard-delete a batch and cascade through child rows.

    Returns 200 on success, 404 if the batch doesn't exist, 409 if any
    claim in the batch is past the ``submitted`` state (must unreconcile
    first). See ``CycloneStore.delete_batch`` for the cascade mechanics.
    """
    try:
        deleted = store.delete_batch(batch_id)
    except BatchNotDeletableError as exc:
        from fastapi.responses import JSONResponse as _JR
        return _JR(
            status_code=409,
            content={
                "error": "Batch not deletable",
                "detail": str(exc),
                "batch_id": exc.batch_id,
                "offending_claim_id": exc.offending_claim_id,
            },
        )
    if not deleted:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail=f"Batch {batch_id} not found")
    return {"ok": True, "batch_id": batch_id}
```

6. **Add the `BatchNotDeletableError` import** at the top of `api.py`
(find the existing imports from `cyclone.store`):

```python
from cyclone.store import (
    BatchNotDeletableError,  # NEW
    CycloneStore,
    store,
)
```

- [ ] **Step 4: Run the new tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest \
  tests/test_api_parse_persists.py -k "409 or force or delete_batch_endpoint" \
  --no-header -q
```

Expected: all 6 new + 1 pre-existing (`test_409_response_includes_existing_batch_id_for_837`)
should pass once the body shape matches. The pre-existing test
expects `body.get("existing_batch_id") == "PRIOR"` which still
holds under the new shape. PASS.

- [ ] **Step 5: Run the full test_api_parse_persists.py, expect all PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_api_parse_persists.py --no-header -q
```

Expected: all tests pass.

- [ ] **Step 6: Run the broader API test suite to catch regressions**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_api.py tests/test_api_835.py tests/test_api_gets.py \
  --no-header -q
```

Expected: all pass. If anything fails, it means an existing test
relied on the old 409 body shape or the absence of `force` — update
those tests to match the new contract.

- [ ] **Step 7: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/src/cyclone/api.py backend/tests/test_api_parse_persists.py
git commit -m "feat(api): 837 pre-flight dedup, force=true, race 409, DELETE /api/batches/{id}"
```

---

### Task 2.4: 835 endpoint pre-flight, `?force=true`, race handler

Mirror the 837 changes for the 835 endpoint.

**Files:**
- Modify: `backend/src/cyclone/api.py` (835 endpoint)
- Modify: `backend/tests/test_api_835.py` (add 409/force tests)

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

Add to `backend/tests/test_api_835.py` (find a good insertion point
near other parse tests):

```python
def test_parse_835_409_includes_parse_result_and_collisions(client: TestClient) -> None:
    """835 pre-flight dedup returns 409 with the full parse result + collisions."""
    from datetime import datetime, timezone
    from cyclone import db as _db
    from cyclone.db import Batch, Remittance

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="PRIOR835", kind="835", input_filename="p.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Remittance(
            id="CLP-A", batch_id="PRIOR835",
            payer_claim_control_number="CLP-A",
            status_code="1",
            received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        ))
        s.commit()

    # Use the co_medicaid 835 fixture (real EDI), then post-assert the 409.
    from pathlib import Path
    text = (Path(__file__).parent / "fixtures" / "co_medicaid_835.txt").read_text()

    resp = client.post(
        "/api/parse-835",
        files={"file": ("dup.835", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    # The fixture's first CLP01 collides with the seeded one. We expect 409.
    assert resp.status_code == 409, resp.text
    body = resp.json()
    assert body["error"] == "Duplicate remittance"
    assert body["existing_batch_id"] == "PRIOR835"
    assert body["batch_id"] is None
    assert len(body["collisions"]["colliding_claim_ids"]) >= 1
    assert "parse_result" in body


def test_parse_835_force_true_persists_non_colliding_claims(client: TestClient) -> None:
    """With force=true, the colliding remittance is skipped, others persist."""
    from datetime import datetime, timezone
    from pathlib import Path
    from cyclone import db as _db
    from cyclone.db import Batch, Remittance

    with _db.SessionLocal()() as s:
        s.add(Batch(
            id="PRIOR835", kind="835", input_filename="p.txt",
            parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
            raw_result_json={},
        ))
        s.add(Remittance(
            id="CLP-OLD", batch_id="PRIOR835",
            payer_claim_control_number="CLP-OLD",
            status_code="1",
            received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
        ))
        s.commit()

    # Build a small 835 with two CLP segments: one colliding, one new.
    text = (
        "ISA*00*          *00*          *ZZ*PAYERID        *ZZ*RECEIVERID     "
        "*240101*1200*^*00501*000000001*0*P*:~\n"
        "GS*HP*PAYERID*RECEIVERID*20240101*1200*1*X*005010X221A1~\n"
        "ST*835*0001~\n"
        "BPR*I*100*100*C*ACH*CCP*01*123456789*DA*0000001**01*123456789*DA*0000002*20240101~\n"
        "TRN*1*CHECK1*1234567890~\n"
        "DTM*405*20240101~\n"
        "N1*PR*PAYER NAME~\n"
        "N3*100 PAYER ST*~~\nN4*DENVER*CO*80202~\n"
        "REF*2U*PAYERID~\n"
        "N1*PE*RECEIVER NAME~\n"
        "N3*200 RECEIVER ST*~~\nN4*DENVER*CO*80202~\n"
        "LX*1~\n"
        "CLP*CLP-OLD*1*100*100*0*0*0*11*1*PATIENT*DOE****MI*M0001~\n"
        "NM1*QC*1*DOE*JOHN****MI*M0001~\n"
        "DTM*232*20240101~\n"
        "SVC*HC:99213*100*100**1~\n"
        "DTM*472*20240101~\n"
        "LX*2~\n"
        "CLP*CLP-NEW*1*50*50*0*0*0*11*1*PATIENT*ROE****MI*M0002~\n"
        "NM1*QC*1*ROE*JANE****MI*M0002~\n"
        "DTM*232*20240101~\n"
        "SVC*HC:99214*50*50**1~\n"
        "DTM*472*20240101~\n"
        "SE*30*0001~\nGE*1*1~\nIEA*1*000000001~\n"
    )

    resp = client.post(
        "/api/parse-835?force=true",
        files={"file": ("force.835", text, "text/plain")},
        headers={"Accept": "application/json"},
    )
    assert resp.status_code == 200, resp.text
    body = resp.json()
    assert "CLP-OLD" in body.get("skipped_claim_ids", [])
    # CLP-NEW is in a new batch.
    with _db.SessionLocal()() as s:
        row = s.execute(_db.text("SELECT batch_id FROM remittances WHERE id='CLP-NEW'")).first()
    assert row is not None
    assert row[0] != "PRIOR835"
```

- [ ] **Step 2: Run the new tests, expect FAIL**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest \
  tests/test_api_835.py -k "409 or force" --no-header -q
```

Expected: 2 failures (no pre-flight on 835 yet, no force param).

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

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

1. Add `force: bool = Query(False)` to the 835 signature:

```python
@app.post("/api/parse-835")
async def parse_835_endpoint(
    request: Request,
    file: UploadFile = File(...),
    payer: str = Query("co_medicaid_835"),
    include_raw_segments: bool = Query(True),
    strict: bool = Query(False),
    force: bool = Query(False),
) -> Any:
```

2. Add the pre-flight check between validation and persist (mirror
837). Find the `try: store.add(...) except IntegrityError as exc:`
block in the 835 endpoint and replace it with the pre-flight +
race-handler pattern from Task 2.3, but call `dedup.preflight_835`
and pass `error="Duplicate remittance"`.

3. Add the `_compute_skipped_835` helper alongside `_compute_skipped_837`:

```python
def _compute_skipped_835(batch_id: str, parsed_remits) -> list[str]:
    """Compare parsed remittances against what's in the DB for this batch."""
    from sqlalchemy import select
    from cyclone import db as _db
    from cyclone.db import Remittance
    parsed_ids = [r.payer_claim_control_number for r in parsed_remits]
    if not parsed_ids:
        return []
    with _db.SessionLocal()() as s:
        persisted = s.execute(
            select(Remittance.id).where(Remittance.batch_id == batch_id)
        ).all()
    persisted_ids = {r[0] for r in persisted}
    return sorted(set(parsed_ids) - persisted_ids)
```

- [ ] **Step 4: Run the new tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest \
  tests/test_api_835.py -k "409 or force" --no-header -q
```

Expected: 2 passed.

- [ ] **Step 5: Run the full test_api_835.py, expect all PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest tests/test_api_835.py --no-header -q
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/src/cyclone/api.py backend/tests/test_api_835.py
git commit -m "feat(api): 835 pre-flight dedup, force=true, race 409"
```

---

### Task 2.5: Run the full backend test suite as a checkpoint

The 837/835 endpoints changed significantly. Run the full backend
test suite to catch any test that relied on the old 409 body shape.

- [ ] **Step 1: Run the full backend test suite**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend
PYTHONPATH=/Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix/backend/src \
  .venv/bin/python3.13 -m pytest --no-header -q
```

Expected: all pass. If any test fails:
- If the test was checking the 409 body shape, update it to the new
  shape (or remove the assertion if the field is gone).
- If the test was using `?force=true` semantics that don't match,
  adjust the test to match the new contract.
- If the test was using the old `existing_batch_id`-only body, update
  to the new full-body shape.

**Do not modify production code to make old tests pass.** If a test
was checking pre-conditions that the new code intentionally doesn't
satisfy (e.g., a test that asserts "without `?force=true`, the file
fails to ingest"), update the test to reflect the new contract.

- [ ] **Step 2: Commit any test fixes**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/tests/
git commit -m "test(api): update tests for new 409 body shape and force=true"
```

(Only if there were fixes; otherwise skip.)

---

## Phase 3 — Frontend

### Task 3.1: `ApiError` carries `collisions` + `parseResult`; `parse837/835` accept `force`

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

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

Create `src/lib/api.test.ts`:

```typescript
import { describe, it, expect, vi, afterEach } from "vitest";
import { ApiError, parse837 } from "@/lib/api";

afterEach(() => vi.restoreAllMocks());

describe("ApiError", () => {
  it("carries collisions and parseResult when constructed", () => {
    const collisions = {
      colliding_claim_ids: ["A"],
      total_collisions: 1,
      total_claims: 2,
      new_claims_after_skip: 1,
    };
    const parseResult = { claims: [], summary: { total_claims: 2 } };
    const e = new ApiError(409, "dup", "BATCH-1", collisions, parseResult);
    expect(e.status).toBe(409);
    expect(e.existingBatchId).toBe("BATCH-1");
    expect(e.collisions).toEqual(collisions);
    expect(e.parseResult).toEqual(parseResult);
  });

  it("defaults collisions and parseResult to null", () => {
    const e = new ApiError(500, "boom");
    expect(e.collisions).toBeNull();
    expect(e.parseResult).toBeNull();
  });
});

describe("parse837 throws ApiError with collisions and parseResult", () => {
  it("extracts collisions and parse_result from 409 body", async () => {
    vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
    const collisions = { colliding_claim_ids: ["A"], total_collisions: 1, total_claims: 2, new_claims_after_skip: 1 };
    const parse_result = { claims: [], summary: { total_claims: 2 } };
    const res = new Response(
      JSON.stringify({
        error: "Duplicate claim",
        detail: "collision",
        batch_id: null,
        existing_batch_id: "PRIOR",
        collisions,
        parse_result,
      }),
      { status: 409, headers: { "Content-Type": "application/json" } },
    );
    vi.stubGlobal("fetch", vi.fn(async () => res));
    const file = new File(["x"], "f.txt", { type: "text/plain" });
    await expect(
      parse837(file, { onProgress: () => {} }),
    ).rejects.toMatchObject({
      status: 409,
      existingBatchId: "PRIOR",
      collisions,
      parseResult: parse_result,
    });
  });

  it("appends ?force=true when options.force is set", async () => {
    vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
    const fetchMock = vi.fn(async () =>
      new Response(JSON.stringify({ summary: { total_claims: 1 } }), {
        status: 200,
        headers: { "Content-Type": "application/json" },
      }),
    );
    vi.stubGlobal("fetch", fetchMock);
    const file = new File(["x"], "f.txt", { type: "text/plain" });
    await parse837(file, { onProgress: () => {}, force: true });
    const calledUrl = fetchMock.mock.calls[0][0];
    expect(calledUrl).toContain("force=true");
  });
});
```

- [ ] **Step 2: Run tests, expect FAIL**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test -- src/lib/api.test.ts
```

Expected: ApiError constructor doesn't accept `collisions`/`parseResult`
yet; `parse837` doesn't append `?force=true`.

- [ ] **Step 3: Update `src/lib/api.ts`**

1. **Update the `ApiError` class.** Find the current class (around
   line 164-167) and replace with:

```typescript
export type CollisionSummary = {
  colliding_claim_ids: string[];
  total_collisions: number;
  total_claims: number;
  new_claims_after_skip: number;
  within_file_duplicate_ids?: string[];
};

export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
    public existingBatchId: string | null = null,
    public collisions: CollisionSummary | null = null,
    public parseResult: unknown = null,
  ) {
    super(message);
  }
}
```

2. **Update `readErrorBody` to return more fields.** Find the
   `readErrorBody` function and replace it with:

```typescript
type ErrorBody = {
  detail?: unknown;
  error?: unknown;
  existing_batch_id?: unknown;
  collisions?: unknown;
  parse_result?: unknown;
};

async function readErrorBody(
  res: Response,
): Promise<{
  message: string;
  existingBatchId: string | null;
  collisions: CollisionSummary | null;
  parseResult: unknown;
}> {
  try {
    const t = await res.text();
    if (!t) return { message: "", existingBatchId: null, collisions: null, parseResult: null };
    try {
      const obj = JSON.parse(t) as ErrorBody;
      let message = "";
      if (typeof obj.detail === "string") message = obj.detail;
      else if (typeof obj.error === "string") message = obj.error;
      else message = t;
      const existing =
        typeof obj.existing_batch_id === "string" ? obj.existing_batch_id : null;
      const collisions =
        obj.collisions && typeof obj.collisions === "object"
          ? (obj.collisions as CollisionSummary)
          : null;
      const parseResult = obj.parse_result ?? null;
      return { message, existingBatchId: existing, collisions, parseResult };
    } catch {
      return { message: t, existingBatchId: null, collisions: null, parseResult: null };
    }
  } catch {
    return { message: "", existingBatchId: null, collisions: null, parseResult: null };
  }
}
```

3. **Update `parse837` to accept `force` and surface the new fields.**
   Find the `parse837` function. Replace the function signature and
   the `if (!res.ok)` block:

```typescript
export async function parse837(
  file: File,
  options: { onProgress?: (p: number) => void; force?: boolean } = {},
): Promise<ParseResult> {
  const url = `${base}/api/parse-837${options.force ? "?force=true" : ""}`;
  // ... existing fetch body ...
  if (!res.ok) {
    const { message, existingBatchId, collisions, parseResult } =
      await readErrorBody(res);
    throw new ApiError(
      res.status,
      `${res.status} ${res.statusText}${message ? ` — ${message}` : ""}`,
      existingBatchId,
      collisions,
      parseResult,
    );
  }
  return res.json();
}
```

Keep the rest of the function (the streaming logic for non-JSON
requests) unchanged — only the `if (!res.ok)` block and the URL
construction change.

4. **Update `parse835` similarly.** Same pattern: append
   `?force=true` to the URL and pass the new fields to `ApiError`.

- [ ] **Step 4: Run the new tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test -- src/lib/api.test.ts
```

Expected: 4 tests pass.

- [ ] **Step 5: Run the existing api.ts tests, expect PASS (or update)**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test -- src/lib/api.test.ts src/lib/inbox-api.test.ts
```

Expected: all pass. If the existing inbox-api or other tests assert
on `ApiError` shape and fail, update them to match the new
constructor signature (passing `null` for the new optional fields).

- [ ] **Step 6: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add src/lib/api.ts src/lib/api.test.ts
git commit -m "feat(api): ApiError carries collisions + parseResult; parse837/835 accept force"
```

---

### Task 3.2: New `deleteBatch` function in `src/lib/api.ts`

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

- [ ] **Step 1: Add the function**

Find a good place in `src/lib/api.ts` (near `parse837`/`parse835`).
Add:

```typescript
export async function deleteBatch(batchId: string): Promise<void> {
  const res = await fetch(`${base}/api/batches/${encodeURIComponent(batchId)}`, {
    method: "DELETE",
  });
  if (!res.ok) {
    let body: { error?: string; detail?: string; offending_claim_id?: string } = {};
    try {
      body = (await res.json()) as typeof body;
    } catch {
      // ignore
    }
    throw new ApiError(
      res.status,
      `${res.status} ${res.statusText}${
        body.detail ? ` — ${body.detail}` : ""
      }`,
    );
  }
}
```

- [ ] **Step 2: Add a test**

Add to `src/lib/api.test.ts`:

```typescript
import { deleteBatch } from "@/lib/api";

describe("deleteBatch", () => {
  it("sends DELETE to /api/batches/{id}", async () => {
    vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
    const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
    vi.stubGlobal("fetch", fetchMock);
    await deleteBatch("B123");
    expect(fetchMock).toHaveBeenCalledWith(
      "http://x/api/batches/B123",
      expect.objectContaining({ method: "DELETE" }),
    );
  });

  it("throws ApiError with the 409 detail on refusal", async () => {
    vi.stubGlobal("import.meta.env", { VITE_API_BASE_URL: "http://x" });
    const res = new Response(
      JSON.stringify({ error: "Batch not deletable", detail: "claim X is paid" }),
      { status: 409, headers: { "Content-Type": "application/json" } },
    );
    vi.stubGlobal("fetch", vi.fn(async () => res));
    await expect(deleteBatch("B1")).rejects.toMatchObject({ status: 409 });
  });
});
```

- [ ] **Step 3: Run, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test -- src/lib/api.test.ts
```

Expected: all 6 tests pass.

- [ ] **Step 4: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add src/lib/api.ts src/lib/api.test.ts
git commit -m "feat(api): deleteBatch function"
```

---

### Task 3.3: `Upload.tsx` panel with 4 actions

**Files:**
- Modify: `src/pages/Upload.tsx`
- Create: `src/pages/Upload.test.tsx`

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

Create `src/pages/Upload.test.tsx`:

```tsx
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as apiModule from "@/lib/api";
import { ApiError, CollisionSummary } from "@/lib/api";
import { Upload } from "@/pages/Upload";

vi.mock("@/lib/api", async () => {
  const actual = await vi.importActual<typeof apiModule>("@/lib/api");
  return { ...actual, parse837: vi.fn(), deleteBatch: vi.fn() };
});

beforeEach(() => {
  vi.clearAllMocks();
});

function renderUpload() {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={["/upload"]}>
        <Routes>
          <Route path="/upload" element={<Upload />} />
          <Route path="/batches/:id" element={<div data-testid="batch-page" />} />
        </Routes>
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

const collisions: CollisionSummary = {
  colliding_claim_ids: ["A", "B"],
  total_collisions: 2,
  total_claims: 141,
  new_claims_after_skip: 139,
  within_file_duplicate_ids: [],
};

const parseResult = {
  envelope: {},
  claims: [
    { claim_id: "A", service_lines: [], diagnoses: [] },
    { claim_id: "B", service_lines: [], diagnoses: [] },
  ],
  summary: { total_claims: 141, passed: 141, failed: 0 },
};

describe("Upload error panel", () => {
  it("renders panel with all actions when 409 carries collisions + parseResult", async () => {
    vi.mocked(apiModule.parse837).mockRejectedValueOnce(
      new ApiError(
        409,
        "409 Conflict — dup",
        "PRIOR",
        collisions,
        parseResult,
      ),
    );
    renderUpload();
    const input = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(["x"], "test.txt", { type: "text/plain" });
    fireEvent.change(input, { target: { files: [file] } });
    const forceBtn = await screen.findByRole("button", { name: /force insert/i });
    expect(forceBtn).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /open prior batch/i })).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /delete prior batch/i })).toBeInTheDocument();
    expect(screen.getByRole("button", { name: /pick a different file/i })).toBeInTheDocument();
    // The parse result summary is rendered.
    expect(screen.getByText(/141/)).toBeInTheDocument();
  });

  it("force-insert button re-calls parse837 with force=true", async () => {
    vi.mocked(apiModule.parse837)
      .mockRejectedValueOnce(
        new ApiError(409, "409 Conflict", "PRIOR", collisions, parseResult),
      )
      .mockResolvedValueOnce({ ...parseResult, skipped_claim_ids: ["A", "B"] });
    renderUpload();
    const input = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(["x"], "test.txt", { type: "text/plain" });
    fireEvent.change(input, { target: { files: [file] } });
    const forceBtn = await screen.findByRole("button", { name: /force insert/i });
    fireEvent.click(forceBtn);
    await waitFor(() => {
      expect(apiModule.parse837).toHaveBeenCalledTimes(2);
    });
    expect(apiModule.parse837).toHaveBeenNthCalledWith(
      2,
      expect.any(File),
      expect.objectContaining({ force: true }),
    );
  });

  it("delete-prior button calls deleteBatch", async () => {
    vi.spyOn(window, "confirm").mockReturnValue(true);
    vi.mocked(apiModule.parse837).mockRejectedValueOnce(
      new ApiError(409, "409 Conflict", "PRIOR", collisions, parseResult),
    );
    vi.mocked(apiModule.deleteBatch).mockResolvedValueOnce(undefined);
    renderUpload();
    const input = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(["x"], "test.txt", { type: "text/plain" });
    fireEvent.change(input, { target: { files: [file] } });
    const delBtn = await screen.findByRole("button", { name: /delete prior batch/i });
    fireEvent.click(delBtn);
    await waitFor(() => {
      expect(apiModule.deleteBatch).toHaveBeenCalledWith("PRIOR");
    });
  });

  it("within-file duplicate (existingBatchId null) hides prior batch actions", async () => {
    vi.mocked(apiModule.parse837).mockRejectedValueOnce(
      new ApiError(
        409,
        "409 Conflict",
        null,
        { ...collisions, within_file_duplicate_ids: ["DUP"] },
        parseResult,
      ),
    );
    renderUpload();
    const input = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(["x"], "test.txt", { type: "text/plain" });
    fireEvent.change(input, { target: { files: [file] } });
    await screen.findByRole("button", { name: /force insert/i });
    expect(
      screen.queryByRole("button", { name: /open prior batch/i }),
    ).toBeNull();
    expect(
      screen.queryByRole("button", { name: /delete prior batch/i }),
    ).toBeNull();
  });

  it("'Pick a different file' button clears error", async () => {
    vi.mocked(apiModule.parse837).mockRejectedValueOnce(
      new ApiError(409, "409 Conflict", "PRIOR", collisions, parseResult),
    );
    renderUpload();
    const input = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(["x"], "test.txt", { type: "text/plain" });
    fireEvent.change(input, { target: { files: [file] } });
    const clearBtn = await screen.findByRole("button", { name: /pick a different file/i });
    fireEvent.click(clearBtn);
    expect(screen.queryByText(/collide/i)).toBeNull();
  });

  it("does NOT render panel for non-409 errors", async () => {
    vi.mocked(apiModule.parse837).mockRejectedValueOnce(
      new ApiError(400, "bad file"),
    );
    renderUpload();
    const input = document.querySelector('input[type="file"]') as HTMLInputElement;
    const file = new File(["x"], "test.txt", { type: "text/plain" });
    fireEvent.change(input, { target: { files: [file] } });
    await new Promise((r) => setTimeout(r, 50));
    expect(screen.queryByText(/collide/i)).toBeNull();
  });
});
```

- [ ] **Step 2: Run tests, expect FAIL (Upload.tsx doesn't have the new state yet)**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test -- src/pages/Upload.test.tsx
```

Expected: all 6 tests fail (no panel exists).

- [ ] **Step 3: Modify `src/pages/Upload.tsx`**

This is the most invasive frontend change. Read the current
`Upload.tsx` (the file is 600+ lines; find the existing error-handling
catch block) and apply these changes:

1. **Update imports** at the top:

```typescript
import { ApiError, parse837, parse835, deleteBatch, CollisionSummary } from "@/lib/api";
import { useNavigate } from "react-router-dom";
```

2. **Add new state** near the other `useState` calls:

```typescript
type UploadError = {
  kind: "duplicate";
  existingBatchId: string | null;
  collisions: CollisionSummary;
  parseResult: unknown;
  filename: string;
};
const [uploadError, setUploadError] = useState<UploadError | null>(null);
const [forceInserting, setForceInserting] = useState(false);
const navigate = useNavigate();
```

3. **Replace the existing error-handling catch block** (find the
`catch (err) {` after the parse call) with:

```typescript
    } catch (err) {
      if (err instanceof ApiError && err.status === 409 && err.collisions) {
        setUploadError({
          kind: "duplicate",
          existingBatchId: err.existingBatchId,
          collisions: err.collisions,
          parseResult: err.parseResult,
          filename: file.name,
        });
        toast.error("Duplicate claim — file not ingested");
      } else {
        toast.error(
          err instanceof Error ? err.message : "Failed to parse file",
        );
      }
    }
```

4. **Add the panel JSX** above the streaming results section (find
the existing "parsed claims" / streaming-results JSX and insert
BEFORE it):

```tsx
{uploadError && uploadError.kind === "duplicate" ? (
  <div
    role="alert"
    data-testid="duplicate-error-panel"
    className="rounded-md border border-destructive/40 bg-destructive/5 p-4 mx-auto max-w-3xl"
  >
    <div className="flex items-center gap-2">
      <span className="inline-flex items-center rounded-md bg-destructive px-2 py-0.5 text-xs font-semibold text-destructive-foreground">
        409
      </span>
      <span className="font-semibold">
        {uploadError.collisions.total_collisions} of {uploadError.collisions.total_claims} claims
        collide
        {uploadError.existingBatchId
          ? ` with batch ${uploadError.existingBatchId}`
          : " within this file"}
      </span>
    </div>
    <p className="mt-2 text-sm text-muted-foreground">
      File <span className="font-mono">{uploadError.filename}</span> would persist
      {" "}{uploadError.collisions.new_claims_after_skip} of {uploadError.collisions.total_claims} claims.
      Colliding CLM01s: {uploadError.collisions.colliding_claim_ids.join(", ")}.
    </p>
    <div className="mt-3 flex flex-wrap gap-2">
      <Button
        disabled={forceInserting}
        onClick={async () => {
          setForceInserting(true);
          try {
            const result = await parse837(file, { onProgress: () => {}, force: true });
            setParseResult(result);
            setUploadError(null);
            const skipped = (result as { skipped_claim_ids?: string[] }).skipped_claim_ids?.length ?? 0;
            toast.success(
              `Force-inserted: ${result.summary.total_claims - skipped} of ${result.summary.total_claims} (skipped ${skipped} dups)`,
            );
          } catch (err) {
            toast.error(err instanceof Error ? err.message : "Force-insert failed");
          } finally {
            setForceInserting(false);
          }
        }}
      >
        Force insert (skip {uploadError.collisions.total_collisions} dups)
      </Button>
      {uploadError.existingBatchId ? (
        <>
          <Button
            variant="outline"
            onClick={() => navigate(`/batches/${uploadError.existingBatchId}`)}
          >
            Open prior batch
          </Button>
          <Button
            variant="outline"
            onClick={async () => {
              if (!confirm(`Delete batch ${uploadError.existingBatchId}? This cannot be undone.`)) return;
              try {
                await deleteBatch(uploadError.existingBatchId!);
                toast.success(`Deleted ${uploadError.existingBatchId}`);
                setUploadError(null);
                pickFile(null);
              } catch (err) {
                toast.error(err instanceof Error ? err.message : "Delete failed");
              }
            }}
          >
            Delete prior batch
          </Button>
        </>
      ) : null}
      <Button
        variant="ghost"
        onClick={() => {
          setUploadError(null);
          pickFile(null);
        }}
      >
        Pick a different file
      </Button>
    </div>
    <details className="mt-3 text-sm">
      <summary>Show parsed claims ({uploadError.collisions.total_claims})</summary>
      <pre className="mt-2 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
        {JSON.stringify(
          (uploadError.parseResult as { summary?: unknown })?.summary ?? {},
          null,
          2,
        )}
      </pre>
    </details>
  </div>
) : null}
```

5. **Ensure `parseResult` state setter exists.** The current
   `Upload.tsx` likely stores the parsed result under a different
   name (e.g., `result`, `parsed`). Use `setParseResult` or
   rename to match. **If the existing code uses a different name,
   adapt the JSX above to use that name** — do not introduce
   parallel state.

- [ ] **Step 4: Run the new tests, expect PASS**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test -- src/pages/Upload.test.tsx
```

Expected: 6 passed.

- [ ] **Step 5: Run the broader Upload-related tests to catch regressions**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test -- src/pages/Upload.test.tsx src/hooks/useParse.test.ts
```

Expected: all pass.

- [ ] **Step 6: Commit**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add src/pages/Upload.tsx src/pages/Upload.test.tsx
git commit -m "feat(upload): 409 panel with force-insert / open / delete / pick-different actions"
```

---

### Task 3.4: Frontend typecheck and lint

The frontend touched multiple files. Make sure typecheck and lint
pass.

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

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm run typecheck
```

Expected: no errors. If there are, fix them inline (mostly
`unknown` casts on `parseResult`).

- [ ] **Step 2: Run lint**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm run lint
```

Expected: no errors. Fix any inline.

- [ ] **Step 3: Run the full vitest suite**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
npm test
```

Expected: all pass.

- [ ] **Step 4: Commit any fixes**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add src/
git commit -m "chore(frontend): typecheck and lint fixes for upload panel"
```

(Only if there were fixes; otherwise skip.)

---

## Phase 4 — End-to-end verification

### Task 4.1: Apply migration 0013 to the production database

The production DB at `/Users/openclaw/.local/share/cyclone/cyclone.db`
has the inline UNIQUE constraint. Migration 0013 (already on the
worktree) drops it. Apply it.

**This is the change that fixes the user's immediate "every upload
fails with 409" problem.** All 19 files in
`docs/prodfiles/837p-from-axiscare/` will ingest cleanly.

**Files:** none (manual step)

- [ ] **Step 1: Stop the running uvicorn**

```bash
# Find and stop the dev server.
ps aux | grep -E "uvicorn cyclone" | grep -v grep
kill <pid>
```

- [ ] **Step 2: Make a backup of the production DB**

```bash
cp ~/.local/share/cyclone/cyclone.db ~/.local/share/cyclone/cyclone.db.bak.$(date +%s)
ls -la ~/.local/share/cyclone/cyclone.db*
```

- [ ] **Step 3: Apply migration 0013**

```bash
cd /Users/openclaw/dev/cyclone
backend/.venv/bin/python3.13 -c "
import sqlite3
con = sqlite3.connect('//Users/openclaw/.local/share/cyclone/cyclone.db')
with open('backend/src/cyclone/migrations/0013_drop_claims_unique_constraint.sql') as f:
    sql = f.read()
con.executescript(sql)
con.commit()
con.close()
print('migration 0013 applied')
"
```

**Note:** this is a one-off direct DB application. The next time
the app starts, the migration runner will see `user_version >= 13`
and skip 0013.

- [ ] **Step 4: Verify the UNIQUE is gone**

```bash
backend/.venv/bin/python3.13 -c "
import sqlite3
con = sqlite3.connect('//Users/openclaw/.local/share/cyclone/cyclone.db')
for row in con.execute(\"SELECT sql FROM sqlite_master WHERE name='claims' AND type='table'\"):
    print(row[0])
"
```

Expected: the printed `CREATE TABLE claims` definition does NOT have
`UNIQUE (batch_id, patient_control_number)` at the end.

- [ ] **Step 5: Restart the dev server**

```bash
cd /Users/openclaw/dev/cyclone
backend/.venv/bin/uvicorn cyclone.api:app --host 127.0.0.1 --port 8000 --reload &
```

- [ ] **Step 6: Repro the original 409 — should now succeed**

```bash
cd /Users/openclaw/dev/cyclone
# Run a small test that uploads one of the previously-failing files.
backend/.venv/bin/python3.13 /tmp/test_repro.py 2>&1 | tail -10
```

Expected: HTTP 200, the file persists. The 19 files in
`docs/prodfiles/837p-from-axiscare/` all upload cleanly now.

- [ ] **Step 7: Re-upload the same file — should now 409 with the new body shape**

```bash
# Re-run the same test; the second upload of the same file should
# trigger the pre-flight 409 with the rich body.
backend/.venv/bin/python3.13 /tmp/test_repro.py 2>&1 | tail -30
```

Expected: HTTP 409 with `existing_batch_id` pointing to the first
batch, `collisions.colliding_claim_ids: ["t991102984o1c1d", ...]`,
and a full `parse_result` in the body.

- [ ] **Step 8: Force-insert should succeed and skip the dupes**

Manually via curl or the test script: POST with `?force=true`. Expected: HTTP 200 with `skipped_claim_ids: [...]` listing the colliding claim_ids.

- [ ] **Step 9: DELETE /api/batches/{id} works**

Manually: `curl -X DELETE http://127.0.0.1:8000/api/batches/{id}`. Expected: 204 on success, 409 if the batch has paid claims, 404 if the id doesn't exist.

- [ ] **Step 10: Commit no code (manual step)**

If the manual step revealed that the code needs tweaks (e.g., the
parser handles a 19-claim file differently from a 2-claim test
fixture), commit those tweaks:

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git add backend/
git commit -m "fix: tweaks from e2e verification against production files"
```

(Only if needed.)

---

### Task 4.2: Merge to main

The plan is complete. The user can now upload files cleanly, see
the rich 409 body, force-insert, and delete prior batches.

- [ ] **Step 1: Verify the worktree is clean**

```bash
cd /Users/openclaw/dev/cyclone/.worktrees/claims-unique-fix
git status
```

Expected: working tree clean (or only the `backend/.venv` symlink,
which is in `.gitignore`).

- [ ] **Step 2: Merge to main**

```bash
cd /Users/openclaw/dev/cyclone
git checkout main
git merge --ff-only claims-unique-fix
```

- [ ] **Step 3: Push (only if the user wants to)**

```bash
git push origin main
```

(Skip if the user wants to keep this local; ask before pushing.)

---

## Self-review checklist (run after writing the plan)

1. **Spec coverage** — every section of the spec maps to a task:
   - §1 (incl. schema-bug rationale) → Task 1.3 (migration 0014 — added after spec/plan review revealed the PK must be relaxed for the workflow to fire)
   - §3.1 No collision → covered by existing tests + the new pre-flight not firing
   - §3.2 Collision → Task 2.3 (837) + Task 2.4 (835) + Task 3.3 (Upload panel)
   - §3.3 Force-insert → Task 2.3 (837) + Task 2.4 (835) + Task 3.1 (`?force=true`) + Task 3.3 (force-insert button)
   - §3.4 Race condition → Task 2.3 + Task 2.4 (race handler)
   - §4 Within-file duplicates → Task 2.1 (`preflight_837` `within_file_duplicate_ids`) + Task 2.3 (test) + Task 3.3 (panel hides prior batch actions)
   - §5 409 body shape → Task 2.3 (`_build_dedup_409`) + Task 3.1 (frontend `CollisionSummary` type)
   - §6 DELETE endpoint → Task 2.2 (store helper) + Task 2.3 (endpoint) + Task 3.2 (`deleteBatch` client fn) + Task 3.3 (delete button)
   - §7 Backend implementation → Tasks 1.3, 1.4, 2.1, 2.2, 2.3, 2.4
   - §8 Frontend implementation → Tasks 3.1, 3.2, 3.3
   - §10 Files changed → all listed (incl. migration 0014)
   - §11 Test plan → 14 backend + 6 frontend tests distributed across tasks, plus 3 new 0014 migration tests

2. **Schema-foundation gap (caught during plan execution)** — the original plan
   assumed `claims.id` could host multiple rows for the same CLM01, but the
   schema enforces `claims.id` as a single-column PRIMARY KEY. The implementer
   caught this on Task 1.3 (the helper's "most-recent" test could not insert
   two rows with the same id). The correct fix (per user direction: "fix
   this the correct way ... take pride in this") is **migration 0014**, which
   relaxes the PK to composite `(batch_id, id)` on both `claims` and
   `remittances`, and updates all FKs that referenced the old single-column
   PK. Task 1.3 is the new first executable task in Phase 1; the original
   Task 1.3 was renumbered to 1.4.

2. **Placeholder scan** — no "TBD", "TODO", "implement later". The
   note about "Open question resolved during implementation" in the
   spec was about the audit-event FK cascade; the implementation
   just records the event with `batch_id` set, relying on the
   existing `ON DELETE CASCADE` on `activity_events.batch_id` (or
   the alternative if not present). The plan tests this in Task 2.2
   by deleting a batch and asserting the cascade works.

3. **Type consistency** — every `ApiError` construction in the
   frontend passes 5 args (`status, message, existingBatchId,
   collisions, parseResult`). The `CollisionSummary` type is
   defined once in `api.ts` and imported where needed. The
   `CollisionReport` dataclass is used uniformly in
   `dedup.py`/`api.py`/`store.py`. The `skipped_claim_ids` field
   is only on the 200 success body (not on the 409 body) and is
   only set when `force=true`.

4. **Risk acknowledged** — pre-flight race, DELETE on large batch,
   `force=true` silent skip, within-file dupes, and
   `existing_batch_id` staleness are all documented in the spec
   §13 and tested where applicable.

5. **TDD throughout** — every task is RED (failing test) → GREEN
   (minimal code) → COMMIT. No "write tests after" anywhere.

6. **DRY** — the 837 and 835 changes share `_build_dedup_409` and
   the `_compute_skipped_*` helpers. The `CollisionReport`
   dataclass and `preflight_*` functions live in `dedup.py` so the
   837/835 endpoints share the same dedup logic.

7. **Frequent commits** — every task ends with a `git commit`.
   Expectation: ~7 backend commits + 3 frontend commits = 10
   commits total.
