[Backend] Add optimistic concurrency so concurrent edits stop silently clobbering each other #403
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
Found in the August 2026 session lifecycle review (#319).
Every mutable row in Quest Board (sessions, notes, lore entries, beat notes) is written last-write-wins with no version check of any kind, so two people — or a person and a background job — editing the same thing at nearly the same time results in one of them silently vanishing with no error, no merge, and no way to tell it happened. The most concrete, in-practice case is a web page loading a private note once at page-load and later saving a full replacement over whatever a player appended via the Discord
/notecommand mid-session — destroying live, in-the-moment notes players type from their phones while the GM has the web page sitting open in a background tab.Evidence
Session.updated_at(present on the model since the #107 migration) or any comparable version token — confirmed by inspection ofSessionResponse/SessionListItem— so no client can perform a compare-and-swap even if it wanted to.webapp/backend/app/services/session_note_service.py:44-46(upsert_note) doesnote.content = content— a full replace, not an append or merge — whilewebapp/backend/app/routers/bot.py:846shows the bot's own/notehandler correctly appends server-side (existing.content = existing.content + "\n" + data.note).webapp/frontend/src/pages/SessionDetail.jsx:502-505loads the note content once into local state at page mount; a subsequent "Save" click from that stale state calls the replace-basedPUT /my-note, discarding anything appended via Discord in between.webapp/backend/app/services/session_service.py:303-317(update_session) — a full-field update from whichever caller commits last wins; concrete collision case is two GMs editing the transcript/summary simultaneously, or a GM editing whileprocess_audiofinishes concurrently.webapp/backend/app/services/shelf_service.py:486-516(harvest_notes_on_complete) does a read-modify-append ofLoreEntry.gm_notesin Python, racing againstwebapp/frontend/src/pages/WikiArticle.jsx:727's full-replacegm_notessave — whoever commits second wins (this one is at least hand-recoverable viaLoreEntryVersion, unlike the others).webapp/backend/app/routers/campaigns.py:4498-4520(update_beat_notes) is a full-replace write with no precondition, so two people (or two browser tabs) editing beat notes for the same session clobber each other the same way.Failure scenario
A player opens the session page on their laptop at the start of a session (loading their private note once). Mid-session, they type
/note Found a hidden lever in the crypton their phone via Discord — the bot correctly appends it server-side. At the end of the night, they switch back to the laptop tab, which still shows the pre-session note content, and click "Save" out of habit. ThePUT /my-notereplaces the note with the stale laptop-side content, and every Discord-captured note from that session is gone — with no error, no conflict warning, nothing.Proposed fix
Expose
updated_aton the relevant response schemas (session, note, storyline, beat notes) and require anIf-Unmodified-Since-style precondition (or a simple version integer) on their corresponding write endpoints, rejecting a write whose base version is stale with a 409 the frontend can turn into "someone else changed this, reload?" For the note case specifically, the cheaper and more correct fix is to change the web UI's private-note save into either an append (matching the bot's own behaviour) or a merge-on-server rather than a blind replace — that alone eliminates the worst concrete instance without needing full conditional-write plumbing everywhere else.Acceptance criteria
Session.updated_at(and equivalents for notes/storyline/beat notes) is exposed on the relevant response schemas.