review_version guard is check-then-write and races #77
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Severity: HIGH
The bug
The optimistic concurrency guard is check-then-write, with the check performed in Python:
then later
photo.review_version += 1(repositories/photos.py:101-104) and a commit in theroute. Two requests can both read version 3, both pass the check, and both commit 4 — the second
silently overwriting the first. SQLite WAL serializes the writes but not the read-check-write
window.
The mechanism that exists specifically to prevent silent overwrite does not prevent it.
Two secondary gaps
POST /api/evidence/{id}/supersede(photos.py:333-355) takes noreview_versionat all andbumps nothing, despite mutating reviewer-visible state.
apply_evidence_added(services/projections.py:53-64) bumps the version only when the photowas
pending. So evidence changes on an approved or needs-review photo can never produce aconflict for a reviewer mid-decision — but
docs/circa-spec.md§11.1 lists evidence among thethings a stale write must detect.
Fix
Either map
review_versionto SQLAlchemy'sversion_id_colonPhoto(raisesStaleDataError,which maps to the 409), or use an explicit conditional update and check the row count:
Make every photo-state mutation bump the version, including supersede and all evidence
appends.
Done when
review_versionReferences
backend/app/api/routes/photos.py:75-78backend/app/repositories/photos.py:101-104backend/app/services/projections.py:53-64docs/circa-spec.md§11.1Related: #9 tests the 409 path; extend it to cover this race.
Done in
03dbc33.Photonow mapsreview_versionas SQLAlchemy'sversion_id_col, so everyUPDATEof the row carriesAND review_version = <loaded>and raisesStaleDataErrorwhen nothing matched — mapped to a 409. I verified the semantics against SQLite before building on them rather than trusting recall: two sessions loading the same version, both bumping, and the second genuinely refused with the first's write intact.Putting it on the mapper rather than in the routes is the point. The route-level
_conflict_checkstays — it is the fast path and the only one that can name the current version in the response — but it is no longer what makes the protocol safe. A future route that forgets to call it is still protected. There's a test for exactly that: a plain attribute write, no conflict check, no version bump, still refused. That's the property a convention can't give, and the same reasoning as makingcollection_ida required keyword in #70.version_id_generatorisFalsebecause the counter means "the reviewable state changed", and only the service layer knows when that's true.Evidence appends now bump regardless of prior status. This previously only advanced when the photo was
pending, so a reviewer mid-decision on an approved photo could never be told the evidentiary basis had changed underneath them — and §11.1 lists evidence among the things a stale write must detect.Supersede joined the protocol in #70 (conflict check + version bump); the chain-linking half is #80.
All five "done when" items are covered, 20 tests in
backend/tests/test_review_version.py. The headline isTestGenuineRace, which interleaves two real sessions rather than replaying a stale value — a replay only proves the Python pre-check works, which was never the broken part.One thing found along the way, worth knowing. The API client read
data.error, but FastAPI wrapsHTTPException.detail, so structured errors have been arriving one level deeper than the client looked. The visible symptom is that the conflict banner has been reporting "Current version is -1" rather than a version — i.e. the 409 path has never actually worked end to end. The client now accepts either shape so this fix is real rather than theoretical; unifying the envelope server-side is still #75.