Add optimistic-concurrency (409 review_version_conflict) tests #9

Closed
opened 2026-07-28 04:52:51 +00:00 by claude-bot · 2 comments

Context

Optimistic concurrency via review_version is the mechanism that stops two reviewers
silently overwriting each other. The plan lists silent overwrite as a top risk and asks
for 409 paths to be tested early. The UI already renders a conflict banner, but the
backend guarantee behind it is untested.

Scope

Tests proving stale writes are rejected on every mutating endpoint.

Implementation notes

  • For each of PATCH notes, PATCH flags, POST decisions, POST evidence/manual,
    and POST evidence/supersede: read a photo, mutate it through a second client, then
    submit the first client's now-stale review_version and assert 409.
  • Assert the 409 body carries the current review_version — the frontend's "Reload
    Latest" flow depends on it, so an unlabelled 409 is not enough.
  • Assert the rejected write left no trace: no history row, no projection change,
    no audit event. A 409 that partially applied would be worse than an overwrite.
  • A write at the current version succeeds and bumps the version by exactly one.
  • Two concurrent writes at the same starting version: exactly one succeeds, one 409s.
  • Confirm the conflict guard cannot be bypassed by omitting review_version entirely.

Done when

  • Every mutating endpoint rejects a stale write with 409
  • The 409 response includes the current version
  • Rejected writes are proven to have zero side effects
  • Omitting the version does not bypass the guard

References

  • backend/app/api/routes/photos.py
  • backend/app/services/projections.py
  • docs/circa-phase1-plan.md section 11.3
  • docs/circa-api-spec.md (review_version_conflict)

Depends on: the backend test harness.

## Context Optimistic concurrency via `review_version` is the mechanism that stops two reviewers silently overwriting each other. The plan lists silent overwrite as a top risk and asks for 409 paths to be tested early. The UI already renders a conflict banner, but the backend guarantee behind it is untested. ## Scope Tests proving stale writes are rejected on every mutating endpoint. ## Implementation notes - For each of `PATCH notes`, `PATCH flags`, `POST decisions`, `POST evidence/manual`, and `POST evidence/supersede`: read a photo, mutate it through a second client, then submit the first client's now-stale `review_version` and assert 409. - Assert the 409 body carries the current `review_version` — the frontend's "Reload Latest" flow depends on it, so an unlabelled 409 is not enough. - Assert the rejected write left **no** trace: no history row, no projection change, no audit event. A 409 that partially applied would be worse than an overwrite. - A write at the current version succeeds and bumps the version by exactly one. - Two concurrent writes at the same starting version: exactly one succeeds, one 409s. - Confirm the conflict guard cannot be bypassed by omitting `review_version` entirely. ## Done when - [ ] Every mutating endpoint rejects a stale write with 409 - [ ] The 409 response includes the current version - [ ] Rejected writes are proven to have zero side effects - [ ] Omitting the version does not bypass the guard ## References - `backend/app/api/routes/photos.py` - `backend/app/services/projections.py` - `docs/circa-phase1-plan.md` section 11.3 - `docs/circa-api-spec.md` (`review_version_conflict`) Depends on: the backend test harness.
claude-bot added this to the v0.2.0 milestone 2026-07-28 04:52:51 +00:00
Author

Amended by the audit of 2026-07-28.

These tests as written would pass against a broken implementation. The review_version guard
is check-then-write with the comparison in Python (photos.py:75-78), then a separate increment
and commit — so two requests can both read version 3, both pass the check, and both commit 4.

A stale-value replay (read, mutate elsewhere, submit old version) catches the easy case. Add a
genuine two-session interleaving test that exercises the race window.

The enforcement fix is #77. Also note #80: supersede_evidence takes no review_version at all
and bumps nothing, so it currently sits outside the concurrency protocol entirely.

**Amended by the audit of 2026-07-28.** These tests as written would pass against a **broken** implementation. The `review_version` guard is check-then-write with the comparison in Python (`photos.py:75-78`), then a separate increment and commit — so two requests can both read version 3, both pass the check, and both commit 4. A stale-value replay (read, mutate elsewhere, submit old version) catches the easy case. Add a genuine **two-session interleaving** test that exercises the race window. The enforcement fix is #77. Also note #80: `supersede_evidence` takes no `review_version` at all and bumps nothing, so it currently sits outside the concurrency protocol entirely.
Author

Done in c396bb6. backend/tests/test_concurrency_protocol.py, 34 cases.

What was already there vs. what this adds

#77 landed the enforcement and test_review_version.py proves the mechanism — including the genuine two-session interleaving the audit comment above asked for. What was missing is the question this issue actually asks: does every write use it, and does a refused write really change nothing.

Both are parametrized over one list of the five versioned endpoints, because "every mutating endpoint" is a claim about a set, and a set spelled out by hand is only ever true of the routes someone remembered.

Per endpoint — notes, flags, decisions, manual evidence, supersede

