Two screens, one set
Dad reviews candidates wherever he happens to be. The web studio on a Mac and the iPhone and iPad app are the same WebUI: the app hosts it in WebKit and talks to the same engine. The architecture puts it plainly: selections are server-owned, they carry an optimistic revision, and web and iOS share saved choices. So picture the race. A batch review is open on both screens. Dad picks a candidate on the iPad. Later he turns to the Mac, where the page has been open for an hour, and picks a different candidate there.
A number that moves on every write
Every row in records has a version column that starts at 1. Every update bumps it in the same statement that writes the new body. A client that reads a record also reads its version, and sends that number back with its change. Store.update compares. If the stored version is not the one the client read, someone else wrote in between, and the store refuses with a 409: This record changed. Refresh before saving again.
This is optimistic concurrency. No lock is held while Dad looks at a screen, which may be for an hour. A lost race costs one refusal, and only the write that lost pays it.
Why BEGIN IMMEDIATE matters
The comparison and the write have to be one step. Studio.io hands every store call to asyncio.to_thread, so two choices can reach the store at the same moment on two threads. update opens with BEGIN IMMEDIATE, which takes SQLite's write lock before it reads anything. The second thread waits, within the connection's 10-second timeout, then reads the version the first one just wrote, and fails the comparison.
Leave that line out and the guard leaks. Python's sqlite3 module, in its default mode, opens a transaction only before a statement that writes, never before a SELECT. Both threads could read version 7, and both updates would land, because the UPDATE itself matches on the id alone.
Every editable record carries its version
| What Dad edits | Route | Version |
|---|---|---|
| a set's selection | PATCH /api/sets/{id}/selection | required |
| an asset's label or archive flag | PATCH /api/assets/{id} | required |
| a set's or a batch's archive flag | PATCH /api/sets/{id}, PATCH /api/batches/{id} | required |
| a reference profile, a workflow template | PUT /api/souls/{id}/references, PUT /api/templates/{workflow} | left out only for the first save |
The last row covers a second race: two screens creating the first profile for the same soul. The save takes the studio's intent lock and looks for an existing profile. A request without a version is a claim that nothing is saved yet. If something is saved after all, the claim is wrong and the save is refused with Reference settings changed. Refresh first. If nothing is saved, a request that carries a version is just as wrong, and gets the same refusal. Templates follow the same rule.
A few writes carry no version: the worker moving its own tasks through their states, and a publication completing. The second one reaches every open screen. Completing a publication writes the set's new published map and live revision, which bumps the set's version, so a screen still holding the old number gets a 409 on its next choice and reloads.
The server guards its own merge
select reads the set, checks every chosen candidate (the right soul, and for a generated one, a finished task for that frame of this set), merges the new choices into the existing selection, and writes with expected=body.version. Between that read and the write there are calls to Pippa's registry. If another write lands in that window, the version has moved, and the merge built on the old read is refused. One number protects both the client's view and the server's own read, merge and write.
What the screen does with a 409
The Batches page sends the version of the set it holds. If the call fails, it shows the error, and if the message says the record changed, it reloads the sets so the next click carries the new version. It does not resend the choice. I think that is right: the iPad's choice might be the one Dad wanted, and he should see it before he overrides it.
One honest weakness. The page recognises a conflict by the word changed in the message, not by the 409 status. The conflict message on this path contains that word today. A rewording on the server would quietly stop the reload, and a status check would not care.
What a selection means, and why choosing is never publishing, is the opening lesson of Publishing Is One Way.