Skip to content

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.

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.

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.

  • 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.

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.