A demotion lives where the STORE does, and an advance that answers NOTHING is how a driver learns ​
ADR-0075 made a second writer's mutation land never, and ADR-0077 made the claim something a consumer takes by constructing a writer. Neither says what a writer DOES when its claim is taken, and the answer must not be "raise": the refusal is a writer learning it lost a race it could not have avoided, and an application that renders it is rendering a crash for a state change. We decided: a demotion is ONE function (demoteToReader, @etherfold/browser) that stops the fold, drops the in-memory cursor and narrows the writer's stores to openForReading, and the drivers that meet the refusal report it by ANSWERING NOTHING (Promise<LastSync | undefined>) rather than by throwing. syncing.demotion is the reported state and a named-logs warning is the developer's copy, because the failure this replaces is the quiet one.
It lives in @etherfold/browser because that is where the STORE is, and @etherfold/core deliberately has no store. A demoted writer must hold its state as a reader, and holding it means naming @etherfold/state-store, which the core does not depend on and must not: on both containers the state a generation folds into is an opaque type parameter supplied by a caller's createState factory, so Indexer and ReceivingIndexer could stop an engine but could never narrow a handle they have never seen the type of. The browser hook is also the only place where the other two halves already are: the LOOP that fetches (nothing in core schedules one) and the observable surface an app subscribes to. And it is where the demotion's second caller will be, since the tab-leader election that reuses this is Web Locks in a tab (work/specs/tasked/one-tab-indexes-and-the-others-read.md).
A demoted writer is not a FOLLOWER, and the two are restricted on opposite axes. A follower (ADR-0044) is read-only on the STREAM axis: it fetches nothing and appends no segment. It is a full writer of STATE, and busily so, because it re-folds the stored stream through EventProcessor.process, which calls applyBlock per block and persists a cursor in the same transaction. A demoted writer must stop writing STATE. Modelling one on the other would produce a generation that keeps mutating a store it no longer holds, keeps being refused, and loops -- which is the failure being removed, wearing a different name.
undefined from an advance means DEMOTED and means nothing else. The alternatives were both worse. Throwing keeps the signature and puts an exception in front of every app on a path that is not an error, and the auto-index loop and indexToLatest both SWALLOW exceptions and retry on a timer, so the refusal would be retried for ever against a store that will never accept it again. Returning the cursor the writer last held would return the very LastSync the demotion exists to drop, and checkTxInclusion answers from that window. So the drivers narrow to LastSync | undefined: a caller that ignores the value is unaffected, and one that reads it is made to notice, which is what "this tab is a reader now" deserves.
Considered Options ​
- Demote in
@etherfold/core, besidepause. The obvious home, sinceIndexer.pauseis already "one generation stops advancing". Rejected because the core cannot express the half that matters: it can calldisableProcessing()and cannot hold aReadableStateStore, so "the demoted generation holds a store opened for reading" would have to be re-implemented by each runtime anyway -- which is two demotions with one name. Note what this costs and does not: the receiving container (server, CLI) gets no demotion from this ADR. That is deliberate rather than deferred-by-accident, because a second writer on a server is a MISCONFIGURATION an operator fixes (two hosts on one database) and not a browser tab that must keep rendering, so the honest server behaviour is the refusal reaching the host that scheduled the call. - A
demotedflag onHeldGeneration, reported per generation. Rejected because the claim it would report is not per generation. A claim is scoped to one unit of STORAGE (ADR-0075) and the shipped pattern hands ONE store to every generation (ADR-0077), so a demotion true of one generation and false of its neighbour is a state no backend can produce. It is reported for the indexer, once. - Swallow the refusal and keep the loop running. The shape
readOnlyStreamtakes for streams, and rejected here for the reason ADR-0077 already gives for not swallowing writes: the loop would go on fetching a chain in order to be refused by every write it made, on a rate-limited browser provider, with nothing said anywhere. - Re-claim automatically after a demotion (retry the open, take the store back). Rejected because the backend never re-mints a claim it has committed (ADR-0075: two writers would take a store in turns), and because the re-read a new store forces is the thing that makes the recovered writer correct. Becoming a writer again is
dispose()plus a freshinitover a store built fresh.
Consequences ​
indexMore,indexMoreAndCatchupIfNeededandindexToLatestanswerLastSync | undefined. A caller that uses the cursor handles the absent case; nothing is published, so this costs a changeset rather than a migration.startAutoIndexingon a demoted hook returnsfalseand starts nothing, which is the same answer it already gives when a loop is already running: the tab is not indexing, and it says so rather than pretending.- The demotion is one-way per container.
dispose()clears it, because a laterinitcalls the factories again; acreateStatethat hands back the store that LOST is refused on its first write and demotes again, which is correct and is why the way back is a new store. syncing.demotionsits besidesyncing.streamSeedand NOT insidesyncing.error, on the same ground: an app that renderserroras a fault must not render one for an ordinary outcome, and the phase returns toIdlebecause nothing is happening.- The refusal is recognised structurally as well as by
instanceof(error.name === 'StoreWriterChangedError'), likeisOutOfSpacein@etherfold/core: a bundled app can hold two copies of@etherfold/state-store, and an unrecognised refusal is retried for ever.