Skip to content

The storage seam splits AT THE INTERFACE, and the claim is taken by CONSTRUCTING a writer ​

ADR-0075 made every mutating path on a StateStore carry a writer token, checked inside the transaction that writes, so that a second writer's mutation lands never. That closed the corruption and left the rule as something a caller still has to REMEMBER: any value typed StateStore can mutate, so "a reader does not write" was a convention, and the claim was implicit enough that no caller could see it happen. We decided: the seam SPLITS into a readable shape and a writable one, the split is AT THE INTERFACE with the concrete backend classes unchanged, and the writable shape is obtained by CONSTRUCTING a writer over a store (openForWriting) rather than by leasing one. This is for state exactly the move ADR-0044 makes for streams, where a follower is handed a read-only stream view (readOnlyStream) rather than asked to behave: the one-writer rule becomes structural.

The split is at the INTERFACE, and that is what keeps the change small. A concrete backend implements the whole surface, reads and mutations alike, and always will -- it IS the storage. What moves is what a CONSUMER holds. That distinction is load-bearing rather than tidy: the mutating call sites in this repository are overwhelmingly tests that construct a backend concretely (new VersionedStateStore(...), over 150 mutating call sites in the SQLite suite alone) and then mutate it, and those hold the CLASS type, so a change to the seam does not reach them. Only code holding a StateStore-typed value migrates, and it migrates in its own tasks: the base type keeps its mutating members for now, so nothing breaks on anybody else's schedule, and the contract task deletes them and makes StateStore the readable shape.

WritableStateStore carries a token, and its job is to make the type UNFORGEABLE. Without it the writable shape would be structurally identical to the readable-plus-mutations shape, so any store would satisfy it and "you may write because you claimed" would be a comment rather than a compile error. It is deliberately NOT the token the backend keeps in its storage. That value stays private, because a token a caller can READ is a token a caller can hand BACK, which is the required-token-argument shape ADR-0075 rejected: provenance would rest on caller discipline, which is a read-then-write that merely looks atomic. The seam's token names the claim and is compared with nothing.

Claiming is CONSTRUCTION, not a lease, and three questions then stop existing. migrate writes, so a lease over an already-open store would have a write outside the guard on day one; bootstrap writes too and is simply a writer here; and a demoted writer re-opens, which forces the re-read correctness wants anyway, so "what does a dead lease do mid-fold" never needs an answer. There is no expiry to tune and no held state a crash can strand: a writer killed mid-block leaves a store the next claim simply takes over, which is the property ADR-0075 already chose over a lock row.

openForWriting is IDEMPOTENT per store INSTANCE, and this is not a convenience. The shipped generation pattern hands ONE store instance to EVERY generation (createState: () => store in the @etherfold/browser suites, and the documented IndexerState.ts example ignores its GenerationContext so every generation lands in the default database). If each generation claimed independently, building a successor would invalidate the canonical generation and the guard would refuse the process against ITSELF, breaking the two stories the spec has about generations writing concurrently. So one storage means one claim: the host factory that builds a store claims, everything downstream RECEIVES the handle, and a second open on one instance returns the claim the first one made.

The claim is taken by clearing a cursor key nothing ever writes (WRITER_CLAIM_KEY). The concrete classes are unchanged, so there is no claim() verb to call, and the seam had to provide the act out of the surface every backend already implements. clearCursor on an absent key is the one seam verb that is a guaranteed no-op ("Forget a cursor. A no-op where none was written") while still being a MUTATION, so a backend that enforces a single writer swaps its stored token there and touches no byte a caller can observe. That makes "clearCursor claims even when it deletes nothing" a contract rather than an accident, which is why the conformance chapter a writer claims by opening asserts it against every backend that claims singleWriter: removing the clear turns six of its cases red.

