Flow
Order of checks on one save
11. Seen this key?
Same key and same request gets the stored answer. Same key with a different request is an error.
22. Lock, then ask again
Two copies of one save cannot both get past this point.
33. Board version
An edit built on an old board is refused with the current version.
44. Load the week
Too many shifts to load means a refusal, not a partial check.
55. Plan, then write
Changes run on a copy, the finished week is checked, then everything is saved together.
Two people work the same morning, and the manager wants them to trade. Done as two separate saves, the first one gives shift A to the second person, and for a moment they're booked into the same morning twice. The end result is fine. The middle step is junk, and a check that runs between the two saves is judging a schedule nobody wanted.
My scheduling app used to save one shift per request. So every check could only look at the schedule one step at a time, and swaps, rotations and moving a whole crew all go through bad steps on the way to a good result.
This week I replaced that with one save per schedule. The browser sends a change set, which is a short list of adds, edits and deletes for one schedule. The server works out the finished week and checks that.
The planner builds the week first
The server copies the saved shifts and applies every change to the copy. Only then does it check the result. Does each shift end after it starts and stay inside the schedule's dates? Is anyone booked twice in this schedule? Is anyone booked against a shift they already have in another schedule? If anything fails, nothing is saved, and the error usually says which change caused it.
Because this step never touches the database, it's easy to test. My tests check that the swap passes, that a real overlap fails, that breaks move with their shift, and that someone who isn't an active, schedulable person can't be assigned.
A few rules are about the request itself. Each shift can appear only once. A save that changes nothing is refused. Moving a shift moves its breaks too, and if a break would land outside the shift, the save fails.
The database still double-checks
All of it happens in one transaction with the schedule locked. If the server would have to load too many shifts, it refuses instead of checking only part of the week. The database has its own no-overlap rule, set to check only at the end, so it doesn't trip on the middle steps. Old overlapping shifts had to be cleaned up first, and that cleanup only unassigned people. It never deleted anything.
Replay first, then check freshness
Each save carries two things: the version of the board the manager loaded, and a one-time key. The version answers "is this edit based on the latest board?" The key answers "have I already done this exact save?"
Order matters when the response gets lost. The save worked, so the version moved on. The browser retries with the same key and the old version. If the server checked the version first, it would tell the manager to reload, and they'd find their own change already there. So the key goes first.
Look up the key first, and if it has been seen, return the answer that was stored.
If the key was used before with a different request, that's an error. The key is checked again after the server takes its lock, so two copies of the same save can't both slip through. My tests replay a saved answer even when the version sent is far newer.
The role labels I mangled
My first version turned custom role names into all caps, and the browser sent every field on every edit. So changing just a shift's end time could change someone's typed role. Same day, I made the server only trim spaces and the browser send only what changed.
Two saves at the same instant
Nothing tests two identical saves landing at once yet. And the board still saves one shift at a time, so the swap works in a test, not in the app.