Skip to content

One query runs against a worker and a server, through an accessor seam beside the store seam ​

AMENDED 2026-09-30: an answer names its block's HASH, a root field may be pinned to a hash, and the reorg guard is a REVERT SEQUENCE beside the tip check, which retires the gap this ADR's guard accepted; see the amendment at the end.

AMENDED 2026-09-30 (second): QueryContext.blocks is REQUIRED, not optional; see the second amendment at the end.

An app that indexes in the browser and an app that reads a hosted indexer should be the same app, and they could not be: the browser has the four typed reads of createReadSurface (ADR-0021, no predicate, no ordering), and the server a raw SQL predicate. GraphQL was already chosen for the hosted case on measured evidence (Hono, Yoga, Pothos, built programmatically from the declarations). We decide that the SAME GraphQL document runs against a browser worker and a server with identical results, and that the work is in three layers decided separately. Decided with the maintainer on 2026-09-28, tasking the spec the-same-query-runs-against-a-worker-and-a-server; it builds on ADR-0098 (relations, enums, semantic types).

The accessor seam, in its own package, never a member of StateStore ​

One interface the resolvers call and each backend implements: find the rows of an entity matching a predicate over declared fields (eq, ne, lt, lte, gt, gte, in, isNull, combined with and and or; a comparison with null is false as in SQL; nulls order first ascending and last descending, as SQLite does; text compares as UTF-8 bytes on every backend, so the IndexedDB side does not use JavaScript's UTF-16 order), ordered by a declared field, bounded, at the tip or as of a block, with relations batched and bounded PER PARENT (a batch bound would let one prolific parent starve the others). It lives in @etherfold/accessor, with its conformance suite; SQLite implements it in @etherfold/state-store-sqlite by generating SQL, IndexedDB in @etherfold/state-store-indexeddb. It is not part of the handler seam because a predicate-taking read there would breach ADR-0021, which narrowed that seam because a handler runs once per event on a substrate with no query planner. The generated read surface stays: it costs no bundle and is the right answer for an app reading a few entities by id.

The IndexedDB scan is bounded by ROWS EXAMINED, not time ​

The bound is the IndexedDB accessor's alone: SQLite has a query planner and a server answers what it can serve, so the refusal is a DOCUMENTED difference between deployments, asserted per executor, not a parity rule. Rung 1 scans a key range and filters in memory, and REFUSES past a declared number of rows examined, default 25,000 (generous against the measured live set of 4,072 rows), configurable per deployment. Rows examined, not elapsed time, because time is a property of the device (a phone would refuse what a laptop answers) and a deterministic bound refuses identically everywhere, which makes it testable. block: with where is served as current plus a delta (the rows with a version opened or closed above the block, both already indexed range scans), so the cost is churn since the block, not the depth of history, under the same bound. Those indexes are keyed by block across every entity, so the churn counted is the whole database's since the block, not the queried entity's: an as-of query on a quiet entity can be refused because others changed. An entity-scoped index would fix it at the price of a versionchange, and is not added now. Outside retention it is BlockNotRetainedError, the seam's existing refusal. Rung 2, a real multiEntry index over computed [field, value] keys, was measured viable on all three engines and ships only after its write cost is measured on the real workload; rung 1 stays as the fallback and the reference rung 2 is checked against.

The schema is one, built from the declarations, and the transport is an executor ​

One runtime-neutral schema module (@etherfold/graphql) builds the GraphQL schema from the declarations; a capability a deployment cannot serve is a coded REFUSAL, never a different schema or an ignored argument. The transport is a QueryExecutor ({query, variables, operationName} to a result): HTTP for a remote indexer, the worker port for a worker that holds the store, in-process for tests, and executorToFetch for client libraries that only take a fetch. The executor contract defines ONE transport-failure shape that every executor normalises to (an HTTP 500, a non-JSON body, a network error, a closed port or a dead worker host). The HTTP endpoint is /graphql on etherfold serve. A u256 crosses as a U256 scalar carried as a decimal string, since JSON has no bigint. The worker executor is forced: the resolvers need the store, the store is in the worker, so the schema and the graphql runtime live there. It stays OPT-IN in the worker bundle: @etherfold/browser carries a generic query case on the host port whose handler the app's worker entry injects, and never depends on GraphQL; @etherfold/graphql provides that handler and workerExecutor on a subpath export (@etherfold/graphql/worker), so its root entry stays runtime-neutral and a server importing it pulls in no browser package. Liveness is not streamed through it; it is the state-moved signal (ADR-0083).

Four parity rules, and one block per operation ​

bigint and u256 serialise identically on every executor (no path is nicer locally); both executors share one error formatter and one set of codes; the executor contract defines what a transport failure normalises to; and every response reports the generation in extensions. Every operation pins one block and resolves every field as of it, reported in extensions, so a query cannot straddle a block or a promotion; a reorg mid-operation is guarded optimistically (read the cursor at the start and the end, retry once if it moved backwards, then refuse), deliberately not by pinning below finality, because serving the unconfirmed tip is the point.

Status ​

accepted, not yet implemented: the tasks with spec: the-same-query-runs-against-a-worker-and-a-server build it, and the one that lands last, an-indexeddb-index-serves-the-accessor, removes this status line in the same change (or, if its measurement declines the index, rewrites this section to say so) (work/protocol/ADR-FORMAT.md).

Amendment, 2026-09-30: a query is pinned to a block HASH, and the reorg guard is a revert sequence ​

What changed. Every answer's extensions names the operation's pin by hash as well as by number, {generation, block, blockHash}, blockHash being null exactly when block is. A root field's block argument is no longer a SafeInt: it is a @oneOf input BlockAddress {number: SafeInt, hash: Bytes32} (a breaking schema change, a 0.x minor), mirroring the SQLite store's own BlockAddress, so an app composing several operations into one view pins each follow-up to the hash the first answer named, and reads the same block of the same chain or is refused (block-not-recorded, ADR-0015's amendment of the same date). The accessor seam stays number-only: a hash is resolved to a number before any read.

Where the lookup lives. QueryContext gains blocks: {at(number), of(hash), revertSequence()}, which a host copies off the store it reads, as it copies tip. Each query-capable backend implements the reads (blockAt, blockOf, revertSequence, plus a public tip on SQLite): SQLite through its block table and _seam, IndexedDB through the hash index its blocks store already kept unique and its seam object store. Both wrappers between a store and the query layer forward them by feature detection, as they forward tip (the claimed handle, and the snapshot-aware one); openForReading wraps nothing. graphqlQueryHandler refuses a store without them, and the server's context supplies them. The member is OPTIONAL on the type only so a hand-built test context keeps its old answers.

The guard. The start-and-end tip comparison could not see the pinned block REPLACED at the same height, and re-resolving the pin's hash at the end is not enough either: a reorg away and back (A, then B, then A) leaves the hash matching while fields read in between came from B. So each store keeps a monotonic, PERSISTED revert sequence in the seam's own keyspace (ADR-0080's amendment of the same date), incremented in the same transaction as every revertTo (persisted because a reader tab reads the store another tab's leader reverts), and every operation reads it at its start and at its end: changed means torn, retried once, then refused (tip-moved-during-operation, with a message saying the store reverted). THE READ ORDER IS PART OF THE GUARD: the sequence is read BEFORE the pin (tip, then blocks.at) and AFTER the last field read; read the other way round, a revert and a replacement landing between the pin and the sequence are never seen and the answer names a hash its fields were not read at. It ADDS to the tip check rather than replacing it: on a revert-only store any forward move of the tip still tears an operation, which a revert sequence cannot see. A revert that did not reach the pin also bumps it and costs a needless retry, accepted to keep the state one integer rather than a revert log. Between operations the hash is enough on its own: the state as of a block is a function of the chain up to it. The gap this ADR's guard accepted ("a reorg that takes the tip below the pin and back above it between the two reads is not seen") is closed.

Amendment, 2026-09-30 (second): QueryContext.blocks is required ​

The first amendment made blocks optional so that a context built by hand for a test kept its old answers. Review found that this let a context that left it out answer in a different SHAPE (no blockHash at all) and with a weaker guard, silently, which is the failure ADR-0099 already refused for asOf ("REQUIRED, with no default"). So blocks is required, QueryExtensions.blockHash is always present (null exactly when block is), and queryBlocksOf(store) builds the member in one line from any store with the query reads. Every host already supplied it; only hand-built test contexts changed.