Considered Options ​

  • Add a claim() verb to the seam and implement it on every backend. The obvious shape, and rejected because it is the expensive one: it changes four backend classes and their suites in the same change that introduces the types, which collapses the migration into one unreviewable diff. The no-op clear buys the same act through a verb every backend already has, and its cost is one documented reserved key.
  • BRAND the store instance with the token instead of wrapping it (Object.defineProperty(store, 'token', ...), or a Proxy). Strictly better on paper -- it preserves object identity and every backend-specific method (queryCurrent, applyBlocks, drop, bootstrap) -- and it is unsafe here for a blunt reason worth recording so nobody re-proposes it: both guarded backends keep their own claim in a field named token, so branding would overwrite the very value the guard compares, and a Proxy intercepting token would feed the backend its own private field back. The wrapper keeps the two tokens apart, at the cost of hiding a backend's own surface behind the seam; applyBlocks is declared OPTIONAL on the writable shape for exactly that reason, since backfill by round trip is the one such method a seam-typed caller has a reason to reach.
  • A LEASE over an already-open store (store.claim() returning a revocable handle). Rejected above: migrate and bootstrap both write, so the guard would have holes on day one, and a revoked lease mid-fold is a state nothing has a good answer for.
  • Make openForReading SWALLOW writes, the way readOnlyStream does. Rejected because the reason readOnlyStream does it does not apply here. That seam's save is driven by the indexing loop, so a follower has nowhere to NOT call it from and the no-op is the only expressible read-only. Here the caller holds the handle and decides every call, so a swallowed write would be a mutation that looked like it worked -- the silent class this whole spec exists to remove. openForReading returns the store it was handed, narrowed, and the type is the whole guard.
  • Claim LAZILY, on the writer's first mutation, and let openForWriting be a pure type act. This is what ADR-0075 already does and it would have made the new function free. Rejected because it leaves the displacement invisible: an earlier writer would keep writing successfully until the new one happened to write, so "I have taken this store" and "I have started using it" would be different moments, and a host that opened a writer in order to become the writer would not have become one. Claiming at open costs one no-op transaction and makes the refusal exact.

Consequences ​

  • There are three seam type names during the migration and two afterwards. ReadableStateStore is the shape a consumer holds to read, WritableStateStore is what a claim hands back, and StateStore is what a BACKEND implements -- today the union of both halves, so every existing consumer compiles unchanged. When the contract task lands, the mutating members leave StateStore and ReadableStateStore is folded into it.
  • The conformance suite is parameterised over the two SHAPES, so every factory-driven chapter is asked twice: once of the store as the backend hands it over, once of the handle openForWriting returns. A handle that delegated one verb wrongly would be a store that behaves differently depending on how its holder obtained it. The chapters driven by twoWriters are asked once, because a second HANDLE is not a second shape. The parameterisation is temporary and goes when there is one shape again.
  • openForWriting MIGRATES, for the same reason openSnapshotAware does: claiming is a write and a store that has not been migrated has nothing to write to. So it is a complete replacement for the migrate() a host would otherwise call, not one more step to remember.
  • A handle that has already LOST cannot re-claim through openForWriting. The backend never re-mints a claim it has committed (ADR-0075: a writer that could silently re-claim would let two writers take the store in turns), so the no-op clear is refused and StoreWriterChangedError travels out of the open. A demoted writer builds a new store and opens that, which is the re-read the design wants anyway.
  • A claim that never landed is not remembered. The idempotency map drops a failed open so the next one tries again; what stays refused is a claim that landed and was later taken, and the BACKEND is what remembers that, not the map.
  • WRITER_CLAIM_KEY is a reserved cursor-port key, the second after SNAPSHOT_ORIGIN_KEY. It is exported so a host choosing its own keys can avoid it. Nothing ever writes it, so it is invisible in storage.

Amended by ADR-0097 ​

Where the claim is taken is unchanged: the factory that builds a generation's store (createState), and a tab that only renders never comes through it. What is new is that a HOST can now be that tab. With the tab election on, a host that does not hold the app's lock is built from the READER factory beside it (openState, returning a store opened with openForReading) and claims nothing; it moves from one factory to the other only by winning the election, which is ADR-0078's fresh start and not a re-claim of a store it lost.