Files
cyclone/docs/superpowers/plans/2026-06-21-cyclone-parse-decide-workflow.md
T
Tyler 7290cac643 plan+spec: add migration 0014 to relax PKs; renumber plan Task 1.3 -> 1.4
The implementer caught that claims.id is a single-column PK, which
prevents the same CLM01 from existing in multiple batches. That
makes the spec's pre-flight 409 workflow unreachable and resubmits
impossible. Migration 0014 relaxes the PKs to composite (batch_id,
id) on both claims and remittances, and updates all FKs that
referenced the old single-column PK. Task 1.3 in the plan is now
the migration; the previous Task 1.3 (helper tightening) is
renumbered to Task 1.4 and will run after 0014 lands.
2026-06-21 18:09:06 -06:00

2958 lines
106 KiB
Markdown

# 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(...),)`.
- [ ] **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. **Do not modify production code to make old tests pass**; update the tests.
- [ ] **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.