[Backend] Add optimistic concurrency so concurrent edits stop silently clobbering each other #403

Closed
opened 2026-08-25 20:44:42 +00:00 by claude-bot · 0 comments
Contributor

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 /note command 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

  • No response schema exposes Session.updated_at (present on the model since the #107 migration) or any comparable version token — confirmed by inspection of SessionResponse/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) does note.content = content — a full replace, not an append or merge — while webapp/backend/app/routers/bot.py:846 shows the bot's own /note handler correctly appends server-side (existing.content = existing.content + "\n" + data.note). webapp/frontend/src/pages/SessionDetail.jsx:502-505 loads the note content once into local state at page mount; a subsequent "Save" click from that stale state calls the replace-based PUT /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 while process_audio finishes concurrently.
  • webapp/backend/app/services/shelf_service.py:486-516 (harvest_notes_on_complete) does a read-modify-append of LoreEntry.gm_notes in Python, racing against webapp/frontend/src/pages/WikiArticle.jsx:727's full-replace gm_notes save — whoever commits second wins (this one is at least hand-recoverable via LoreEntryVersion, 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 crypt on 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. The PUT /my-note replaces 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_at on the relevant response schemas (session, note, storyline, beat notes) and require an If-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.
  • The web private-note editor no longer replaces the full note content; it appends or merges instead of clobbering Discord-captured content.
  • At least one further high-traffic write path (session summary/transcript PATCH, beat-notes PUT) rejects a write against a stale version rather than silently overwriting.
  • Regression test: append a note via the bot's code path, then save from a web session opened before the append, and assert the appended content survives.
**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 `/note` command 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** - No response schema exposes `Session.updated_at` (present on the model since the #107 migration) or any comparable version token — confirmed by inspection of `SessionResponse`/`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`) does `note.content = content` — a full replace, not an append or merge — while `webapp/backend/app/routers/bot.py:846` shows the bot's own `/note` handler correctly appends server-side (`existing.content = existing.content + "\n" + data.note`). `webapp/frontend/src/pages/SessionDetail.jsx:502-505` loads the note content once into local state at page mount; a subsequent "Save" click from that stale state calls the replace-based `PUT /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 while `process_audio` finishes concurrently. - `webapp/backend/app/services/shelf_service.py:486-516` (`harvest_notes_on_complete`) does a read-modify-append of `LoreEntry.gm_notes` in Python, racing against `webapp/frontend/src/pages/WikiArticle.jsx:727`'s full-replace `gm_notes` save — whoever commits second wins (this one is at least hand-recoverable via `LoreEntryVersion`, 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 crypt` on 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. The `PUT /my-note` replaces 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_at` on the relevant response schemas (session, note, storyline, beat notes) and require an `If-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. - [ ] The web private-note editor no longer replaces the full note content; it appends or merges instead of clobbering Discord-captured content. - [ ] At least one further high-traffic write path (session summary/transcript PATCH, beat-notes PUT) rejects a write against a stale version rather than silently overwriting. - [ ] Regression test: append a note via the bot's code path, then save from a web session opened before the append, and assert the appended content survives.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Quest-Board#403
No description provided.