Skip to content

A bootstrapped store carries current rows only, reports a retention floor at its snapshot's block, and refuses to revert under it ​

A client may start from state somebody else computed (StateSnapshot at the storage seam, bootstrapFromSnapshot in @etherfold/processor-entities) instead of replaying the chain from the start block, which is the capability the free-form path has always had through keepStateOnIndexedDB(name, remote). A snapshot carries the LIVE rows at one block plus the cursor that belongs to them, and the two install as one applyBlock; the store then reports its retention as a window whose oldest block is the snapshot's, refuses an as-of read below it with BlockNotRetainedError, and refuses a revert that would reach under it with RevertBeyondSnapshotError, changing nothing.

Why the floor, and why it is not optional ​

A freshly migrated store reports unbounded because that is true of a store that has been indexing since genesis, and it has no way to know it is not one. Install a snapshot into it and it will answer getAsOf(entity, id, snapshotBlock - 1_000) with undefined -- "the entity was absent then" -- which is an ordinary answer a caller acts on normally and which is wrong. That is the plausible-wrong-number failure ADR-0019 and the whole retention capability exist to prevent, arriving through a door nobody was watching. So the floor comes FROM the snapshot rather than from the deployment's configuration, and it is persisted (a second key at the cursor port, SNAPSHOT_ORIGIN_KEY) because otherwise the trap returns on the next reload: a floor held in a closure is gone when the tab reopens.

It is expressed in the existing retention vocabulary rather than a new one. A bootstrapped store's retained range is [snapshotBlock, tip], which is a window of tip - snapshotBlock blocks; intersecting it with whatever the deployment configured is min of two distances, so no fourth Retention kind is needed and retentionFloor, retainedRange, assertRetained and the conformance suite's claimedDepth all keep working unchanged.

Considered options ​

What a snapshot contains: current rows, not rows plus history. Measured on the real workload (docs/spikes/bootstrap-an-entity-store-from-a-snapshot/, the launched stratagems game): a current-rows snapshot is 45.6 KB gzipped, 4% of the 1,073.8 KB gzipped event stream, while every version in the same encoding is 304.6 KB, 28% of the stream and 6.7x the current-rows snapshot. The decisive figure is that 6.7x: a client is asked to pay nearly seven times the price of the tip to be handed history it cannot verify, and which it could derive by replaying the stream into a store that then holds history it verified itself.

Figures re-measured 2026-09-09. They previously read 7% and 49% against a 619.6 KB stream. Both SNAPSHOT numbers are unchanged to the byte; the stream grew to 1,073.8 KB when the fixture was re-captured, on an identical workload (same event, row and version counts), so only the ratios moved. That makes the current-rows case stronger (4% rather than 7%) and the full-history case weaker as a share of the stream, which is why the argument now rests on the 6.7x against current rows rather than on "most of the way to the stream". The decision is unaffected. The re-run also disposed of a suspicion that these bytes were stale because the storage adapters changed BigInt encoding: a snapshot contains no BigInt at all, so that change cost it nothing. Decisively, a full-history snapshot is not even expressible through the seam: a version is what applying a block PRODUCES, and a write surface that could set _lower / _upper directly could manufacture states no sequence of blocks could reach. Current rows install as one ordinary block, through a verb every backend already has.

Reorg below the floor: refuse loudly, and decline the snapshot that would invite it. A snapshot at block N with no history below it cannot survive a reorg reaching under N, and there is nothing cleverer available than refusing -- there are no superseded versions to reopen at any price, and reverting as far as the rows reach would leave a partly-undone reorg, which is a plausible state nothing downstream can tell apart from a correct one (the same reasoning RevertBeyondPatchHistoryError records for the patch store). Prevention is the producer's job (take snapshots at least the finality depth behind the tip, which is what the finality depth is for) and a consumer cannot verify it after the fact, so the consumer does the half it can: given a finalityDepth, bootstrapFromSnapshot declines a candidate taken inside the reorg-eligible window of the tip its producer had observed. Both halves, because either alone leaves a real gap.

A decorator (openSnapshotAware) rather than a verb on every backend. The obligation is identical everywhere and is expressible entirely through the seam a backend already implements, so writing it once means a new backend inherits the trap's answer instead of rediscovering the trap. The conformance suite asks the properties of every backend regardless, so a backend that later implements bootstrapping natively is held to the same behaviour.

Consequences ​

  • openSnapshotAware belongs on the BOOT path, not only on the install path, and it migrates the store itself so it is a replacement for the migrate() a host would otherwise call rather than one more step to remember.
  • The window a bootstrapped store REPORTS is measured from the highest block the handle has seen, because the seam has no verb that reports a tip. Between a reload and the first block applied in that session it therefore under-claims (never over-claims), and it corrects itself on the first applyBlock.
  • revertTo(-1) -- the wipe EntityEventProcessor.reset() performs -- is not a reorg and is not refused: it drops the rows, so the floor goes with them and the store goes back to claiming what it was configured to keep.
  • Only the CONSUMING half was built here. Publishing snapshots as a first-class artifact (a publish command, format versioning, mirror layout, pruning old snapshots, and who is allowed to publish state a client accepts without recomputing) was a design of its own, since decided by ADR-0095 and built as etherfold publish / etherfold build --publish. createSnapshot is the minimal producer the tests need, and it cannot read rows out of a store, because the seam has no "list everything" read and deliberately never will (ADR-0021).