WAL — Snapshots & reader postures
An op-log grows forever. A snapshot is a materialized state a new reader adopts
so it replays only the tail instead of the whole history. Snapshots live in a
sibling LWW document, <documentKey>__snapshot, and are client-generated by a
trusted role — the server holds no keys and cannot fold.
interface WalSnapshotDoc {
state: Record<string, unknown> // sealed CrdtState (full state incl. tombstones)
uptoTs: number // the contiguous ts-prefix this snapshot folded (also the resume point)
writerSeq: Record<string, number> // per-author highest sequence covered
producedBy: string // snapshot-role identity (Ed25519 pubkey hex)
authorPubkey: string // == producedBy
authorSignature: string // signDoc over { state, uptoTs, writerSeq, producedBy }
}
Wire a WalSnapshotStore (read/write that document) into the WalDocument options
to enable snapshots; omit it to always cold-start from ts = 0.
Why snapshots matter
- Cold-start cost — a new reader loads the snapshot and replays only
ts > uptoTsinstead of the whole log:O(tail)notO(history). This matters most because the dominant replay cost is the per-element Ed25519 author verification (~1–2 ms/element in pure JS), not the fold — so bounding the number of elements replayed is the lever, and a snapshot is how you pull it. - A correctness requirement under
delegated— replaying fromts = 0needs a key for every epoch the log spans. A recipient added after an epoch rotation only holds the current epoch, so for them a current-epoch snapshot is the only way to read history — not just an optimization. The trusted writer re-seals the materialized state under the current epoch, making a snapshot a natural re-keying boundary.
Producing a snapshot
const snap = await doc.snapshot() // requires a snapshotStore
snapshot() pulls the full log, author-verifies and folds every element,
exports the full CrdtState, seals it with the encryptor, signs the snapshot with
signer.signDoc on the __snapshot key, and writes it to the store.
Two obligations on whoever may call it:
- Snapshot
writeRole. The snapshot must be written through the role-gated push path so the server enforces who may write<name>__snapshot. Do not write it from an in-processafterWriteplugin with ambient authority — that bypasses thewriteRolegate the whole trust model depends on. - Full-history key custody (under
delegated). To materialize, the producer must decrypt the entire log — i.e. hold a CEK for every epoch it spans. A late-joining trusted writer (current epoch only) cannot produce a complete snapshot until it is re-wrapped the old CEKs.
Adopting a snapshot on read
open() reads the snapshot and accepts it only if:
- its author signature verifies (
verifyDocAuthorover the signed content); producedBy === authorPubkey; and- it passes the
isSnapshotAuthorgate (if you provided one) — i.e. the signer actually holds the snapshot role.
An accepted snapshot's state is decrypted and adopted; the cursor resumes at
uptoTs; the snapshot's writerSeq seeds the per-writer baseline (so ops pruned
below it are not flagged as gaps). An unauthorized snapshot (failing the role
gate) is ignored and the reader cold-starts. A forged snapshot (bad signature
or producedBy mismatch) fails per onAuthorError and is recorded as rejected.
The resume invariant
uptoTs names a contiguous, gap-free ts-prefix: the server assigns
strictly-increasing tail timestamps, so the snapshot folded exactly ts ≤ uptoTs
and the cursor folds exactly its complement ts > uptoTs — no drops, no
double-counts at the boundary. The one duplication that can occur (a client retry
re-appending the same op at a higher ts) is harmless because ops are idempotent.
Reader postures
Set posture in the options. All three ship; pick per your threat model.
trust
Adopt a role-signed snapshot's state directly and replay only the tail. Lowest
cost; relies on the snapshot-role identity's integrity. snapshotVerified stays
null (no re-derivation was performed).
trust-retain-tail (default)
Like trust, but always keep the most recent retainTailN (default 64)
author-verified elements in memory (retainedTail()), so recent history stays
independently re-verifiable against the signed op-log even though older history was
adopted from the snapshot.
re-derive
Treat the snapshot as a hint: re-fold the signed prefix ts ≤ uptoTs from the log
and compare the full canonical state against the snapshot's claimed state.
The result is exposed as doc.snapshotVerified:
const doc = new WalDocument({ /* … */, posture: "re-derive", snapshotStore })
await doc.open()
doc.snapshotVerified // true = matches | false = diverges/forged | null = no snapshot adopted
The comparison is over full state (registers, list nodes, clocks, tombstones,
ids) — not the materialized projection — so a validly-signed snapshot that
materializes identically but, say, carries an inflated register clock that would
suppress a future legitimate write is still flagged false. The materialized value
is always correct here because the log itself was fully replayed and per-element
verified.
snapshotVerified values: undefined before open(); null when no authorized
snapshot was adopted; true/false from a re-derive comparison; false when a
present snapshot was rejected as forged.
Compaction & retention (not yet implemented)
Once a snapshot durably covers uptoTs, a trusted writer could prune log
elements at or below it (trading history for storage), with knobs like
keepFullHistory vs compactBelowSnapshot + a retainTailN of re-verifiable
ops. This is not implemented yet — today snapshots are a pure cold-start
optimization and the log is never pruned by the library. Tombstone garbage
collection is entangled with this (tombstones can only be dropped inside a
snapshot's state, and only if every reader resumes from it), so it is deferred
together.
Note the interaction for when it lands: prune only below the durably-acknowledged
winning snapshot, and merge concurrent snapshots by max(uptoTs) (a larger
contiguous prefix strictly dominates), so a compaction never strands a snapshot
whose tail was pruned beneath it.