# ADR 003: Sync = op log + hybrid logical clocks + per-field last-writer-wins, server validates the tree

> 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…

Source: https://nooklet.danielalder.cz/decisions/003-sync-oplog-hlc-lww

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 corrective
  `block.place` op 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.crdt` op)
  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.
