Conflict preview & resolution
While a session holds staged changes, the live documents keep moving — the head, the live document as it stands right now, advances underneath the staged view. The session’s job is to make that visible before commit, not to surprise you at commit time.
Conflicts are derived, not stored
Section titled “Conflicts are derived, not stored”A stage stores only its
patches and base sequence — no
snapshot of the document. Each staged replace or remove carries an RFC 6902
test guard asserting the prior value at
the path it changes — an add has no prior value to assert and so
carries no guard.
Detection is a fold: the staged patches are applied in order, guards included,
onto the live head. A clean fold is the auto-merge — disjoint upstream
changes need no attention. A failed fold — a guard that no longer matches, or
an operation that no longer applies — marks the stage blocked.
The fold needs nothing but the head and the patches, so it gives the same answer on every client, and it’s recomputed rather than persisted: a head change that turns out to be disjoint un-blocks the stage by itself. One deliberate strictness: a staged path that was edited and then reverted still carries its guards, so an upstream edit to it conflicts — the session expressed intent about that path even though the net change is nothing.
The three-way preview
Section titled “The three-way preview”A blocked stage is available as data — for a diff UI, or for an agent to reason about:
- base — the document as it was when staging began,
- ours — base with the session’s changes applied,
- theirs — the live document now.
getConflictPreview(docId) returns a promise, because the base value is
resolved rather than stored: it’s instant in the common cases (the live head
still sits at the stage’s base sequence, or this client has watched the stage
since it opened), and on a cold client it’s reconstructed by replaying the
document’s event log up to the base
sequence. The replay is online-only — a cold, offline client’s preview waits
for reconnect. Detection and commit gating never wait; they need no base. The
session’s read view degrades the same way, but only where it must: on a real
overlap, where no coherent head-plus-stack view exists, it serves the last
coherent one (base plus the staged changes), and a cold client sees the live
head un-overlaid until the replayed base lands.
The resolution verbs
Section titled “The resolution verbs”- Amend a staged change — rewrite a pending change in place (fix the agent’s typo before committing). Async for the same reason as the preview: it re-derives the change against the state it was authored on.
- Drop a staged change, or a whole stage — discard part of the staged work.
- Rebase a stage — adopt the current head as the new base and restage on top of it. The embedded guards are rewritten against the new base; a staged change the upstream edit already made identical is dropped, and a stage left with no surviving work is removed.
- Discard the session — walk away; live documents were never touched.
commit() is the gate: it refuses while any stage is blocked, and writes all
stages atomically once none are.
When the document moves under you
Section titled “When the document moves under you”A session’s onUpstreamAdvance decides where the line between merge silently
and surface it falls:
autoMerge— the default, and everything above. Upstream changes that still fold cleanly merge without a word; only a failed fold blocks the stage.block— blocks the stage whenever the document advanced upstream at all, even when the staged patches would still apply.
The reason the second policy exists is that a clean fold proves structural
independence, not semantic independence. A staged change guards the paths it
wrote, not the paths it read. An agent that reads a whole document and
stages a one-field edit to summary produces a patch that folds cleanly over
an upstream edit to body — and a summary that now describes text nobody can
find. Disjoint on paper, stale in fact.
So reach for block when a staged change was derived from more of the document
than it touched: agent-authored edits, or a review gate where “somebody else
moved this while you were staging” is itself something the reviewer should see.
Because it keys off the document’s sequence rather than the guards, it also
catches what the guards structurally cannot — two authors adding the same new
key. You pay for all this in false positives: on a busy document, every
upstream touch blocks and wants a rebase. That’s why autoMerge is the
default.
A stage blocked this way reports reason: "upstreamAdvance" rather than
physicalConflict — nothing clashed, the patches still apply. It keeps reading
head plus your staged changes, which is exactly what commit would write; only a
real overlap falls back to base plus stack. And when a stage both overlaps
and advanced, the overlap wins the reason: physicalConflict is the more
actionable of the two.
Both policies are live: staged work follows the head as it moves, and both ignore Yjs-only upstream movement (rich-text deltas merge onto any later state, so they conflict with nothing — an “advance” here means the JSON lane’s sequence, which they never touch). Neither ever auto-resolves an overlap — that stays a decision for you or the agent.