Sync Engine — Flow Atlas

Every end-to-end flow in the new canvas sync engine, drawn as a diagram, with the questions you're likely to ask answered underneath. Faithful to the closed design in spike/ — nothing here is proposed, it's all decided.

design CLOSED · rev 5 · 2026-07-16 rulings R1–R35 applied sources: spike/lld/* + decision-record §10–§13

00 · What this is, in one diagram

spike/README.md · fe-assembly §1

We're replacing Yjs (a CRDT) as the store for structured canvas data — artboards, layers, objects, sections, tracks, clips. Structured data moves to server-authoritative Postgres rows with per-field last-writer-wins, an IndexedDB cache + outbox for offline/optimistic edits, and a per-scope commit counter (syncId) as the delivery cursor. Rich text stays Yjs; presence stays Hocuspocus awareness. The engine is collection-agnostic: canvas objects are collection #1, workspace assets are #2.

flowchart LR
  subgraph FE [Browser tab]
    G[gesture overlay] --> P[pool: effective = base + pending + gesture]
    O[(IDB outbox)] --> P
    B[(IDB base)] --> P
    P --> V[WebGL view]
    P --> H[undo history]
  end
  subgraph BE [Backend]
    T[POST /txn] --> C[(sync_counter)]
    T --> J[(journal = receipt = /since source)]
    J --> S[GET /since]
    R[(Redis)] -. bump .-> SSE[SSE /stream]
  end
  O -- POST commands --> T
  S -- envelopes --> B
  SSE -. pull trigger .-> S
  
Two stacks — structured data on the engine; Yjs text + awareness presence stay on a demoted CanvasSyncService One command contract — one user intent → one commandId → pending | accepted | rejected, atomic on the server
Questions
Why leave Yjs at all for structured data?

Structured canvas edits are absolute-value writes (move, resize, recolor) where "last writer wins per field, in server commit order" is the behavior we actually want. A CRDT's merge semantics buy nothing there, and the current Yjs path gave us silent divergence classes, a 673 ms load stall on big canvases, and no server-side source of truth to reason about. Text keeps Yjs because character-level concurrent editing is exactly what CRDTs are for.

Is any of this speculative?

No. The design went through five review revisions (an adversarial audit, three review/grill rounds, a code-grounded verification pass). Every open question was ruled and recorded as binding decisions R1–R35 in spike/hld/decision-record.md. Deliberate gaps are tracked in named-not-built.md with explicit triggers for when to build them.

What's a "scope"?

The unit a cursor and counter attach to. v1 has two live tiers: canvas:{id} (objects) and workspace:{id} (assets only — R32). An org tier is type-admitted but not opened in v1. Each scope has its own gap-free server counter and its own client cursor.

01 · Glossary — the eight words everything else uses

wire-contract §1 · apply-loop §1 · write-path §0

TermMeaning
syncIdPer-scope, gap-free, server-assigned commit number. One primitive, two jobs: per-row LWW ordering and the catch-up cursor. Never a client clock.
commandOne user intent: {commandId (uuidv7), ops[]}. Atomic on the server — all ops commit or the whole command rejects. At most one op per row id (R3).
opcreate (full row) · update (changed keys only) · delete · reparent (distinct so the server can cycle-check).
baseThe local IDB mirror of server-confirmed rows. Only /since and hydrates write it, always syncId-gated.
outboxIDB store of not-yet-confirmed commands {commandId, ops, createdAt}. Survives crashes; drained receipt-idempotently.
effectiveWhat the renderer sees: base ⊕ pending(outbox) ⊕ gesture, topmost layer wins per field. Recomputed synchronously on any base write (never-flash).
envelopeA journaled action {syncId, actorId, commandId, ops[]}. The journal is simultaneously the receipt store and the /since feed.
tombstoneA deleted row kept 7 days (deleted: true). Updates to it are accepted no-ops (R1); a full-row create on it revives it (R2 — how undo of delete works).

02 · Cold open — first load of a canvas

bootstrap §2 · fetch-core §3–§4 · R25

Nothing local yet. The goal: paint the frames you're looking at in well under a second, then fill everything else in the background until the whole canvas is resident, then catch up on anything that changed while we were filling.

sequenceDiagram
  participant U as User
  participant FE as Tab (engine)
  participant BE as Backend
  U->>FE: open canvas
  FE->>BE: GET /canvas/:id/id-tree
  BE-->>FE: skeleton: sections + artboards, LOCAL bboxes (brotli, ~250KB)
  Note over FE: anchor = max(node.syncId)
  Note over FE: chunked R-tree insert across frames → FIRST PAINT
  FE->>BE: POST /objects/batch (viewport ids first, ≤100 ids / 256KB, ≤5 in flight)
  BE-->>FE: rows (tombstones included for deleted ids)
  Note over FE: pool ← rows → progressive render, pixels via CDN from assetRef.storage
  loop until whole canvas resident
    FE->>BE: POST /objects/batch (background fill, re-sorted on pan)
  end
  FE->>BE: GET /since?cursor=anchor
  BE-->>FE: everything that changed during the fill
  Note over FE: caught up — SSE was live the whole time
  
R25 — geometry is relative to the parent; the client composes world transforms. The server does zero bbox work. anchor pass — one /since from the skeleton's max syncId closes the fill race accepted — a half-filled artboard is a valid transient state
Questions
Why fetch the whole skeleton in one shot instead of paging it?

The payload is extremely repetitive (uuids, enums, bbox tuples) so brotli crushes ~1 MB to ~250 KB — a sub-100 ms transfer. Paging increases total bytes (per-request overhead, N round-trips). The real historical stall was never the fetch: it was constructing the WASM R-tree synchronously on the main thread (the 673 ms class). That's fixed client-side by chunking the insert across idle/rAF frames, viewport first. Camera-ordered server paging exists as a deferral (#6) if first-paint still misses on the 7k-artboard canvas.

What if someone deletes an artboard while my canvas is still filling?

Two guards. Live SSE + /since run during the fill, so the delete arrives as a normal envelope. And because a delete can arrive for a row the tab hasn't loaded yet, the engine materializes a tombstone entry in base (R28) so a stale in-flight hydrate of that row loses the per-row syncId comparison and can't resurrect a ghost.

Does first paint wait on asset metadata?

No. assetRef.storage on the row is render-authoritative — the renderer builds the CDN URL straight from it. The asset record (name, status, provenance) syncs in parallel through the workspace collection, entirely off the paint path.

Will holding a whole 22k-object canvas in memory blow up mobile?

That's a named bet with a measured gate, not a hope: benchmark gate C fails the design if the pool exceeds ~150 MB V8 heap at 22k items on a low-end phone, and gate D fails it if any frame exceeds 16.6 ms during skeleton construction. The fallbacks are pre-chosen: app-level LRU eviction (#5) and camera-ordered paging (#6). No number, no debate.

What decides which rows load first?

A flat viewport-intersection scan over the skeleton bboxes — in-view ids to the head of the queue, the rest in document order. The queue only orders, never drops: everything eventually loads. A pan mid-fill re-sorts only the remaining queue. No priority-queue or second R-tree structure — a 7k-bbox scan is sub-millisecond (#9 if that ever janks).

03 · Warm open — coming back the next day

bootstrap §3 · R14 · R27 · R9

IDB still holds yesterday's rows, cursor, and possibly unsent work. Paint from local data instantly, push any pending commands, verify freshness, pull the delta.

flowchart TD
  A[open canvas] --> B[replay IDB → pool: instant paint]
  B --> C{outbox has pending entries?}
  C -- yes --> D{entry older than 48h?}
  D -- yes --> E[drop + toast + Sentry — never POSTed · R27]
  D -- no --> F[re-POST all entries, oldest first — receipts make retries no-ops]
  C -- no --> G
  E --> G[GET /checksum — gated · R9]
  F --> G
  G --> H{matches local?}
  H -- yes --> I[GET /since from saved cursor → apply → resume background fill]
  H -- no --> J[forced /since → still off? cold reload + Sentry]
  
R14 — pending outbox entries are resolved before first interactive paint, so a committed-but-undeleted entry can't mask newer remote edits R27 — the 48h expiry gate is client-side (own clock vs own clock — skew-immune) and runs at every drain site
Questions
Why is there no id-tree re-fetch on warm open?

The skeleton is durable in IDB. Freshness is covered by checksum + /since; completeness by the resumed background fill. Re-fetching the tree would buy nothing the /since delta doesn't already deliver.

What if the browser evicted my IDB overnight?

Two distinct failures, two distinct answers. A missing row (partial data) → a coalesced by-id batch fetch, business as usual. A missing cursor (Safari's 7-day rule and quota eviction are origin-wide, so this is the real signature) → cold bootstrap for that scope, because there's no baseline for /since to diff against. Never a full re-bootstrap for one missing row.

Why does checksum get skipped if the outbox isn't empty?

Pending creates would make the local row count legitimately differ from the server's — the comparison would false-positive and cold-reload a user who's just holding offline work. R9 gates it; and per R30 the skipped tick isn't wasted — it drains the outbox instead, which also clears the reason the checksum couldn't run. A stuck entry can't suppress the drift canary forever.

04 · Normal edit — a drag, committed and echoed

write-path §0–§3 · fe-assembly §6 · apply-loop §2

The golden path: durable state changes exactly once, at pointer-up. During the drag, remote viewers see live motion through presence (awareness), not through the database.

sequenceDiagram
  participant A as Client A
  participant BE as Backend
  participant B as Client B
  Note over A: pointerdown → gesture overlay (memory only)
  Note over A: pointermove → overlay updates; motion broadcast via awareness
  Note over A: pointerup → build ONE command (≤1 op per id, R3)
history {prev,next} · outbox put · overlay promoted, no flash A->>BE: POST /txn {commandId, ops} Note over BE: receipt? → validate → counter+1 → stamp rows → journal → commit BE-->>A: accepted {syncId} BE--)B: SSE bump (scope-tagged) B->>BE: GET /since BE-->>B: envelope {syncId, actorId, commandId, ops} Note over B: apply → base → PoolChange → markDirty → render A->>BE: GET /since (own echo) Note over A: base write + outbox delete (cursor ≥ receipt syncId, R14)
overlay drops — pixels identical
R3 — command builders coalesce to at most one op per row; reparent+resize is one op with a patch reject path — drop the overlay keyed by commandId; base was never touched, so a concurrent remote edit survives automatically. No pre-image, no compensating command. Rejects are not sticky (R7). accepted — the IDB outbox put is async; a sub-millisecond crash window can lose one unpersisted pending edit
Questions
Why not write to the database during the drag?

A 60 fps drag would be a write storm for state nobody needs durably — the intent is only real at release. Remote users still see the motion live because the transform rides the awareness channel (same as today's throttled preview, minus the 100 ms structured write that's being deleted). Durable state changes once, at commit.

What happens if the response to my POST is lost?

Nothing bad. The command sits in the outbox until the tab's base cursor passes the receipt's syncId. Any re-POST (this tab or another) hits the receipt keyed (scope, actorId, commandId) and gets the stored response back — the command can never apply twice (§10.3).

If my command is rejected, do I lose other edits?

Only that command's overlay is dropped. Because rollback is "remove the overlay" rather than "write old values back", a teammate's concurrent same-field edit — which lives in base — shows through instead of being clobbered. The matching undo entry is removed atomically, so undo can never replay a rejected command (phantom-free).

I move something into an auto-fit section and the section resizes. How many commands is that?

One. The move, the section's new frame, and every sibling's re-anchor travel as a single atomic command (distinct ids, so R3 holds), which is what makes undo restore the whole arrangement at once. Known pre-existing issue: two clients auto-fitting the same section concurrently can LWW-interleave — today's Yjs has the identical bug; the engine neither adds nor fixes it.

Who computes text box sizes?

Only the editing client (single-writer): it awaits the font load, measures, and commits {text, width, height} as one atomic update. Everyone else reads w/h from the row and never measures — this kills the cross-client measure war between machines with different fonts. A client missing the font may see overflow; accepted.

05 · Conflict — a teammate edits the field I'm dragging

apply-loop §3 (the §10.7 reversal)

You're dragging X (x=100). Mid-drag, a teammate's committed edit arrives setting x=700. The old design suppressed the incoming change and froze the cursor; the shipped design does the opposite — incoming edits always land, and your live gesture simply masks them.

flowchart TD
  A[grab X — gesture overlay masks base] --> B[remote x=700 arrives mid-drag]
  B --> C[lands in BASE · cursor advances to 500 · screen still shows your drag]
  C --> D{you release or cancel?}
  D -- release --> E[gesture → outbox overlay → your commit gets a LATER syncId → your value wins]
  D -- Esc --> F[overlay drops → x=700 shows — teammate's edit was there all along]
  
LWW — same-field collisions resolve by server commit order, per field. Different fields merge cleanly because updates carry only changed keys. plain counter — the cursor never holds back or skips; there is no watermark, no buffer, no suppression machinery
Questions
Won't the incoming edit yank the object out from under my cursor?

No. The renderer reads effective = base ⊕ pending ⊕ gesture and your gesture is the top layer, so base changing underneath is invisible until you let go. The recompute is synchronous with the base write (materialize-on-write), so there's not even a one-frame flash.

What if I crash mid-drag?

The gesture overlay was memory-only, so it vanishes — uncommitted work is supposed to vanish. The teammate's x=700 is already in base and the cursor already advanced past it, so nothing is lost and nothing needs re-pulling. Crash-safety falls out of the layering; there's no recovery code for this case.

Why is "always apply, mask on top" better than suppressing the incoming change?

Suppression creates a hole — a change you've seen but not applied — and then needs four mechanisms to manage it (held cursor, replay buffer, watermark semantics, a drift-check exception). Landing everything means there's never a hole: masking falls out of the existing layer stack and LWW settles the winner. It's also exactly what Yjs today and Linear do, so behavior doesn't change for users.

06 · Inside the server — what /txn actually does

backend §1–§3 · R29 · R15 · R16

One transaction, counter-first (R29). The counter is a plain row per scope; holding its row-lock until commit is what makes allocation order, commit order, and visibility order the same thing — the property the whole cursor model rests on.

flowchart TD
  A[BEGIN] --> B{receipt exists for scope+actor+commandId?}
  B -- hit --> B2[return stored accepted+syncId — never re-apply]
  B -- miss --> C[validate ops — reads only
dup-id = malformed · tombstoned update = no-op R1 · tombstoned create = revive R2] C -- invalid --> C2[reject: not-member / precondition-failed / cycle / stale-reload-required
rollback clean — no journal row, no counter burn] C -- valid --> D[counter: INSERT..ON CONFLICT seq=seq+1 RETURNING — self-seeding R16] D --> E[apply: each row's patch AND sync_id=seq in ONE UPDATE, sorted id order R15] E --> F[journal INSERT — journal = receipt = /since source] F --> G[COMMIT] --> H[Redis publish scope bump]
R29 — counter-FIRST for all commands: one lock order for everything makes reparent-vs-normal deadlocks impossible by construction §10.2 trigger — a DB trigger rejects any canvas_objects write that doesn't advance sync_id (only the server-internal path rewrite is exempt, R12) accepted — every writer to a scope serializes on one counter row; benchmark gate A (p99 ≤ ~30 ms under 10–20 concurrent writers) protects it, with a Postgres-LSN cursor as the named fallback
Questions
Why a counter row and not a Postgres sequence?

A sequence's nextval() is non-transactional: command 502 can commit before 501, a client reads to 502, advances its cursor, and 501 is never pulled — silent loss. The counter row's lock is held to commit, so a higher syncId literally cannot become visible before a lower one; a client that has read to N has provably seen everything ≤ N. Gap-freedom is a free bonus (rollback un-increments; a sequence burns the number).

How is exactly-once guaranteed if two identical requests race?

Both miss the receipt lookup, both proceed, and the second one's journal INSERT hits the UNIQUE constraint on (scope, actor_id, command_id) — it catches, re-reads, and returns the first one's stored response. The receipt lookup is the fast path; the UNIQUE is the backstop.

What about deadlocks on overlapping multi-selects?

All row work applies in sorted id order (R15), so two commands touching the same rows always lock them in the same sequence. Combined with counter-first, the deadlock rate should be zero by construction — the benchmark suite verifies it rather than tolerating a retry budget. Any residual serialization abort retries server-side and is never surfaced as a client reject.

Reparent moves a whole subtree — does every descendant get stamped?

No — only the moved node (R12). The descendant path rewrite is a server-internal concern for its own ltree queries; the FE holds no paths and derives the subtree from its parentId reverse index, so nothing else needs to re-sync. Stamping descendants would desync FE/BE per-row syncIds and false-positive any future row-hash checksum.

Do server-side writers (like doc summaries) bypass all this?

No — there's exactly one audited internal path: a system actor with a synthetic commandId, one counter allocation and one journal envelope per debounced flush. The §10.2 trigger makes bypassing structurally impossible: nothing can write canvas_objects without advancing sync_id.

07 · Drift detection — trust, but checksum

wire-contract §5 · backend §5 · R8 · R9

Every silent-divergence bug class gets one safety net: a cheap periodic comparison that either proves the tab is in sync or heals it. It runs on the SSE idle tick.

flowchart TD
  A[SSE idle tick] --> B{scope outbox non-empty?}
  B -- yes --> C[drain it instead — R30
clears the reason checksum can't run] B -- no --> D[GET /checksum → maxSyncId from sync_counter.seq R8 + rowCount] D --> E{local matches?} E -- yes --> F[in sync — done] E -- no --> G[forced /since] G --> H{now matches?} H -- yes --> F H -- no --> I[cold reload the scope + log to Sentry — the canary]
R8 — maxSyncId reads the counter, never max(sync_id) over rows: a row-max regresses after the 7-day tombstone purge and would cold-reload a quiet canvas forever R9 — rowCount is compared only when the scope's fill completed; by-id-fetched rows outside the synced set never count; a scope too big to fully cache just stays maxSyncId-only
Questions
What does a mismatch actually mean?

Either the tab missed a delta (freshness — maxSyncId axis) or holds the wrong row set (completeness — rowCount axis). Both are self-healing: /since first, cold reload if it persists. Crucially every heal also logs to Sentry — the checksum is the canary that tells us whether any divergence class survives in production, so escalating to a per-row hash (#7) is a data-driven decision, not a guess.

Could the checksum itself be wrong on a lagging read replica?

That trap is designed out: anything that advances a cursor (/since and /checksum) reads the primary, because the SSE bump fires from the primary and a lagged replica would tell the client "you're caught up" about data it hasn't seen — divergence SSE would never re-signal. Only the heavy bootstrap snapshot may use a replica; the anchor /since pass self-heals its staleness (W15).

08 · Reconnect — wifi comes back

fetch-core §5 · R30 · R31 · R27

Reconnection is deliberately boring: reopen the one SSE stream, then do the two unconditional things — pull everything, push everything. No negotiation, no session state.

sequenceDiagram
  participant T as Tab
  participant BE as Backend
  Note over T: network returns
  T->>BE: GET /stream?scopes=workspace,canvas (one SSE, R31)
  Note over BE: membership re-checked per scope (R17)
  Note over T: onopen fires BOTH, unconditionally
  T->>BE: GET /since per scope (W8)
  BE-->>T: envelopes → base → cursor max-advance
  T->>BE: re-POST all pending outbox entries (oldest first)
  Note over T: entries older than 48h: dropped + toast + Sentry (R27)
  BE-->>T: receipts — already-committed entries return stored responses
  Note over T: idle tick resumes its checksum cadence
  
R31 — one multiplexed SSE connection per tab for all open scopes; a scope-set change is just a reconnect, which re-runs this whole flow accepted — two tabs reconnecting together re-POST the same entries; receipts make the duplicates no-ops
Questions
Why unconditional /since on every reconnect instead of trusting the last cursor?

The cursor is trusted — /since pulls from it. What's not trusted is the gap: any bump published while the stream was down is gone (SSE has no replay), so the only safe move is to always pull on open. It's cheap when nothing changed (empty response) and correct when everything did.

Why is the SSE stream data-free? Wouldn't sending the changes save a round trip?

Bump-only keeps push strictly latency-optional — losing an event can delay you, never diverge you, because /since is the single ordered source. Inline payloads (with gap-checking and pull-repair) are specced as a purely additive deferral (#22), triggered only if measured remote-edit latency is actually felt.

09 · Away for two weeks — the retention boundaries

DR §5 · R27 · R35 · bootstrap §3

Three clocks govern long absence: the 48-hour outbox promise (your unsent edits), the 7-day journal/receipt/tombstone floor (the server's replay window), and task retention. Come back inside them and everything replays; come back outside and the design chooses fresh truth over stale replay — loudly, not silently.

flowchart TD
  A[tab reopens after N days] --> B{N under 2 days?}
  B -- yes --> C[warm open: replay IDB → drain outbox → checksum → /since → filled]
  B -- no --> D{cursor within the 7-day journal floor?}
  D -- yes --> E[warm open, but outbox entries over 48h are dropped with a toast — never POSTed · R27]
  D -- no --> F[/since rejects the cursor → COLD BOOTSTRAP: fresh snapshot replaces local rows]
  F --> G[outbox: everything is over 48h → all dropped + toast + Sentry]
  G --> H[generation reconcile skips tasks completed over 7d ago · R35]
  H --> I[user is on fresh server truth]
  
R27 — 48h ≪ 7d closes the exactly-once hole: no well-behaved client can re-POST a command whose receipt was already pruned accepted, by design — edits made offline and not synced within 48h are lost, with notice. That's the announced offline promise, not an accident.
Questions
Why drop my old offline edits instead of trying to apply them?

Two-week-old ops replayed as fresh last-writer-wins would silently overwrite everything your teammates did since — stale work masquerading as new intent. Conditional apply (only if the captured preconditions still hold) is specced as a deferral, but the honest default is: drop, tell the user, log it. And the gate is the client's own clock compared against itself, so clock skew can't lock anyone out.

Why does generation reconcile stop at 7 days (R35)?

Reconcile re-creates placements for finished slots that have no local row and no tombstone. Tombstones purge at 7 days — beyond that, the record that you deliberately deleted a placement is gone, so reconcile would resurrect work you undid. Aligning the reconcile window with the tombstone window makes that impossible; older tasks are simply settled.

Does the user lose anything that was already synced?

No. Everything that reached the server is in the rows; the cold bootstrap re-downloads it. Only the never-sent outbox tail is affected, and only past 48 hours.

10 · Multi-tab — two tabs, zero coordination

write-path §2 · fe-assembly §2.3 · R30 · R11 · R13

v1 is fully leaderless: no elected leader, no locks, no cross-tab message bus. Each tab is an independent client of an already-multi-client engine; correctness comes from server receipts and monotonic base writes, not from coordination.

flowchart LR
  subgraph T1 [Tab 1]
    A1[own SSE] --> A2[own /since → own in-memory cursor R11]
    A3[POST own commands immediately]
  end
  subgraph T2 [Tab 2]
    B1[own SSE] --> B2[own /since → own cursor]
    B3[POST own commands immediately]
  end
  subgraph shared [Shared]
    O[(one IDB: base + outbox + cursor seed)]
  end
  T1 --- O
  T2 --- O
  O --> D[any tab re-POSTs ALL pending entries at 4 triggers:
instance start · offline→online · SSE onopen · idle tick when dirty]
R30 — the Replicache pattern: no deadness signal at all; any live tab re-pushes any persisted pending entry; receipts are the sole correctness R11 — each tab's pull cursor is in-memory ("max syncId applied to MY pool"); the IDB copy is only a warm-open seed accepted — tab B does not see tab A's optimistic (uncommitted) edit; it arrives after commit via B's own /since. Two tabs behave like two devices.
Questions
A tab crashes with unsent commands in the outbox. Who sends them?

Whichever tab hits one of the four triggers next — or the next session's first tab at instance start. The outbox is durable and commandId-keyed, POSTs are receipt-idempotent, so anyone can safely drain everything. No one needs to know the origin tab died; there is deliberately no liveness detection.

Why was the per-scope drain lock removed?

Because holding a Web Lock is not proof of life — a suspended iOS tab keeps its locks while draining nothing, starving the queue — and a held or queued lock blocks bfcache in Chrome. Worse, the outbox has no ownership column, so "drain only your own" was never even definable across sessions. Locks added failure modes and solved nothing receipts didn't already solve.

Isn't N tabs doing N× the work wasteful?

Yes — redundant, never incorrect, and only when a user actually opens N tabs. The leader (one tab owns the network, others follow over a broadcast bus) is fully designed and parked as deferral #15; it bolts on additively by gating SSE/base-writes on isLeader. It gets built when the measured N-connection cost bites, not before.

Why can't tabs share one durable cursor?

Because the cursor means "what my pool has applied." If tab A pulls 501–510 and advances a shared cursor, tab B — which never applied them — would skip that range and render stale forever, invisibly to the checksum. Per-tab in-memory cursors make that impossible; the shared IDB value is only a starting seed, advanced with max().

11 · Undo / redo — forward commands, absolute values

history-layer · DR §3 · R1 · R2

Undo is not time travel — it's a brand-new forward command that writes back the captured previous values of exactly the fields the original command touched. Remote observers can't even tell it's an undo.

flowchart TD
  A[edit committed] --> B[history entry: per field, absolute prev AND next, one place]
  B --> C{user presses undo}
  C --> D[submit NEW forward command carrying prev values — fresh commandId]
  D --> E[settles by ordinary syncId LWW — deliberately wins same-field collisions]
  E --> F{redo?}
  F -- yes --> G[submit forward command carrying next values]
  B -.-> H[structural: create↔delete by snapshot · delete undone = create SAME id = revive R2]
  
absolute, never deltas — replaying x += -delta on a remotely-changed base yields garbage; setting x = prev is deterministic under concurrency one gesture = one command = one undo step — multi-select transforms, paste, reparent+resize all collapse to a single entry
Questions
My teammate deleted the object I'm trying to undo an edit on. What happens?

The undo's update lands on a tombstoned row and is an accepted no-op (R1) — delete wins, and crucially the rest of your multi-object undo command still applies. If instead your undo is undoing a delete, the full-row create on the tombstoned id revives it (R2) — that's intentional forward-LWW, the same spirit as "undo can deliberately win a collision."

Does undo rewind my teammate's unrelated changes?

No. The undo command carries only the fields the original command touched. A teammate's edit to a different field merges untouched; a teammate's edit to the same field is deliberately overridden — that's the chosen model (no rebase, no operational transforms), and it's recorded as such.

Does my undo history survive a reload?

No — it's per-tab, in-memory UX state, trimmed at a max stack size, gone on reload. Persisting it is a named deferral with a trigger ("users demand undo-after-reload"), not an oversight.

12 · Generation — server makes assets, client places them

generation.md · DR §10.8 · R19 · R35

The server owns the durable generation task, the asset identities, and production (never cancelled). The requesting client owns the placements — it creates canvas objects via ordinary commands as results announce, keyed by the server-minted slotId so every path is idempotent.

sequenceDiagram
  participant C as Client
  participant BE as Backend (task)
  participant P as Provider
  C->>BE: POST /generate
  BE->>P: produce assets (never cancelled)
  BE--)C: gen stream: slot announced {slotId, assetRef}
  C->>BE: POST /txn create placement {id: slotId} — idempotent
  BE--)C: gen stream: asset ready
  Note over C: existing placement resolves pixels via LoD — readiness is NOT a new placement
  Note over C: on reconnect / cold reopen (≤7d): fetch task state,
create any finished slot with no live row AND no tombstone (R35)
R19 — the synced asset row carries status transitions only; per-second % ticks never touch the counter/journal/SSE (that's the storm case). Live progress rides the generation stream, ephemerally. undo/redo — undo = ordinary delete (production continues); redo = create the same id with the same assetRef = revive, never a second generation
Questions
I close my laptop mid-generation. Where do the results go?

The task finishes server-side regardless. When you return (within 7 days), the client fetches the task's durable state and materializes a placement for every finished slot you don't have — reading state, not replaying missed stream events, so a lost chunk can't drop a placement. The reconcile waits until your canvas scope's /since is current, so it never races a delete that was about to arrive.

Do my teammates see the generation happening live?

They see placements as your creates commit and sync — not the in-flight progress (that's on your personal generation stream). Accepted cost, recorded: generation is a personal action; a shared live preview is a named deferral if product ever wants it.

I undo a generation while slots are still finishing. What happens to the stragglers?

A slot that finishes after your undo-delete may briefly reappear via reconcile, and you delete it again — bounded, rare, self-correcting (and its tombstone then blocks any further resurrection). Auto-clearing that late placement is a named deferral, triggered only if it measurably confuses users.

13 · Cutover — flipping a canvas from Yjs to rows

cutover.md · DR §10.12 · R16 · R21 · R24 · R34

There is no dual-write, ever: a CRDT and a LWW store resolve the same pair of concurrent edits differently, so two authoritative stores would silently diverge. Instead Yjs stays the sole truth until a global downtime window, where each canvas is frozen, projected once, verified by content hash, and flipped.

flowchart TD
  A[downtime window opens] --> B[flush Y.Doc + FREEZE writes
the 60s debounce would otherwise drop the newest edits] B --> C[one-shot backfill: pure function Y.Doc → rows, sync_id stamped] C --> D{content hash of rows == hash of frozen Y.Doc? · R24} D -- mismatch --> E[field-level diff localizes the bad mapping → fix → re-project] E --> D D -- match --> F[deploy the §10.2 monotonic trigger for this canvas · R16] F --> G[FLIP read+write to rows · drop legacy geometry columns + bbox endpoint · R34] G --> H[retain cold Y.Doc through verification only · R21] H --> I[protocol handshake force-reloads any pre-cutover client]
R24 — the flip gate is a content hash, not an entity count (a count check passes a projection that mapped every field wrong); the mapping is dry-run over offline Y.Doc snapshots before the window accepted, named — after verification there is NO rollback (reverting would discard post-flip row edits); and the handshake force-reload discards a pre-cutover client's unflushed offline edits — a freeze horizon is announced and rejects are counted
Questions
Why downtime instead of a gradual migration?

Downtime removes the split-brain writer race entirely: no old client writing Yjs while a new client writes rows. The dangerous residue isn't the happy path — it's a backgrounded mobile tab that wakes after cutover still running the old build. That's what the protocol-capability handshake exists for: it hard-blocks and force-reloads pre-cutover clients at connect, because a soft "please update" banner provably isn't enough.

What happens to rich text during cutover?

Nothing — text was never leaving Yjs. Only the bounded read-only summary is projected onto the row; the docId link and the subdocs are untouched. The riders are infra: per-edit content flush + Redis AOF/no-evict so "undelete restores exact text" actually holds, and a reap cron for purged docs' subdocs.

Is deleting the old geometry columns really safe?

Yes, and it was verified rather than assumed: the canvas_objects table and its bbox read APIs have zero production callers today (the table is only the debounced Yjs shadow this cutover replaces). Under the relative-geometry model those world-envelope columns would be permanently stale anyway — a container move changes the whole subtree's world positions with zero row writes. Server-side spatial queries, if ever needed, are parked deferrals (#6, #19) with the local geometry retained to build on.

Built from the closed spike design (rev 5, 2026-07-16). Precedence when anything seems to disagree: spike/lld/* > decision-record §13 > §12 > §11 > §10 > HLD prose — and a ⚠/SUPERSEDED banner always wins over the text under it. Companion Miro board: frames 1–12 deep-dive + end-to-end lanes.