| 1 | # Snapshots — Design |
| 2 | |
| 3 | **Status:** designed, not yet reviewed, not yet a plan. |
| 4 | **Extends:** `docs/superpowers/specs/2026-08-30-docs-design.md` §4.1 (the log is |
| 5 | bounded), §5.1/§5.2 (what the core does, and why the sandbox is load-bearing). |
| 6 | **Follows:** `2026-09-01-live-sync-design.md`, whose cursor rules this must not |
| 7 | disturb. |
| 8 | |
| 9 | ## 1. Why now, and it is not latency |
| 10 | |
| 11 | The obvious reason to want this is speed: `DEBOUNCE_MS` is 2000ms because every |
| 12 | accepted update is one `doc_updates` row, and a shorter debounce multiplies rows. |
| 13 | That is real, and it is the second reason. |
| 14 | |
| 15 | The first is a cliff already in production. `internal/core/core.go`'s |
| 16 | `maxRetainedUpdates` is 4096, and once a document's retained log reaches it, |
| 17 | `Handle.Apply` refuses every further update — `ErrUpdateLogFull`, surfaced to the |
| 18 | browser as a 400 that the save queue treats as permanent. **The document stops |
| 19 | accepting edits.** That constant's own comment is explicit that this is a |
| 20 | deliberate, visible failure standing in for snapshot support, chosen because |
| 21 | silently dropping old entries would make a later rebuild replay an incomplete |
| 22 | history and produce quietly wrong content. |
| 23 | |
| 24 | At today's 2s debounce that is roughly 2h16m of continuous typing on one |
| 25 | document. A person writing all day reaches it. Nothing warns them first; the |
| 26 | document simply starts refusing. |
| 27 | |
| 28 | So this work is not a performance optimisation with a latency bonus. It is the |
| 29 | thing that stops a document going read-only, and it happens to be what makes |
| 30 | near-realtime affordable afterwards. |
| 31 | |
| 32 | ## 2. Scope |
| 33 | |
| 34 | **Snapshots only.** Paul's ruling, 2026-09-01: nothing is deleted. |
| 35 | |
| 36 | §4.1 names three mechanisms — coalescing before persistence, a snapshot cadence, |
| 37 | and a compaction horizon that drops superseded updates. This slice builds the |
| 38 | middle one. The horizon is deferred because deletion is irreversible and |
| 39 | forecloses any future history feature over the window it eats; coalescing is |
| 40 | deferred because it belongs with the transport work that makes a short debounce |
| 41 | worth having. |
| 42 | |
| 43 | The consequence worth stating plainly: **this slice makes storage grow faster, |
| 44 | not slower.** The update log still grows, and snapshots are added on top of it — |
| 45 | a full copy of the document every 256 updates, all retained under "nothing is |
| 46 | deleted", all replicated by Litestream. For a large document the snapshots |
| 47 | outgrow the log they accelerate. This slice bounds *replay cost* and removes the |
| 48 | cap; bounding storage is what the horizon is for. |
| 49 | |
| 50 | One correction to that, though, which review surfaced and which does not need the |
| 51 | horizon ruling: **a superseded snapshot is derived data, not history.** It is |
| 52 | reproducible from the log at any time, so deleting it deletes nothing a reader |
| 53 | could ever have asked for — unlike an update row, whose loss is irreversible. |
| 54 | This design therefore rules that **only the newest snapshot per document is |
| 55 | retained**, and older ones are deleted as they are superseded. That keeps the |
| 56 | added storage at one document-sized blob rather than one per 256 updates, and it |
| 57 | is not the compaction horizon in disguise: no update row is touched, and the |
| 58 | "nothing is deleted" ruling is about history, which this is not. |
| 59 | |
| 60 | ## 3. What a snapshot is |
| 61 | |
| 62 | One Yjs update encoding the document's entire state at a point in the log — |
| 63 | exactly what `yrs` produces internally today and what `core/src/abi.rs` does not |
| 64 | yet expose. Applying it to an empty document yields the same document the whole |
| 65 | log yields. |
| 66 | |
| 67 | **The call is `ReadTxn::encode_state_as_update_v1(&StateVector::default())`, and |
| 68 | which one is not a detail.** Against the empty state vector it encodes all |
| 69 | blocks, the entire delete set, and — through `merge_pending_v1` — the pending |
| 70 | queue, meaning updates whose dependencies never arrived and which `apply_update` |
| 71 | accepted and parked. That is Yjs's own persistence mechanism, and it is |
| 72 | merge-equivalent to the log rather than merely render-equivalent: every clock |
| 73 | range and every tombstone survives, so a later concurrent merge integrates |
| 74 | against a snapshot exactly as it would against the full history. |
| 75 | |
| 76 | Two neighbours in the same API are traps, and an implementer reaching for the |
| 77 | nearest-looking name will find them first: |
| 78 | |
| 79 | - **`encode_diff_v1(&StateVector::default())`** looks identical and silently |
| 80 | drops the pending queue (its own doc comment says so). A log holding an update |
| 81 | whose dependency arrives later heals under full replay and does not heal under |
| 82 | a diff-built snapshot. That is precisely the failure this design most fears — |
| 83 | renders identically today, breaks a merge tomorrow — and §D5's comparison |
| 84 | cannot see it, because pending content is invisible in both documents. |
| 85 | - **yrs's own `Snapshot` type** and `encode_state_from_snapshot` are a name |
| 86 | collision with this spec's vocabulary and a different feature entirely |
| 87 | (historical views, requiring `skip_gc`). Not this. |
| 88 | |
| 89 | The fixed guid and client id play no part in the encoded state, so nothing here |
| 90 | touches the no-entropy rule; the `"prosemirror"` root travels inside the update; |
| 91 | and GC settings match on both sides, so tombstone handling is symmetric with log |
| 92 | replay. |
| 93 | |
| 94 | That single property is what makes everything below cheap: a snapshot is not a |
| 95 | new kind of thing, it is *an update*. Every path that already knows how to apply |
| 96 | concatenated updates — `Store.replay`, and the browser's own `applyServerLog` — |
| 97 | handles one with no changes at all. |
| 98 | |
| 99 | ## 4. Decisions |
| 100 | |
| 101 | ### D1 — The ABI gains a state export, v3 → v4 |
| 102 | |
| 103 | `docs_core_snapshot_len` / `docs_core_snapshot_read`, mirroring the existing |
| 104 | `html_len`/`html_read` pair. `internal/core`'s version check is exact-match, so |
| 105 | the wasm artifact and the host move together. |
| 106 | |
| 107 | This is the export `core.go`'s `maxRetainedUpdates` comment already specifies: |
| 108 | "an ABI export that encodes a document's current state as one update… so rebuild |
| 109 | could restore a compacted snapshot plus only the updates since it". |
| 110 | |
| 111 | ### D2 — `doc_snapshots`, and it is never authoritative |
| 112 | |
| 113 | New table (migration `0004`), extending §4's data model with the columns that |
| 114 | failure handling actually needs: `doc_id`, `seq`, `state`, **`abi_version`**, |
| 115 | `created_at`. |
| 116 | |
| 117 | `seq` names the last update the snapshot includes. `abi_version` is what makes |
| 118 | the failure table's "from an older ABI → discarded" implementable at all; without |
| 119 | it the row records nothing about what produced it, and the rule is unenforceable |
| 120 | prose. |
| 121 | |
| 122 | Two schema decisions the implementer would otherwise have to invent, settled |
| 123 | here. `(doc_id, seq)` is **not** unique: under §D3's before-apply placement two |
| 124 | concurrent boundary POSTs can legitimately produce snapshots at the same seq, and |
| 125 | they are equivalent by construction, so a collision is not an error to raise. |
| 126 | "Newest usable" therefore means **highest `seq`, then highest `id`**, so a |
| 127 | duplicate resolves deterministically to the last one written. |
| 128 | |
| 129 | The migration adds a table and touches no existing row, which is the whole of its |
| 130 | effect on documents that already exist — migration `0003`'s own comment sets the |
| 131 | precedent that this is spec-level material rather than an implementation detail. |
| 132 | |
| 133 | `state` is plaintext for now, exactly as `doc_updates` is; sealing (§2) is its own |
| 134 | work. One honest note it does change, though: a `doc_updates` row is a fragment of |
| 135 | an edit, while a snapshot row is **the entire document in a single plaintext |
| 136 | blob**. That is a materially fatter target in the same threat model, and worth a |
| 137 | sentence in the sealing work rather than a redesign here. |
| 138 | |
| 139 | **A snapshot is an accelerator, never the source of truth.** The log remains |
| 140 | complete and authoritative. Anything unreadable, corrupt, or produced by an older |
| 141 | ABI is discarded and the full log replayed instead — slower, always correct. This |
| 142 | is the property that makes the whole slice safe to ship: the worst failure is a |
| 143 | performance regression, never wrong content. |
| 144 | |
| 145 | ### D3 — Taken from the replayed state, before the new update is applied |
| 146 | |
| 147 | `Store.ApplyUpdate` replays the log into a `Handle`, applies the incoming update, |
| 148 | then persists it. The snapshot is taken from **the replayed state, before |
| 149 | `Apply`**, at `seq` = the highest seq that replay actually read. |
| 150 | |
| 151 | The obvious placement — after a successful write, snapshotting the handle that |
| 152 | just accepted the update — is wrong twice, and both ways were found in review |
| 153 | rather than in production: |
| 154 | |
| 155 | **It can record a state that is missing a committed row.** The replay runs |
| 156 | *outside* `ApplyUpdate`'s transaction; only MAX(seq)+INSERT is serialised by the |
| 157 | single-connection write pool. Two concurrent POSTs to one document — the flush |
| 158 | timer racing the `beforeunload` beacon, a pair the live-sync design already names |
| 159 | as real — both replay rows 1..10; A commits seq 11, B commits seq 12. B's handle |
| 160 | holds 1..10 plus its own update and *not row 11*. A snapshot written there claims |
| 161 | seq 12 while missing row 11's content, and §D5 cannot catch it, because it |
| 162 | compares the candidate against the same stale handle that produced it. Every |
| 163 | later render and every §D7 response would then be missing that content, with |
| 164 | `Docs-Seq: 12` genuinely lying about the bytes in hand — the live-sync design's |
| 165 | D2 failure re-entering through a seq column instead of a `MAX(seq)` query. It |
| 166 | would heal at the next cadence boundary 256 updates later, and never if the |
| 167 | document went idle first. |
| 168 | |
| 169 | **And it never rescues a document that is already at the cap** — the case §1 |
| 170 | opens with, and the one that may exist in production now. A capped document |
| 171 | replays to exactly `maxRetainedUpdates`, so `Handle.Apply` returns |
| 172 | `ErrUpdateLogFull` *before* any update succeeds. Snapshot-after-success is never |
| 173 | reached, and the document 400s forever. |
| 174 | |
| 175 | Taking it from the replayed state fixes both by construction. The replayed prefix |
| 176 | is complete — seq is gapless by MAX+1, and replay read every row that existed |
| 177 | when it ran — so the seq column is unconditionally truthful. And the read happens |
| 178 | against a fully usable handle whether or not the incoming update will be |
| 179 | accepted, so the first POST to a capped document writes a snapshot at seq 4096, |
| 180 | still fails once with a 400, and the client's own retry replays snapshot plus an |
| 181 | empty tail and succeeds. One visible failure, then healed. |
| 182 | |
| 183 | **Still on the write path, and still never on a timer.** §7.3's rule is that a |
| 184 | hibernating instance runs no timers, so a background snapshotter stops working |
| 185 | exactly when a document goes idle enough to be worth snapshotting. A request |
| 186 | means the instance is awake by definition. |
| 187 | |
| 188 | Two costs to state. The boundary POST pays a state export, §D5's verification and |
| 189 | one INSERT, all bounded by the existing guest-call timeout — which now indirectly |
| 190 | bounds snapshot size: a document whose export or re-apply exceeds that timeout |
| 191 | traps every attempt, never gets a snapshot, and pays a rebuild penalty per |
| 192 | attempt. `WithGuestCallTimeout` is the existing knob if that ever bites. And once |
| 193 | the distance reaches the cadence, *every* subsequent POST retries until one |
| 194 | succeeds — not "the next Nth write". |
| 195 | |
| 196 | ### D4 — Cadence of 256 updates |
| 197 | |
| 198 | Replay is then at most one snapshot plus 256 updates, and the retained-update |
| 199 | count in a `Handle` stays around 16× clear of `maxRetainedUpdates`. |
| 200 | |
| 201 | **Conditionally, and the condition belongs in writing.** That bound holds only |
| 202 | while snapshot writes keep succeeding. Roughly fifteen consecutive cadence |
| 203 | failures — each logged at Error, so noisy rather than silent — walk the tail back |
| 204 | to the cliff. Before D8 that is where this section ended: the next replay past |
| 205 | that point met `ErrUpdateLogFull` inside `Store.replay` itself, for a read exactly |
| 206 | as for a write, and the failure table's "correct, possibly slower" bar was not |
| 207 | met. D8 closes that: a tail replay that meets the cap re-seeds a fresh handle from |
| 208 | its own state, in memory, and carries on, so reaching the cliff this way no longer |
| 209 | ends a read, or the write riding the same replay. What is left of the condition is |
| 210 | cost, not failure — a re-seed on every replay until a persisted snapshot finally |
| 211 | succeeds and this section's 16× bound is restored. The cliff is removed in every |
| 212 | ordinary case and merely deferred to a per-replay cost in a persistently failing |
| 213 | one, which is a weaker claim than "retired" and the true one. |
| 214 | |
| 215 | The cadence distance is read from the snapshot row §D6's replay already loads, |
| 216 | not from a separate query — the same discipline as the live-sync design's D2, for |
| 217 | the same reason. |
| 218 | |
| 219 | A byte-size trigger (§4.1's "or M bytes, whichever first") is deliberately not in |
| 220 | this slice: with no coalescing yet, one update is one debounced burst of typing, |
| 221 | so update count and bytes track each other closely enough that a second trigger |
| 222 | would add a knob nobody can yet set from evidence. |
| 223 | |
| 224 | ### D5 — A snapshot is verified before it is trusted, inside the core |
| 225 | |
| 226 | Before persisting, the candidate is applied to a fresh document *inside the Rust |
| 227 | core* — no second wasm instance, no second host-side handle — and three things |
| 228 | are compared against the document it was taken from: |
| 229 | |
| 230 | - **state vector equality**, as a Rust struct comparison; |
| 231 | - **delete set equality**, likewise; |
| 232 | - **`fragment_json` equality**, not rendered text. |
| 233 | |
| 234 | Rust-side struct comparison avoids depending on encoding determinism, which does |
| 235 | not hold: update encoding is not canonical, so no cheap byte-level proof exists |
| 236 | and the spec should stop wishing for one. Comparing structure rather than text |
| 237 | also catches formatting-only divergence, which a text comparison misses at |
| 238 | identical cost. |
| 239 | |
| 240 | **This is not a proof, and it is the right place to stop.** What survives it is |
| 241 | "yrs corrupted content while preserving every clock range, every deletion, and |
| 242 | the full structural render", which is a narrow enough residual to accept. |
| 243 | |
| 244 | What it can never catch is a snapshot taken from a stale state, because that |
| 245 | compares a candidate against the very document that produced it and they agree |
| 246 | perfectly. That failure is closed by §D3's placement, not here — a comparator |
| 247 | cannot detect a truthful copy of the wrong thing. |
| 248 | |
| 249 | ### D6 — `Store.replay` reads snapshot-then-tail |
| 250 | |
| 251 | Load the newest usable snapshot, apply it, then apply only `seq > snapshot.seq`. |
| 252 | Falls back to a full replay when there is no snapshot or it fails D2's usability |
| 253 | check. |
| 254 | |
| 255 | ### D7 — `GET /d/{sqid}/updates` serves snapshot-then-tail |
| 256 | |
| 257 | When the requesting cursor is behind the newest snapshot, the response is that |
| 258 | snapshot followed by the updates after it, instead of every row since the cursor. |
| 259 | |
| 260 | This needs **no client change**. `applyServerLog` already loops `Y.readUpdate` |
| 261 | over a concatenated body, a snapshot is an update, and Yjs integration is |
| 262 | idempotent — a client that asks `?since=5` and receives a snapshot covering |
| 263 | through 256 has lost nothing, because the snapshot contains rows 6-256. |
| 264 | |
| 265 | Two things it fixes beyond tidiness: initial load stops being O(all history), and |
| 266 | `maxLogBytes` (8MB) stops being a second cliff a long-lived document eventually |
| 267 | hits. |
| 268 | |
| 269 | **`Docs-Seq` keeps its meaning and its rule.** It is still the highest seq the |
| 270 | returned bytes actually reach, still derived from what is in hand and never from |
| 271 | a separate `MAX(seq)` query — the snapshot's own `seq` column is a property of the |
| 272 | row being sent, not a second question asked of the database. The live-sync |
| 273 | design's D2 (empty slice echoes `since`) and D3 (cursor+1) are untouched. |
| 274 | |
| 275 | The endpoint's own doc comment must stop describing the response as the raw log, |
| 276 | because it will no longer be one. |
| 277 | |
| 278 | Select the snapshot row first and the tail as `seq > snapshot.seq`. The reverse |
| 279 | order is also loss-free, since overlap is idempotent, but one order should be |
| 280 | stated so two readers do not each pick their own. |
| 281 | |
| 282 | **The one place §D2's safety story does not reach, stated rather than papered |
| 283 | over.** `Store.replay` can discard a bad snapshot because it applies it and sees |
| 284 | the failure. `updatesGet` cannot: it serves the blob without a core call, so a row |
| 285 | corrupted on disk *after* §D5 verified it goes straight to the browser, where |
| 286 | `Y.readUpdate` throws mid-loop and the resync controller refetches the same bytes |
| 287 | forever. Server-side rendering keeps working; the editor becomes permanently |
| 288 | unloadable for that document. That contradicts both "the worst failure is a |
| 289 | performance regression" and "this needs no client change", so this design pays for |
| 290 | it: **`?raw=1` forces the pre-snapshot behaviour**, returning only `doc_updates` |
| 291 | rows, and `applyServerLog` refetches with it once when a decode throws. Small, and |
| 292 | it makes the log's authority reachable from the one reader for whom it matters |
| 293 | most. |
| 294 | |
| 295 | ### D8 — A tail replay past the cap re-seeds in memory rather than failing |
| 296 | |
| 297 | Found after this design was reviewed, in a task review of its implementation: |
| 298 | what happens when `Store.replay` has no usable snapshot to start from — D2's |
| 299 | ordinary "discarded, full replay" story for a corrupt row, a stale one, or (the |
| 300 | one that will actually happen) every row at once on the deploy that ships the |
| 301 | next ABI bump, since `newestSnapshot`'s exact-ABI match invalidates them all |
| 302 | together — and the log it falls back to is longer than |
| 303 | `core.MaxRetainedUpdates`? Full replay is precisely what §1 opens by naming as |
| 304 | already unsafe past that count: it is the cliff `Handle.Apply` was built to stop |
| 305 | at, met this time from inside `Store.replay`'s own tail loop instead of from a |
| 306 | client's POST, and reached with no snapshot in hand to shorten the log that |
| 307 | triggers it. |
| 308 | |
| 309 | §3's property closes it the same way it closes everything else here: a snapshot |
| 310 | is nothing but a handle's own state, exported, and applying it to an empty |
| 311 | document reproduces the handle. `Store.replay`'s tail loop uses that on itself. |
| 312 | When applying a row meets `core.ErrUpdateLogFull`, it takes that same export of |
| 313 | the handle it already has — mid-loop, via `Handle.Snapshot` — opens a fresh |
| 314 | handle from the document's own stored GUID and client id (never a new identity; |
| 315 | see Document), applies the export, and continues from the row that was refused, |
| 316 | which is re-applied into the fresh handle and not dropped. `reseedForReplay` in |
| 317 | `internal/docs/store.go` is the mechanism; its call site is the one place in the |
| 318 | tail loop that catches `core.ErrUpdateLogFull` rather than surfacing it. A log of |
| 319 | any length gets through in as many re-seeds as it needs, and what a replay |
| 320 | retains at once stays bounded by the cap regardless — the same bound §D4 relies |
| 321 | on, just re-established mid-replay instead of assumed for it. |
| 322 | |
| 323 | **It writes nothing**, deliberately, and this is the one place that needs saying |
| 324 | twice: `Store.replay` is the read path (Text, HTML and JSON all reach it), and a |
| 325 | read that persisted a row would be a materially different change — the one place |
| 326 | today's design allows a snapshot to originate is a request that has already |
| 327 | proved the instance awake (D3), which a plain read has not. The persisted |
| 328 | snapshot this document is still owed arrives the ordinary way, on its next |
| 329 | `ApplyUpdate`: crossing the cap here means the gap already exceeds |
| 330 | `snapshotCadence` many times over, so that write's own cadence check (D3, D4) |
| 331 | passes trivially and it snapshots at the current ABI — which is exactly what an |
| 332 | ABI bump needs repaired, and the reason this stays self-healing rather than |
| 333 | becoming a second, competing writer to reason about. |
| 334 | |
| 335 | **It logs at Warn, once per re-seed** — not Error, and not silent either, and |
| 336 | both halves of that were argued from evidence rather than assumed. Not Error, |
| 337 | because nothing is wrong with the document: it is complete and correct, only |
| 338 | slower to read, exactly the distinction D4 draws between a cliff and a cost. Not |
| 339 | silent, which is what this was at first, on the reasoning that the document's |
| 340 | next write repairs it — reasoning that assumes a write arrives. Reads write |
| 341 | nothing, so a long document that is finished and only ever read again, which is |
| 342 | most of what an ABI bump invalidates at once, would re-seed on every single read, |
| 343 | forever, with degraded latency as the only trace. One line per read is the price |
| 344 | of that not being invisible to an operator. |
| 345 | |
| 346 | ## 5. What does not change |
| 347 | |
| 348 | The wire format (a snapshot is an ordinary Yjs update); the browser bundle; the |
| 349 | cursor protocol; the stream and its hub; ownership scoping; `doc_updates` itself, |
| 350 | which keeps every row it has. |
| 351 | |
| 352 | ## 6. Failure modes |
| 353 | |
| 354 | | Failure | Behaviour | |
| 355 | |---|---| |
| 356 | | No snapshot exists | Full replay, as today | |
| 357 | | Snapshot row unreadable or from an older ABI | Discarded, full replay, logged | |
| 358 | | D5's verification fails | Snapshot not written, log untouched, logged at Error | |
| 359 | | Snapshot write fails after the update committed | Update stands, no snapshot, next Nth write tries again | |
| 360 | | Core call traps producing the snapshot | Same as above; the update is already durable | |
| 361 | | A full replay (no usable snapshot) crosses `core.MaxRetainedUpdates` mid-tail | Re-seeds a fresh handle from its own state, in memory, and continues; writes nothing, logged at Warn per re-seed (D8) | |
| 362 | |
| 363 | Every row of that table ends in "correct, possibly slower", which is the bar this |
| 364 | design sets for itself. |
| 365 | |
| 366 | ## 7. Non-goals |
| 367 | |
| 368 | The compaction horizon and any deletion of rows; coalescing before persistence; |
| 369 | sealing `state`; named versions (§14), which will need to pin points a future |
| 370 | horizon must not collapse; the WebSocket transport; presence. |
| 371 | |
| 372 | ## 8. Testing |
| 373 | |
| 374 | - **Equivalence is the central test.** For a document with more updates than the |
| 375 | cadence, the text and HTML produced through snapshot-then-tail must equal what a |
| 376 | full replay produces, byte for byte. Property-shaped rather than one fixture: |
| 377 | several documents, several cadences, including a snapshot at the exact boundary. |
| 378 | - **The cap stops being reachable.** Apply more than `maxRetainedUpdates` updates |
| 379 | to one document and assert it still accepts writes — the regression test for the |
| 380 | cliff this exists to remove. It should fail against `main` today. |
| 381 | - **A document that is ALREADY at the cap recovers**, which the test above does |
| 382 | not cover: driven through the new path a document never reaches the cap, so the |
| 383 | production-shaped case needs its own fixture — a log of `maxRetainedUpdates` |
| 384 | rows and no snapshot, asserting one 400 followed by a successful retry. This is |
| 385 | §D3's second defect, and without this test the fix for it is unverified. |
| 386 | - **The concurrent boundary race** (§D3's first defect): two POSTs racing at the |
| 387 | cadence boundary must never produce a snapshot whose seq outruns its content. |
| 388 | Assert against the rendered document, not against the snapshot row, since the |
| 389 | row is exactly what a stale snapshot would make look right. |
| 390 | - **`?raw=1` returns only `doc_updates` rows**, and a client that hits a decode |
| 391 | failure recovers through it. |
| 392 | - **Fallbacks are exercised, not assumed:** a corrupted snapshot row, a snapshot |
| 393 | from a wrong ABI, and a missing snapshot each still produce correct content. |
| 394 | - **D5's verifier rejects a bad snapshot** — constructed by hand, since the core |
| 395 | will not produce one on request. |
| 396 | - **The GET's cursor contract survives:** `?since=` behind, at, and ahead of the |
| 397 | snapshot; `Docs-Seq` still derived from bytes in hand; the live-sync suite green |
| 398 | unchanged. |
| 399 | - **Wire fixtures unchanged.** They are the contract, not an output. |
| 400 | |
| 401 | ## 9. Risks |
| 402 | |
| 403 | 1. **The ABI bump is a coordination point.** `internal/core`'s check is |
| 404 | exact-match, so a stale wasm artifact fails at load. Anyone rebasing across |
| 405 | this rebuilds the artifact. |
| 406 | 2. **A wrong snapshot is the worst bug this repo could ship** — silently wrong |
| 407 | content for every future reader, with the log that would prove it still sitting |
| 408 | there unread. D2's never-authoritative rule and D5's verification exist for |
| 409 | that one risk, and the review should be hardest on exactly that. |
| 410 | 3. **Storage still grows, and this slice adds to it.** §2 states the shape and the |
| 411 | supersession rule that keeps it to one blob per document. The log itself is |
| 412 | untouched and unbounded until the horizon lands. |
| 413 | 4. **`maxLogBytes` is bounded against history, not against size.** Snapshot-then- |
| 414 | tail means a long-lived document no longer sends its whole past, but a document |
| 415 | whose *current state* exceeds 8MB still fails the GET — with the snapshot in |
| 416 | the body. Different cliff, further away, not removed. |
| 417 | |
| 418 | ## 10. Review record |
| 419 | |
| 420 | Reviewed by **Fable** on 2026-09-01, at the spec stage, per AGENTS.md. Verdict: |
| 421 | approve with changes; all folded in above. |
| 422 | |
| 423 | Two were correctness defects with the same root — where the snapshot is taken |
| 424 | from. Snapshotting the handle that just accepted an update can record a state |
| 425 | missing a concurrently-committed row (§D3), and can never rescue a document |
| 426 | already at the cap, which is the case §1 opens with. Taking it from the replayed |
| 427 | state before `Apply` closes both by construction. |
| 428 | |
| 429 | The rest: the exact `yrs` call named along with the two neighbours that look |
| 430 | right and are not (§3); §D5 moved inside the core and strengthened from a text |
| 431 | comparison to state-vector, delete-set and structural equality, with its honest |
| 432 | limit stated; the `abi_version` column without which the failure table's own rule |
| 433 | is unenforceable (§D2); the `?raw=1` escape hatch for a corrupt snapshot reaching |
| 434 | a browser that has no way to refuse it (§D7); the cap's retirement restated as |
| 435 | conditional on snapshot writes succeeding (§D4); and the supersession ruling that |
| 436 | keeps this from making storage worse than the problem it solves (§2). |
| 437 | |
| 438 | The review also confirmed what §3 rests on: against an empty state vector, |
| 439 | `encode_state_as_update_v1` is merge-equivalent to the log and not merely |
| 440 | render-equivalent, so a snapshot is safe to build future concurrent merges on. |
| 441 | |
| 442 | **§D8 was added after this review**, not folded into it: a finding from a later |
| 443 | task review of the implementation, that a full replay falling back to no usable |
| 444 | snapshot on a log already past `core.MaxRetainedUpdates` met `ErrUpdateLogFull` |
| 445 | inside `Store.replay` itself with no way out, which is the ABI-bump case this |
| 446 | same design's D2 and §9's first risk both name as the thing that will actually |
| 447 | happen. The fix — re-seeding a fresh handle from the replay's own state, in |
| 448 | memory, mid-tail — was implemented and tested before this section existed; §D8 |
| 449 | is this spec catching up to what the code already does, not a proposal. |
| 450 | |