ADR 003: Sync = op log + hybrid logical clocks + per-field last-writer-wins, server validates the tree
Date: 2026-09-10. Status: accepted.
Decision#
- Every write anywhere (editor, HTTP API, MCP tool, markdown import) is a list of ops:
page.create|rename|prop|delete,block.create|place|text|prop|delete. An op targets one entity and one independently mergeable field and carries a hybrid logical clock (HLC). - Each field is a last-writer-wins register keyed by HLC. Block placement
(page, parent, order)is one field so a move is atomic. Sibling order is a fractional index string; equal keys tie-break on block id. - The home server is the single validator: it applies pushed ops in arrival order, assigns a
server_seq, rejects a move that would create a cycle in its own state, and emits a correctiveblock.placeop with a newer HLC. Clients never rebase; a transient cycle renders under an "Unplaced" pseudo-node for one round trip. - Protocol:
POST /sync/push,GET /sync/pull?since=seq,GET /sync/snapshot, plus a WebSocket "poke". Idempotent, resumable, works through a service worker. - State tables are a pure function of the op log;
rebuild()replays the log and must reproduce the state. Property tests simulate N devices with partitions and assert convergence.
Why#
- Compared to Yjs (no tree type, y-indexeddb unmaintained), Automerge (no move op), and Loro (correct movable tree, but a 1 MB gzipped WASM and 0.x sync protocol), a hand-rolled log on SQLite is smaller, fully inspectable, and sufficient for a single user on several devices. Workflowy, Trilium, SiYuan, Anytype, and Logseq DB all converged on block-level op logs.
- Because ids and ops are data, per-block text CRDTs (Loro/Yjs bytes in a
block.text.crdtop) can be added later without migration if live co-editing is ever wanted.
Consequences#
- Concurrent edits to the same block's text on two devices resolve last-writer-wins in v1; a
3-way text merge on the client (diff-match-patch) is the planned v1.1 improvement. (Shipped in
M6 as a hand-rolled word diff3,
packages/core/src/sync/text-merge.ts; when it cannot merge, the losing text becomes a sibling block minted by the server — ADR 027.) - Device clocks more than 60 s ahead are rejected with a visible error rather than accepted.