Stale version 409
The 409 names current_version on all five — "Reload Latest" reads that field, and a 409 without it is a dead end (what #75 surfaced as "Current version is -1")
Version omitted 422, not a bypass
Current version 200, and the version advances by exactly one
Two writes at the same starting version exactly one succeeds, one 409s

"Exactly one" matters in both directions: advancing by two would make a concurrent reader stale for no reason, advancing by none would leave the next stale write undetectable.

A rejected write leaves no trace

Evidence, decision, note-revision and audit-event counts plus every projection field — status, notes, effective date range, precision, current decision, flags — compared before and after the 409. A 409 that partially applied would be worse than the overwrite it exists to prevent: the reviewer is told their edit was refused, and it was not. The ledger matters most, because its triggers refuse deletion (#67), so an event written by a rejected write could never be removed afterwards.

Supersede gets its own assertion for is_active, because that is not a counted row: a supersede that took effect and then 409'd leaves every count identical and the evidence silently retired — and retired rows are still rendered, at 40% opacity, so it is easy to miss.

TestNoWriteEscapesTheProtocol

The part that keeps the above true. Every write route in ROUTE_ACCESS (from #8) must either carry review_version or appear in an exemption list with a reason:

  • POST /api/auth/logout — ends the caller's own session, touches no photo
  • POST /api/ingest — creates a photo; there is no prior version to be stale against
  • comments (POST, DELETE) — append-only side channel, carries no date, feeds nothing into projections.fold; two reviewers commenting at once is a conversation, not a conflict
  • ai-rerun / ocr-rerun — enqueue only; the evidence they produce goes through the normal append path, which does bump
  • user admin routes — a real concurrency question, but not a photo-shaped one; #73's last-admin guard covers it

#80 is exactly what the absence of this check looks like: supersede_evidence mutated a photo's evidentiary basis and sat outside the protocol entirely, for as long as it existed, because nothing anywhere stated that such a write had to participate.

Verified load-bearing

Disabling _conflict_check fails 20 of the 34 cases. The audit's warning — that these tests as written would pass against a broken implementation — was about the stale-value replay specifically; that remains true of the replay, which is why the interleaved case stays in TestGenuineRace and this file says so where the arrival-order test could be mistaken for it.

Done when

  • Every mutating endpoint rejects a stale write with 409
  • The 409 response includes the current version
  • Rejected writes are proven to have zero side effects
  • Omitting the version does not bypass the guard

957 passed, 8 skipped; ruff clean; CI green.

Done in c396bb6. `backend/tests/test_concurrency_protocol.py`, 34 cases. ## What was already there vs. what this adds #77 landed the enforcement and `test_review_version.py` proves the *mechanism* — including the genuine two-session interleaving the audit comment above asked for. What was missing is the question this issue actually asks: does **every** write use it, and does a refused write really change nothing. Both are parametrized over one list of the five versioned endpoints, because "every mutating endpoint" is a claim about a set, and a set spelled out by hand is only ever true of the routes someone remembered. ## Per endpoint — notes, flags, decisions, manual evidence, supersede | | | |---|---| | Stale version | 409 | | The 409 names `current_version` | on all five — "Reload Latest" reads that field, and a 409 without it is a dead end (what #75 surfaced as **"Current version is -1"**) | | Version omitted | 422, not a bypass | | Current version | 200, and the version advances by **exactly** one | | Two writes at the same starting version | exactly one succeeds, one 409s | "Exactly one" matters in both directions: advancing by two would make a concurrent reader stale for no reason, advancing by none would leave the next stale write undetectable. ## A rejected write leaves no trace Evidence, decision, note-revision and audit-event counts plus every projection field — status, notes, effective date range, precision, current decision, flags — compared before and after the 409. A 409 that partially applied would be worse than the overwrite it exists to prevent: the reviewer is told their edit was refused, and it was not. The ledger matters most, because its triggers refuse deletion (#67), so an event written by a rejected write could never be removed afterwards. Supersede gets its own assertion for `is_active`, because that is not a counted row: a supersede that took effect and then 409'd leaves every count identical and the evidence silently retired — and retired rows are still rendered, at 40% opacity, so it is easy to miss. ## `TestNoWriteEscapesTheProtocol` The part that keeps the above true. Every write route in `ROUTE_ACCESS` (from #8) must either carry `review_version` or appear in an exemption list with a reason: - `POST /api/auth/logout` — ends the caller's own session, touches no photo - `POST /api/ingest` — creates a photo; there is no prior version to be stale against - comments (`POST`, `DELETE`) — append-only side channel, carries no date, feeds nothing into `projections.fold`; two reviewers commenting at once is a conversation, not a conflict - `ai-rerun` / `ocr-rerun` — enqueue only; the evidence they produce goes through the normal append path, which does bump - user admin routes — a real concurrency question, but not a photo-shaped one; #73's last-admin guard covers it **#80 is exactly what the absence of this check looks like**: `supersede_evidence` mutated a photo's evidentiary basis and sat outside the protocol entirely, for as long as it existed, because nothing anywhere stated that such a write had to participate. ## Verified load-bearing Disabling `_conflict_check` fails **20 of the 34** cases. The audit's warning — that these tests as written would pass against a broken implementation — was about the stale-value replay specifically; that remains true of the replay, which is why the interleaved case stays in `TestGenuineRace` and this file says so where the arrival-order test could be mistaken for it. ## Done when - [x] Every mutating endpoint rejects a stale write with 409 - [x] The 409 response includes the current version - [x] Rejected writes are proven to have zero side effects - [x] Omitting the version does not bypass the guard **957 passed, 8 skipped**; ruff clean; CI green.
Sign in to join this conversation.
No description provided.