A reader is TOLD the state moved, by a SIGNAL carrying a coherence token ​
AMENDED 2026-09-30: an
appliednotification also names the HASH of the block it applied; read the amendment at the end first. Nothing below is withdrawn: the two-line rule, the token and the three cases are unchanged, andretractedandrepointedcarry exactly what they did.
A client can read the state and has no way to know when to read it again. Today an app subscribes to a reactive store in the same thread as the indexer, which works precisely because everything is in one heap, and every direction this project is going breaks it: the indexer moves into a worker (ADR-0082) and the subscription is on the wrong side of a postMessage; a query surface arrives and the app sends documents rather than holding a handle; a remote indexer has no shared heap in principle; an elected tab indexes and the other tabs have no indexer to subscribe to. Without an answer, "a client can query state" means "a client can poll", and an interval invented separately by every app is worse than one answer.
We decided: the side that applied the block TELLS the sides that are reading, over whichever transport the deployment has, and what it says is {block, coherence, entities, generation}.
type StateMoved = {
block: number;
/** Opaque. COMPARE it, never parse it. Changes when cached data may be stale. */
coherence: string;
/** Entity NAMES this block touched. Bounded by the declaration, not by block size. */
entities: readonly string[];
/** WHICH generation answered, so a refetch is not served by another lineage. */
generation: string;
};As BUILT it is a union tagged kind, because the retraction below is a case of this signal and not a variation of the shape above: the four fields are the 'applied' case, the 'retracted' case carries {forkPoint, coherence, generation}, and the 'repointed' case (a move of the canonical pointer, below) carries {coherence, generation}. The tag is on EVERY case deliberately, so a reader that read block off a retraction or a pointer move does not compile rather than rendering a number that means the opposite of what it thinks.
The generation field is carried IN ADDITION to the token rather than folded into it, because the two answer different questions: the token says WHETHER what you hold may be stale, and a reader must never parse it, so it can NAME nothing. A reader that refetches after a promotion needs to know which lineage answered, which is a fact it renders and compares rather than an invalidation trigger. Both are needed; neither substitutes for the other.
A reader's whole rule is two lines: token unchanged, invalidate narrowly using entities; token changed, invalidate everything.
This is recorded as an ADR rather than left to its callers because it is a decision that belongs to NEITHER of them. The query surface needs it and lists it out of scope; the election spec needs it and cannot answer it alone. Deciding it in either would give the other a second, incompatible answer. A notification model is also very hard to widen later: a bare "something changed" signal shipped first is consumed by every app, and adding retraction or per-entity detail afterwards changes every consumer.
The token is the load-bearing part, because best-effort delivery and retraction do not otherwise compose ​
Delivery is best-effort, at-most-once and unordered, and the producer holds NO per-client state. That is what stops a SharedWorker's memory growing with the number of open tabs, which is the case that would otherwise decide this by accident.
"A missed notification is repaired by the next one" is true for an append and FALSE for a retraction. After a reorg the stale entities are the ones the ABANDONED branch touched, and those are generally not in the changed-set of whatever block arrives next, so a reader that misses the retraction, receives the next append and invalidates narrowly under-invalidates and keeps dead-branch rows on screen indefinitely. The token closes it for the cost of one field: the next notification already carries a different one, so a missed retraction is self-correcting.
A retraction is carried explicitly even so, and it names a FORK POINT rather than a set of blocks, which is the vocabulary the emission stream, the removed marker and revertTo already share. The token makes a MISSED retraction safe; it does not make an explicit one unnecessary, because a reader that received it can act at once instead of at the next block.
As BUILT, the rotation is not a second call a caller has to remember: StateMovedPublisher.publishRetraction rotates AS IT PUBLISHES, in the one assembly both containers publish from, so a retraction under the old token is unexpressible. The retraction therefore carries the NEW token, which is the one the appends after it carry, so a reader that RECEIVED it invalidates everything once and goes back to invalidating narrowly at the next block. And the "only the canonical fold publishes" filter covers the ROTATION as well as the notification: a follower re-folding a stored stream's reorg must not rotate, or every reader of the canonical fold would throw its cache away because a second generation caught up.
The token also rotates on a PROMOTION, deliberately by the same mechanism. A promotion means a different fold now answers, which from a cache's point of view is indistinguishable from "everything you hold may be wrong". One comparison and one code path rather than two, and it follows the existing convention that a generation is rendered so a reader compares the value and never parses it.
And the move is ANNOUNCED, at once, by a notification of its own: {kind: 'repointed', coherence, generation}, carrying the rotated token and the generation that answers from here on, and NO block and NO entity set. This amends what was first built, which published nothing for a pointer move and left the rotated token to ride the NEXT notification. That was measured to fail exactly where it matters: on a chain that has gone quiet (a local node between transactions, the ordinary state of a developer's hot reload) there is no next notification, so a reader that re-reads on the signal went on rendering the retired generation while every read already answered the new one, and the browser reference had to poll the generation list while a switch was pending. The case costs a reader nothing: the token comparison already says invalidate everything, and the narrow line reads entities off an 'applied' notification only, so the two-line rule does not grow a third. It names no block because none was applied, and it carries no entity set because a rotated token already asks for everything.
As BUILT, the move is TWO steps in each container's one pointer-move (Indexer.movePointerTo, ReceivingIndexer.movePointer), through one publisher call (StateMovedPublisher.rotateForPointerMove) that rotates and answers the announcer carrying the token it rotated, so a pointer move published under a token it did not rotate is unexpressible, as a retraction's is. The ROTATION is FIRST, after the registry write and BEFORE the pointer-moved callback and the state notification that applies the move, since both of those are a reader being told to re-read and a rotation after either would let one notification's worth of questions about the new generation be answered under the retired one's token. The ANNOUNCEMENT is LAST, once the read path has followed the pointer, so a reader that re-reads the instant it is told is answered by the generation the announcement names; on the receiving container reads resolve the durable pointer, so that is right after the write, and it is announced whether or not this process holds a fold for the target, since reads answer from it either way. A promotion followed by a block is therefore ONE announcement and ONE append under ONE rotated token: the block does not rotate again, so a reader invalidates everything once. And it covers EVERY move of the pointer rather than the forward ones alone, because a REVERT changes which fold answers exactly as a promotion does, which is the only thing a reader can see of either; a move onto the generation already answering moves nothing and announces nothing.
The entity set is produced where the mutations are, and core RELAYS it ​
The producer is whoever applied the block, and in this system that is two packages rather than one. @etherfold/core owns the block number, the generations and the canonical pointer, so it owns the token and its rotation. It cannot see entity names: EventProcessor.process returns an opaque ProcessResultType, and the word mutation does not occur anywhere in the package. The Mutation objects that carry an entity name are collected and consumed one package down, in @etherfold/processor-entities.
So the entity set is produced in @etherfold/processor-entities, where the mutations already are, and core RELAYS it into the signal alongside the facts only core holds. This follows ADR-0078's precedent rather than inventing a rule: that ADR put demotion in @etherfold/browser because a demoted writer must narrow a store handle, and core deliberately has no store and could never narrow a handle whose type it has never seen. The same argument applies here, and the alternative (widening EventProcessor.process to return a touched-entity set) would reach core, processor-entities, processor-sqlite, the CLI and the browser to move information that already exists at the lower layer.
A consequence worth stating: a processor that is not entity-declared has no entity names to report, and an empty set is the honest answer there rather than a fabricated one. Narrow invalidation then degrades to whatever the token says, which is correct if coarse.
BOTH containers publish, from one assembly. This system has two things that apply blocks: the chain-facing container that fetches and folds, and the receiving container that folds a stream it is pushed and is deliberately chain-free. Every server and CLI deployment runs the second, which published nothing before this decision. Both emit the same signal from the same assembly rather than two implementations, because one notification model is the claim being made and two producers that drift is how that claim dies. What the receiving container still withholds is a state HANDLE, which was a deliberate decision and is a different thing from a notification.
As BUILT, "one assembly" is StateMovedPublisher held by each container and the relay each attaches to every fold it holds; what each container supplies is only the half a container knows, and the two differ in exactly one place, which is how they answer which fold is canonical. The chain-facing one compares against Indexer.current, the in-memory pointer its read path moves at a notification. The receiving one has no such pointer, because reads there resolve the DURABLE pointer to a table namespace (ADR-0053) and its own answers are derived from the registry on every question -- so it keeps canonicalFold, re-read wherever it already reads the pointer AND on the two paths that PRECEDE a fold (liveIngestions before a batch is routed, rebuildMore before a chunk is replayed). It has to be answerable synchronously, because a fold reports from inside process(). The cost is stated rather than discovered: a pointer moved by ANOTHER process is not seen until the next read, so a container can briefly publish from a fold that has stopped being canonical elsewhere, which is the same in-process staleness the chain-facing container has and is bounded by the same best-effort decision.
A HOST reaches the receiving container's signal through the ENTRY it registered the name under, because @etherfold/server applies no blocks and a route holds an entry rather than a container: IndexerRegistryEntry.onStateMoved is OPTIONAL and forwarded by indexerEntryOn, on the same absent-is-a-capability-statement rule as generations and promote. A host holding a bare receiver and no container (singleContextEntry) reports none, which is what lets a transport REFUSE rather than attach to silence -- the Worker case above.
Only the CANONICAL fold publishes. A non-canonical generation re-folds a whole stored stream to catch up, and publishing per block there would emit thousands of notifications naming past blocks while nothing a reader can see has moved. The chain-facing container already filters its per-generation callbacks this way; the notification follows the same rule rather than inventing a second one.
Entity NAMES, not ids, in v1 ​
Entity names are bounded by the declaration, so the payload is O(schema) rather than O(mutations). That matters because the worst block on the real measured stream carried 457 mutations against a median of 7. Type-level invalidation is also what a normalised GraphQL cache does well and what most apps use anyway.
Ids can be ADDED later as an optional field without breaking a reader, while they could not be removed, so starting narrow is the reversible direction. There is a trap in shipping them early: ids invite a reader to apply the delta by hand instead of re-reading, which is precisely what goes wrong under reorg.
It is a SIGNAL on its own channel; a GraphQL subscription is an adapter over it, and is anticipated ​
The signal has to exist anyway for the worker path, because a MessagePort has no GraphQL on it. The derivability is one-way: a subscription is derivable from a signal by wrapping it in an AsyncIterable, while a signal is NOT derivable from a subscription without a GraphQL runtime, which is exactly what a read-surface-only app has deliberately not loaded. Every client library's invalidation API is a plain callback (invalidateQueries, refetchQueries, reexecuteOperation), so a signal composes with all of them in a few lines where a subscription needs a second link or exchange configured in each. And a subscription whose payload is "block N changed" is a heavyweight way to deliver a number: subscriptions earn their weight by pushing DATA, and pushing data means per-client state, which the delivery decision rules out.
A server-side GraphQL subscription, over WebSocket or SSE, is anticipated and deferred rather than rejected. When it is wanted it arrives as an ADAPTER over this signal, which is why the server's producer is required to be transport-agnostic: adding one must need no change to the code that publishes. What it must not become is the primitive.
The signal is NOT SUPPORTED on Cloudflare Workers, and that is stated rather than discovered. A Worker invocation is isolated: an I/O object created in one request handler cannot be touched from another, so an ingest POST cannot write into a stream opened by a different request (work/notes/findings/a-worker-cannot-hold-a-timer-across-requests.md is the same constraint from the timer side). The remedy would be a Durable Object, which is infrastructure and cost this deployment has not taken on, and the question properly belongs to whoever adds the subscription adapter. The failure mode is what makes silence unacceptable: the server package deliberately names no runtime, enforced by its own platform-agnostic test, so a module-global subscriber registry COMPILES, passes on Node, and never fires on a Worker, which a reader cannot distinguish from a quiet chain. So the host must REFUSE the endpoint where it cannot serve it. This is the same shape of statement platforms/cf-worker already makes about the chain-facing half being a poor fit there.
Sync progress rides the same stream, because a reader cannot compute it — the cursor is opaque behind the storage seam (ADR-0027) and deserialising it in a reader would breach that — so it must be published by the producer, and a second channel would be two mechanisms with two failure modes for one question. This does not disturb ADR-0082's decision that a HOST pushes status to its own tab over its port; that push stays as it is, and what rides this stream is the cross-tab AND cross-network case, where a reader has no host to ask. Both halves are owed: a tab reading from an indexing tab and an app reading from a hosted indexer must each be able to render how far behind the fold is.
The three transports, as built, and the ONE SUITE that keeps them one model ​
All three are built, and each is an adapter carrying the value above unchanged rather than a producer of its own.
- A worker's PORT to its tab (
IndexerPort.onStateMoved,@etherfold/browser): one more push besideprogresson ADR-0082's envelope rather than a channel of its own. A SERVER-shaped host reaches the receiving container's signal through the registry entry it registered the name under instead (IndexerRegistryEntry.onStateMoved, forwarded byindexerEntryOn), which is what the remote transport attaches to; a host holding a bare receiver and no container (singleContextEntry) reports none, on the same absent-is-a-capability-statement rule asgenerationsandpromote, which is what lets a transport REFUSE rather than attach to silence. - The CROSS-TAB channel (
openStateMovedAcrossTabs/stateMovedChannelName,@etherfold/browser), whose channel is NAMED from the STORAGE IDENTITY the fold writes into and nothing else (etherfold/state-moved/<databaseName>). That is ADR-0075's scoping rule for the writer token rather than a second one: an origin, a tab or an app-supplied name would silently put two unrelated indexers on one origin into one channel. ONE channel object per tab serves BOTH directions, so a tab that hosts its own worker is not handed back what it just published and no publisher identity has to exist to filter it out; no election, lease or heartbeat is introduced, because which tab indexes isone-tab-indexes-and-the-others-read. SYNC PROGRESS rides that same channel (publishProgress/onProgress, forwarded fromIndexerPort.onProgressby the tab that holds a host), and a listener attaching is handed WHERE THE FOLD IS rather than nothing -- the last report its tab heard, or, holding none, it ASKS and any tab holding a report re-posts it -- which is ADR-0082's state-versus-notification split carried across. - The NETWORK (
GET /{indexer}/state-moved,@etherfold/server): server-sent events carrying theStateMovedvalue as JSON and otherwise untouched, with sync progress as a second frame kind (StateMovedProgress, read from the stream's own coverage claim and never from a cursor) sent ON CONNECT and whenever it moves. A connecting client is told the position AND the token at once (coherenceNow), which is how a remote reader converges with no state query surface to re-query; nothing is buffered, nothing is replayed, a disconnect detaches, and there is deliberately NO heartbeat, because a dropped idle stream costs a reconnect that is answered with exactly that position. The Worker case REFUSES, on a capability the HOST declares (ServerOptions.holdsStreamsAcrossRequests) and never on a runtime the server package detects.
Three adapters against one decided shape is necessary and not sufficient, and that is why there is a fourth artifact. Three independently-correct adapters agree on the day they are written and drift one edit at a time afterwards, each still passing the tests in its own file, which would leave the claim at the top of this ADR true of nothing in particular. So the cases are DATA in @etherfold/state-moved-conformance -- one list, three subjects, a thin runner in each owning package -- on the shape @etherfold/state-store-conformance already uses to parameterise over storage backends. A transport joins by supplying an ADAPTER (attach a handler, apply a block, cause a retraction, move the pointer, which the suite asserts is announced with no block to wait for) and never by copying cases, which is what makes the anticipated GraphQL subscription adapter cheap to admit. The four chapters are the four places adapters stop agreeing: what the VALUE carries, what the SEQUENCE is, what a reader ATTACHING LATE is told, and what a reader that MISSED something converges on. The last chapter is selected on what a transport's reader can do -- a reader with a state surface is asked that a read is not answered from below the block it was told about, and a reader with none is asked how it is told the position on connect -- and a transport offering neither fails a case saying so rather than skipping it.
Consequences ​
It answers the-same-query-runs-against-a-worker-and-a-server's open question 1 as NO: QueryExecutor stays Promise-returning on day one, with no AsyncIterable. If subscriptions are ever wanted they arrive as a separate subscribe on the same port rather than by widening the executor, which is a cleaner shape regardless — two functions rather than one polymorphic one.
The retraction is produced in core and the StateStore seam is NOT widened. revertTo returns void at the interface; the IndexedDB and SQLite implementations walk their version indexes and could report what they touched, but the fold already knows the fork point above the seam, and the entity-level detail is unnecessary because a rotated token means invalidate everything. Widening the seam would be a breaking change across four backends and the conformance suite for information that is not needed.
A remote reader has no state query surface yet, and this signal does not wait for one. The server exposes status, ingest, feed and admin; the query layer is deliberately deferred to the-same-query-runs-against-a-worker-and-a-server, and the feed is the CONSUMER path with its own cursor rather than a state read. So a remote reader converges by being told the current position when it connects, not by re-querying state it cannot yet ask for. When the query surface lands, re-query convergence and per-operation block pinning join it there.
One limit is accepted rather than solved. If a notification is lost and the chain then goes quiet, a reader stays stale until the next block moves. That is inherent to push, and the honest mitigations belong to the reader: re-query on visibility change, or a slow poll as a backstop. Building delivery guarantees for it would mean buffering per client, which is exactly the per-client state this rejects.
Amendment, 2026-09-30: an applied block is named by its HASH as well as its number ​
What changed. The 'applied' case carries the applied block's hash beside its number: {kind: 'applied', block, hash, coherence, entities, generation}. 'retracted' and 'repointed' are unchanged. The hash is the one the store RECORDED for that block, in the store's one spelling (lower-case hex, normalizeBlockHash), so it is byte for byte the hash the query layer names in extensions.blockHash (ADR-0099's amendment of the same date) for an operation answered while that block is the tip. The shape extensions landed with is {generation, block, blockHash}, a sibling field rather than block: {number, hash}, and the signal follows it: hash sits beside block rather than replacing it.
Why. A query can now be pinned to a block HASH (block: {hash}), and a number cannot say which block a reader was told about: a reorg puts another block at the same height. With the hash, a follower pins its re-read to EXACTLY the block it was told about, so if that block is replaced before the re-read, the re-read is REFUSED (block-not-recorded) and the follower reads everything again, instead of reading the replacement and briefly composing parts from two branches until the rotated token arrives. A remote reader can also match a notification to an answer exactly and key a cache on it. It costs about 66 bytes per applied notification and no work on the producer beyond carrying it, since the fold already holds the hash (it is what the store records), and nothing for a reader that ignores it. The maintainer decided it on its own merit on 2026-09-29, not deferred for its refactoring cost.
This is not a narrowing of what the signal refuses to carry. It is a fact about WHICH block moved, not data: no rows, no mutations, no ids. It does not enter the reader's two-line rule, which still reads only coherence and entities.
Where it is produced, which follows the entity set's rule above. The hash reaches core through the fold report, as the entity set does: AppliedBlock (the upward FoldReport case) gains hash, emitted in @etherfold/processor-entities right after applyBlock returns, and normalised THERE, because the rule lives in @etherfold/state-store and @etherfold/core cannot import it (ADR-0016). Core RELAYS it untouched from both containers' one assembly (StateMovedPublisher.publish), so the chain-facing and the receiving container cannot disagree about it. A fold that reports nothing publishes nothing, as before.
Every transport carries it unchanged, and that is CHECKED. The conformance suite's exact key set for an applied notification now has six fields, and a transport adapter supplies one more verb, recordedHashAt(block), the hash the store behind the canonical fold recorded, read out of the STORE and never out of a notification: the suite asserts that an applied notification's hash equals it, and that a block that REPLACED another at the same height after a retraction is named by a different hash. The case runs on the port, the cross-tab channel and the server's stream alike, and the suite's own self-check shows that a transport which re-spelled the hash (upper-cased it for display, say) fails it by name.