Skip to content

Every mutating path carries a WRITER TOKEN, checked in the transaction that writes ​

Two instances of one indexer writing to one state store corrupted it, and only one mutating path would have noticed: applyBlock reads the block record inside the transaction it writes in, which is a genuine compare-and-swap, while writeCursor, clearCursor, revertTo and prune had no precondition of any kind. That is not an exotic deployment -- a user with the app open in two tabs has it, a backgrounded tab resuming with a stale cursor has it, and an app following the documented createState example puts two generations of one indexer into one database inside a single tab. The failure that matters is the quiet one: a cursor moved BACKWARDS by a writer holding a stale LastSync leaves a state that is internally consistent, reproducible on reload, and wrong.

We decided: a writer CLAIMS the store on its first mutation, and every mutation it makes is checked against that claim inside the same atomic unit as the write. A second writer's first mutation claims in turn, which invalidates the first claim, so the earlier writer's next mutation on any guarded path is refused whole with StoreWriterChangedError -- not partially applied, not applied late, not applied to a state it did not read. The guarded surface is seven verbs: applyBlock, revertTo, writeCursor, clearCursor, prune, plus applyBlocks and drop on @etherfold/state-store-sqlite. migrate is deliberately excluded and must not claim, because it runs on every open and several tabs of one app all open.

This is ADR-0054 one level down, not a second concurrency mechanism, and saying so is half the point of writing this down. ADR-0054 guards a registry commit over RemoteSQL on a revision token because that seam is prepare plus batch and cannot read, run JS and write inside one transaction. Its opening line also records the primitive this rests on: "IndexedDB gives that for free (readwrite transactions serialise across tabs)". So the two substrates get the same mechanism with different sharpness. On IndexedDB the check and the write sit in ONE serialisable transaction, so the fencing is EXACT rather than best-effort -- a stale writer's mutation lands never, not almost never, and no timing assumption is made anywhere. On SQLite it is ADR-0054's shape unchanged: every statement is guarded on the token, and the same batch reads the token back, because remote-sql reports rows and no affected-row count so a read-back is the only evidence a writer has. A loser's statements each match zero rows and its whole batch applies to nothing.

The token is scoped to one unit of STORAGE, and that needs no new concept because the token lives inside the storage it guards. On IndexedDB that is the databaseName; on SQLite it is the database plus ADR-0053's table namespace, which puts _writer beside _blocks and _cursor in the namespace. Two unrelated indexers on one origin therefore never contend, two generations of one indexer addressed apart both write at once, and two generations sharing one storage by misconfiguration are REFUSED where they used to corrupt each other silently. Anyone tempted to scope the claim to an origin, a tab, a connection or a lock name OUTSIDE the database breaks the first two of those; the conformance suite asserts the do-not-over-refuse half as hard as the refusal.

Not every backend can hold a meaningful guard, so a store REPORTS whether it enforces one (StateStoreCapabilities.singleWriter). MemoryStateStore and @etherfold/state-store-patch keep their storage in instance fields, so no second writer can reach it and a token there could only ever be compared with itself: green, tested and meaningless, which is exactly the shape ADR-0054 refuses. They report false honestly, and the conformance suite selects the contention cases on the claim -- the same claim-driven mechanism the retention chapters already use.

Considered Options ​

  • An OPTIONAL token argument on each mutating method. Rejected on ADR-0054's own ground: a guard that is available and unused passes every single-writer test, and the state it fails to protect is invisible afterwards. It also puts the obligation on every caller, including callers that do not exist yet.
  • A REQUIRED token argument on each method. The honest fallback if the blast radius had priced the implicit claim out, and it is written down so it is not re-proposed as an improvement: it forces PRESENCE but not PROVENANCE. A caller can always read the current token and pass it back, which is a read-then-write that merely looks atomic, so the guarantee would rest on caller discipline rather than on the store. The blast radius turned out to be nil -- claiming implicitly on the first write changed no caller at all.
  • Leader election (navigator.locks, a SharedWorker, a BroadcastChannel lease). Not an alternative but a complement, and deliberately sequenced AFTER this: a leader can be alive but asleep (a background tab's timers are throttled hard and frozen after minutes, so a heartbeat cannot tell a healthy leader from a comatose one), any lease handoff has a window where the previous holder has not noticed, and some runtimes lack the primitive. With this guard in place, election demotes from a correctness requirement to an optimisation that stops wasted RPC calls.
  • A lock row taken and released around each mutation. Rejected exactly as ADR-0054 rejects it: a crashed holder leaves the store wedged until something times the lock out, which is a second mechanism with a second failure mode. Claiming here does not block and does not expire, so a writer killed mid-block leaves a store the next claim simply takes over, with no manual clear and no waiting.
  • A monotonic revision instead of an opaque token. Rejected on the read-back, per ADR-0054: under a counter a loser reads expected + 1, which is the winner's value and the very number the loser was about to write, so it cannot tell a win from a loss. Nothing here compares two tokens for order or parses one.
  • Guarding on the CURSOR value (refusing a write that moves the position backwards). Rejected because it would make the store parse what a cursor MEANS, which ADR-0027 exists to prevent, and it is not even the right question: a reorg legitimately rewinds a cursor. The guard asks WHO is writing, never WHAT.

Consequences ​

  • A refused writer stays refused. The claim is never re-minted after one has committed, because a writer that could silently re-claim would let two writers take the store in turns -- which is the corruption, at half speed. Reads are not guarded at all, so a losing tab keeps showing correct data; turning the refusal into a demotion to reader is the caller's, and is specified separately.
  • applyBlocks is many batches, so the guard is checked PER BATCH. A refusal mid-sequence leaves the batches that already committed applied and every later one unwritten. Stated here and asserted, because it is the one place the promise is not "all or nothing" and a caller has to know that recovery is a re-fold.
  • prune widened. On IndexedDB the tip is now read inside the readwrite transaction that deletes against it, and that transaction spans the versions AND the blocks store; on SQLite the tip read rides the claim batch. The floor is never computed against a tip another writer has moved.
  • drop (SQLite) is guarded differently, because DDL takes no WHERE. The claim's RELEASE is a guarded DELETE paired with the read-back in one batch, which is an ordinary compare-and-swap over the question drop actually asks; the drops follow it. A writer that lost the store drops nothing. What no DDL can give is the drops being in that same transaction: a rival claiming in the gap is dropped out from under, which is inherent to disposing of a generation while something writes to it.
  • The tightest hosted backend's parameter cap moved by one. dropVersionsStatement was the one statement whose parameter count scales with maxRowsPerStatement, and it carried no other bound parameter, so the default sat exactly on D1's cap of 100. The guard is now that other parameter, so a prune names one FEWER row id per statement and the query still fits. A prune round also costs one more query (the read-back), which the Worker adapter's per-invocation budget accounts for.
  • The IndexedDB schema version moved to 3 for the new writer object store. It is contains-guarded like every step before it, so an existing database gains the store and keeps every row it had.
  • The conformance suite grew an OPTIONAL second-handle affordance (twoWriters), because StateStoreFactory is documented as a fresh database per call and cannot express two handles on one storage. A backend that CLAIMS singleWriter and supplies no affordance fails a case saying so, rather than silently skipping the only cases that could catch a fiction.
  • The mechanism is testable without timing. A test wraps the RemoteSQL handle and lands a rival's committed write in the exact window between the tip read and the delete it decided (ADR-0054's shape); removing the guard turns six of that file's eight cases red. On IndexedDB there is no window to land in, which is the stronger claim, so the test starts two writers in the same tick and asserts that exactly one may go on writing.