ADR 027: The losing text of a same-block conflict becomes a sibling block, minted by the server
Date: 2026-10-04. Status: accepted. Amends ADR 003's Consequences (the v1.1 text merge) and
research/03-sync.md §6.4's "add a props.conflict_copy with the loser".
Context#
B-642, the owner's first real-device test: one block rewritten on the Mac and on the iPhone (one
offline). Both sides replaced the whole text, so the 3-way merge rightly failed, and the losing
text ended as a conflict_copy:: There is this property chip under the winner ("Nothing"). The
owner: "shouldn't it be smarter than that, and e.g. added that as extra line or something".
How conflicts worked until now (read from code, ae90be5):
- The merge runs only on a client, in
SyncClient.pull()→resolveTextConflicts→@nooklet/core'sresolvePendingTextConflict. Trigger: a pulledblock.textfor a block that still has this device's ownblock.textinpending_op. - Base:
pending_op.base, the block's content just before the local edit (captured inapplyLocal).merge3is a word-token diff3: disjoint hunks merge into a newblock.textwith a newer HLC; overlapping hunks fail closed. - On failure LWW keeps the newer text and the client writes
block.prop conflict_copy = <loser>. - Both devices can take that branch for the same conflict (each has the other's op arrive while its own is still pending — e.g. a push whose response was lost). For a keyed property that was harmless: same key, same value.
Decision#
- Clients are unchanged: on a failed merge they still push
conflict_copy = <loser text>. It is now a report. serverApplyOps(packages/server/src/conflict-copy.ts#planConflictCopies), for asyncpush only, turns every non-rejectedblock.prop conflict_copyinto server-authored ops in the same transaction:block.createof a block holding the loser text, in the winner's parent, ordered directly after the winner and before every sibling that follows it, with the propertysync-conflict:: true;block.prop conflict_copy = nullon the winner. They are corrections like the cycle and subtree repairs: logged (soverifyreplays them), recorded inchanges, returned to the pusher, pulled by everyone else inseqorder (ADR 026).
- The new block's id is derived: 70 bits of sha256(winner id, NUL, loser text), in ADR 004's 14-character alphabet. If that block already exists — live or deleted — no create is minted. That is what makes the second device's report of the same conflict a no-op, and keeps a copy the person already deleted from coming back.
- Texts materialised per named block: each report's own value even when the report lost LWW
(
noop: an earlier report's clear is newer — its text exists nowhere else), plus the property's value before the batch and after it, so neither the report's overwrite nor the clear discards text. A text equal to the winner's current text is skipped. - The client renders
sync-conflictas a small warm "sync conflict" badge (BlockProperties.tsx), with a tooltip saying what happened and what to do. It is an ordinary property: the badge goes when the line is deleted in the editor. - No migration. Existing
conflict_copy::properties stay exactly as they are: they are already visible as a chip, the person may have acted on them, and rewriting user data on upgrade without being asked is the wrong default. A block that conflicts again carries its old value along (point 4). Anyone who wants the old ones as blocks can find them with the queryprop:conflict_copyand move the text by hand.
Alternatives#
- (b) Append the loser to the winner as an extra line. Rejected: it silently changes the winner's text — the one thing every other device just agreed on — mixes two versions into one block where neither can be deleted alone, and a third device's next edit then merges against a text nobody wrote. It also breaks the "a block is one thought" shape of an outline: a task marker, a heading or a property line in the first line of the winner now governs both.
- Client mints the sibling block with a derived id. Rejected: both devices can detect the
same conflict, and core's
block.createisINSERT OR IGNORE. Each device computes the order key from its own replica and stampscontent_hlcwith its own HLC; a replica keeps whichever create it saw first (its own), the server keeps the earlierseq. Same id, different place and clock — the replicas would diverge, and nothing would ever repair it. - Client mints it with a random id. Two copies whenever both devices notice.
- A new op kind (
block.conflict) instead of reusing the property. Cleaner on the wire, but every client built before this change — including the owner's iPhone build — would keep writingconflict_copyand never benefit, and it adds a kind to thedefineOp/OpenAPI/MCP surface for a server-internal concern. Reusing the property upgrades every client at once. - Obsidian Sync's conflict file (since 1.9.7, optional: "creates a separate conflict file
instead of merging automatically", named
original-note-name (Conflicted copy device-name YYYYMMDDHHMM).md; markdown otherwise merges with diff-match-patch — https://obsidian.md/help/sync/troubleshoot, fetched 2026-10-04). Same idea one level up: keep the winner untouched, put the other version next to it, let the person reconcile. A block is nooklet's unit as a file is Obsidian's, so (a) is the analogue. Device name and time in the marker were considered; the server does not know the losing device (the report is minted by whichever device noticed), and a date in the value would make the badge noisier for little gain.
Costs#
conflict_copyis now a reserved-in-practice key for sync writes: a person typingconflict_copy:: xinto a block in the editor gets a new block withxinstead of the property. API and MCP writes (origin other thansync) keep the plain property.- The pushing device shows the
conflict_copychip for one push round trip (≈300 ms debounce + request) before the correction arrives. Not hidden client-side: that would be a second rule for the same property. - A block id is no longer always time-prefixed. Nothing reads time out of ids (
idTimehas no caller outside its tests); ordering ties on id remain deterministic. - If the person deletes the copy and later the same block conflicts again with the identical losing text, no copy is made — the derived id is taken. The text is still in the op log.
- A report naming a deleted winner still makes a copy beside the tombstone, where it is as hidden as the winner. Rare (the merge ran against a live block moments earlier), accepted.
- Conflict detection is unchanged and still needs the device's own edit to be pending when the
other device's arrives (see
docs/progress/b642.md, "Found in passing"). Superseded by the amendment below (B-652): that dependence silently lost text, and detection no longer has it.
Verification#
packages/server/src/conflict-copy.test.ts: placement, nesting, duplicate report, deleted copy not resurrected, stale report kept, pre-existing value carried, API write untouched; each checks a fresh replica reading the log inseqorder equals the server andverifyis clean.packages/server/src/sync/convergence.property.test.ts: aconflictReportaction in the random interleavings; replicas converge and noconflict_copysurvives.apps/web/src/sync/e2e.test.ts"B-642": realSyncClients and server; one device offline; and both devices reporting the same conflict → one copy.e2e/tests/sync-conflict.spec.ts: two browser contexts, one offline, both show the winner, the copy with the badge, and noconflict_copychip.
Amendment 1 (2026-10-04, B-652): detection no longer depends on which response lands first#
Context#
Detection ran only in SyncClient.pull(), and only against this device's block.text rows still
in pending_op. A push response deletes them. worker-core.ts starts push and pull together on
online/resume/visible (and connectLive's onOpen does too), so whenever the push response
was applied first, the other device's edit, pulled a moment later, met nothing: plain LWW, one text
gone, no copy, no trace outside the op log. Proven, not suspected: apps/web/src/sync/e2e.test.ts
"… whichever response a reconnecting device gets first (B-652)" holds each response until the test
releases it. On 8a91f06, 11 of 15 scenarios lost a text — every push-response-first case,
whichever device's text won LWW and whichever device came back first, three devices, and two
devices that were both online and each pushed before pulling (no reconnect needed).
The server sees both ops but cannot tell them apart from sequential edits: a block.text payload
is { content } only, and serverApplyOps → core applyOps is per-field LWW — the second op
either overwrites (applied) or loses (noop), silently either way.
Decision#
The client keeps what detection needs until its own pull has gone past it.
applyPushResponsemoves each acceptedblock.text(with a base) frompending_opto a new client-only tablesent_text, with theseqthe server gave it — unless a pull already passed thatseq. A pull drops a row when the op comes back, or onceserver_cursor >= seq(anoopnever comes back). CreatedIF NOT EXISTSon every open; no migration.- Detection walks a pulled batch in the server's
seqorder. A foreignblock.textmeets this device's "unseen" text:pending_opandsent_textrows newer than the newest op of ours seen so far in the batch. An op of ours appearing in the batch means everything after it was written by a device that may have seen it (and everything of ours older than it was pushed earlier, HLC order) — so exactly one device detects a given conflict: the one whose op is later in the log. - Merge base = the base of the OLDEST unseen row (the text before this device diverged), not the newest row's. The newest row's base is an earlier local edit the other device never saw; merging against it quietly reverted that earlier edit wherever the other device touched nearby (found in passing, reproduced: "two local edits of one block before the other device's edit arrives").
- A merge op keeps that base in
pending_op(it was NULL, which made the block look base-less, so the next foreign edit won by LWW over the merge); a clean merge is this device's text for the rest of the batch, so a third device's edit merges into it. - When the base fails to merge,
mergeBasetries an ancestor the incoming edit certainly or verifiably descends from: its author's previous text, or a text it contains (a third device's or an older one of ours, from the batch or this replica's op log) — and only ifminealready contains everything that ancestor's author has written. Otherwise a merge op carrying a stale word (another device's first edit, since changed again) read as a conflict: 477 of 2000 random merge-mode schedules made a spurious copy before this, 0 after. A third device's newer text is not a safe ancestor — the merge is clean and silently reverts that device's word (tools/probes/b652-merge-base.ts); the containment check excludes it.
ADR 027's server side is unchanged: clients still report conflict_copy, the server mints the
block. Since only one device now detects a given conflict, the derived-id dedup matters mainly for
clients built before this change.
Alternatives#
- Server-side detection (the server sees every op). Without a base it can catch only half: an
incoming
block.textthat loses LWW was certainly concurrent (its author's clock would be past the winner had it seen it), but one that wins looks exactly like an edit made after seeing the current text. Catching that needs the edit's parent (e.g. thecontent_hlcit was written on) in the push — a wire change old clients never send, so "works for old clients" holds for half the cases. It would also make two detectors (old clients still merge on pull) racing to mint merge ops, and a server-side merge needs base text from an op log that GC trims. Rejected for now; the client fix covers both halves for every updated client. Worth revisiting only if a client we cannot update keeps losing text. - Pull before push on reconnect. Fixes reconnect only: two online devices that each push before pulling still lose a text (the "both online" test), and every reconnect would wait a round trip longer to push.
- Keep acknowledged rows in
pending_opwith anacked_seqflag. Same mechanism, but every reader of the outbox (flush, the pending count the indicator shows, refused-page cleanup) would have to learn to skip them; a separate table keepspending_opmeaning "not yet pushed".
Costs#
- One small row per pushed
block.textuntil the next pull passes it (normally milliseconds). - A pull with a candidate conflict reads up to 20 recent
block.texts of that block from the local op log and runs a few extra word-diff3s. Only when this device has unseen text on that block. - Still a heuristic for clean merges: a merge three or more devices deep can, in shapes the tests did not generate, fall back to a conflict copy (fail closed — no text lost).
Verification#
apps/web/src/sync/e2e.test.ts"… whichever response a reconnecting device gets first (B-652)": 15 fixed scenarios (2 response orders × 2 server orders × who wins LWW; both offline either order; both online; three devices in four orders) + three-device clean merges + two local edits; and seeded random schedules (2–3 devices, random clock offsets, 1–2 edits each, random flush / pull / reconnect with random request and response order) in a conflict mode (every device's last text on the page) and a merge mode (one text with every device's last word, no copy). 120 seeds per mode by default; 5000 per mode passed. 20 of these fail on8a91f06.apps/web/src/sync/sync-client.test.ts:sent_textkept on push, merged against, dropped when pulled back / when the cursor passes a noop / never written when a pull already passed it.e2e/tests/sync-conflict.spec.ts"a reconnecting device whose push response lands before its pull keeps both texts (B-652, …)": real browsers, the returning device's pull responses held 1.5 s withcontext.route; both fail with the8a91f06client.tools/probes/b652-race-http.ts: three devices against a realnooklet serve; with the8a91f06client four edits vanished and every replica agreed on the loss, andnooklet verifystill said OK — verify cannot see this kind of loss, only the tests above can.