fix(api): conditional writes so concurrent edits stop clobbering each other (#403) #450
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/403-conditional-writes"
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?
Closes #403 (HIGH).
The defect
Every mutable row was written last-write-wins with no version check of any kind. Two writers moments apart — two people, or a person and a background job — meant one edit simply vanished. No error, no merge, nothing in a log.
The scenario the issue is built around: a player opens a session on a laptop (which loads the private note once at mount), types notes from their phone all evening via the Discord
/notecommand — which correctly appends server-side — then clicks Save on the laptop out of habit.PUT /my-noteis a full replacement, so the evening's notes are replaced with what the page loaded hours earlier, and the response is a cheerful 200.A deliberate departure from the proposed fix
The issue suggests making the note editor append or merge instead of replace. I implemented a conditional write instead.
Appending is wrong for a full-text editor: a GM correcting a typo would get their entire note duplicated rather than fixed. That trades silent loss for silent duplication, which is not an improvement — it is the same class of bug wearing a different hat. Refusing the stale write loses nothing in either direction, and the UI never discards what the user typed: only an explicit Reload replaces the draft, and only when they ask for it.
All four acceptance criteria are still met.
What ships
expected_updated_aton three write paths — the note upsert,PATCH /sessions/{id}(summary/transcript/title), and the beat-notes PATCH. A mismatch is a 409 carrying a message written for a human; the frontend shows it inline next to the thing that conflicted, with a Reload control.Session.updated_atis now exposed onSessionResponse— it has existed on the model since #107 and was never sent to anyone, so no client could compare-and-swap even if it wanted to.The token is optional by design. The bot's append path is already correct and needs none, older clients keep working, and requiring it would be a breaking API change for a guarantee that is purely additive. The protection is real because the clients that do full replacements are exactly the ones that send it.
Two things the tests forced out
The guard did not work at first.
Session.updated_atrelies ononupdate=func.now(), and Postgresnow()is transaction start time — so two writes inside one transaction get identical timestamps and the token stops distinguishing them. This codebase already hit that exact trap in migrationd5e6f7a8b0c1, which switchedcreated_attoclock_timestamp()for the same reason. The write paths now stamp explicitly from the wall clock, which is whatupsert_notealways did.SessionListItemnow carriesupdated_attoo. Without it, an editor working from a session list had to make a second round trip just to learn the version it was editing — and could save unconditionally in the window before that trip returned. Adding it to the list schema removes both the fetch and the gap.Verification
Mutation-checked in two independent places:
updated_atstamp reverted, guard intact: the stale-summary test fails on its own.Also asserted directly: a naive datetime from a client is read as UTC rather than raising (a concurrency guard that 500s is worse than none), and a one-microsecond difference is still a conflict.
The 409 branch keys on
e.status, which I checked against the realApiErrorclass rather than trusting the test's mock — it does carry the HTTP status, so the conflict UI is reachable in production and not just in tests.1,450 backend tests pass (was 1,439). 452 frontend (was 449). Lint clean at CI's pinned ruff 0.4.4; eslint clean on every touched file.
Not done here
workbenchTools.jsx's AI-tool beat-notes append/replace actions still write unconditionally. They are a deliberate "apply this generated text" action rather than a stale-editor save, and they have no natural reload UX, so they take the documented optional-token path. Worth revisiting if it ever bites, but expanding scope to it now would be speculative.Noted while in there and not fixed, as it is unrelated to this issue:
CampaignPlanning.jsxreadsbeat_notesoff session list items, which have never carried that field — so the beat-notes editor appears to open empty regardless of what is stored. Happy to file it separately.🤖 Generated with Claude Code