Files
obsidian-livesync/docs/specs_conflict_resolution.md
T

30 KiB

Conflict resolution and revision provenance

This document describes the conflict-resolution and file-reflection guarantees used by Self-hosted LiveSync 1.0, together with the cases which still require user judgement. The underlying revision-tree operations and injectable provenance contract are owned by @vrtmrz/livesync-commonlib; LiveSync owns persistent device-local composition, Vault reflection, settings, and dialogue policy.

Revision-tree model

PouchDB stores a document as a revision tree. It selects one current leaf as the deterministic winner and reports the other current leaves as conflicts. That winner is not proof that its content is newer, safer, or the version currently shown in the Vault.

For example:

A1
├── B1 ── C1 ── D1
└── B2 ── C2

The two current leaves are D1 and C2. Their nearest shared ancestor is A1; neither B1 nor B2 is shared. A conservative three-way merge therefore compares the changes from A1 to each leaf. Matching generation numbers, or selecting the first older revision from one branch, does not prove shared ancestry.

Resolving a conflict writes the selected or merged result on one observed branch and deletes the other observed conflict leaf. A stale device may still have the deleted leaf's content in its Vault when it receives the resolution.

Independent revision properties

The modifiers defined under Revision describe independent properties, rather than exclusive revision types. The winner is a database-tree role, Vault-matching describes current file-state equality, and displayed identifies the device-local branch recorded for the Vault. The same revision commonly has all three properties, but synchronisation, conflicts, local edits, and missing provenance can separate them.

Situation Winner Vault-matching Displayed
A present Vault file is fully converged R R R
The Vault displays conflict leaf C W C C
The database advances before Vault reflection new winner W2 previous revision R, while the Vault is unchanged R
A local edit of displayed revision R is pending independent none, or a coincidental content match R, as the branch which the edit must extend
Provenance is missing and exactly one current non-deleted leaf fits independent M none, then M after safe reconstruction
Provenance is missing and several revisions fit independent every matching revision none
A logical-deletion winner agrees with an absent file deleted winner D D, and possibly other logical-deletion revisions none; an absent file retains no displayed provenance

At most one revision is the winner, more than one revision can be Vault-matching, and at most one revision can be displayed for a path on one device. A displayed revision may stop matching the Vault while a local edit is pending, but its branch identity remains authoritative until that edit is stored or the relationship is safely reconstructed.

File saving and reflection guarantees

  • Automatic text and structured-data merge uses the nearest available revision ID which is present in both leaf histories.
  • Missing or compacted history stops conservative automatic merge instead of guessing a base.
  • A Vault file which still matches its exact recorded revision is unchanged. An ordinary save does not append those stale bytes to a newer database revision; a newer, unconflicted database result is reflected through the existing file-reflection path.
  • A file which differs from its readable recorded revision is an edit of that revision, even if its bytes match another historical revision. Saving and incoming overwrite protection use the same rule.
  • Without a readable recorded revision, current non-deleted leaves are checked for duplicate content. If none matches, the file is preserved as a fresh independent root under the same document ID. Its unknown ancestry cannot supply a three-way merge base.
  • File bytes, rather than path, size, modification time, or revision generation, determine whether content is known.
  • Three or more current versions are reviewed one pair at a time in a deterministic order, with each completed pair committed before the next pair is read.
  • Each device records the exact revision most recently reflected in each Vault file. An ordinary edit extends that displayed branch even before a conflict exists. Conflict-time deletion and case-only rename retain their separate displayed-branch contracts.
  • A cross-path rename stores the target before logically deleting only the displayed source branch.

The recorded revision can belong to a deleted losing branch. If its readable body still matches the Vault, the propagated resolution can be reflected without recreating the conflict. A historical byte match without that record does not establish that the file is unchanged: it may be an intentional revert. Existing Vaults can lack records, so an upgrade, reset, or unavailable old body can expose additional conflicts requiring review.

The explicit Always overwrite with a newer file option retains its existing modification-time policy. An independent branch prevents an inferred three-way merge; it does not disable the user's selected conflict-resolution option. Metadata and Chunks retain their existing format, and matching chunks can be shared between branches.

Resolution patterns

State Safe action
Both leaves contain identical bytes Collapse the duplicate leaf.
Text or structured data has an available shared base and non-overlapping changes Perform a conservative three-way merge.
One side deletes content which the other leaves unchanged Preserve the deletion.
One side deletes content which the other modifies Ask the user.
A receiving file matches its exact readable recorded revision Apply the propagated database result under the existing conflict policy.
A receiving file differs from its readable recorded revision Preserve the edit as a child of that exact revision.
Provenance is unknown and no current non-deleted leaf matches the file Preserve a fresh independent branch for conflict resolution.
Provenance is unknown and current non-deleted leaves already hold the file bytes Avoid duplicate storage; infer provenance only for a unique match.
A required body or shared ancestor is missing or compacted Ask the user.
Binary contents differ Prefer an explicit user selection; semantic merge is unavailable.

The compatibility implementation currently selects the newer modification time for differing binary conflicts even when the general Always overwrite with a newer file option is disabled. This is existing behaviour, not a new 1.0 guarantee. Changing it to explicit selection only is a separate compatibility decision.

Unreadable revisions and repair

A document revision can remain in the PouchDB tree while one or more chunks needed to reconstruct its content are unavailable. Missing content is not evidence that the revision is obsolete. LiveSync therefore leaves an unreadable winner or conflict revision in the tree instead of deleting it during automatic conflict processing.

HatchInspect conflicts and file/database differences inspects the current winner, every current conflict revision, and the nearest shared ancestor for each conflict. A logical-deletion winner and an absent Vault file already agree, so that state is not reported unless another conflict branch still requires attention. When the Vault already matches the winner but conflict branches remain, the card shows the compact status ✅ Vault matches winner · ⚠️ Conflicts: N; matching the winner does not mean that the conflict has been resolved.

Each reported current leaf revision has a compact wrench menu. The available actions depend on the exact revision and current Vault state:

  • Compare with Vault opens the existing difference dialogue in read-only mode for differing text files.
  • Apply this revision to Vault writes the selected readable revision, even when it is not the database winner. Replacing an existing file requires confirmation.
  • Mark this revision as the Vault version is offered when the bytes already match. It records exact device-local provenance without creating a child revision, and refuses the operation if the file changed after inspection.
  • Store Vault file as a child of this revision preserves the current Vault bytes on the explicitly selected current branch.
  • Apply logical deletion to Vault removes an existing Vault file after confirmation. An absent file needs no retained deletion provenance.
  • Retry reading revision attempts the configured chunk-retrieval path again. It does not change the revision tree.
  • Discard this branch is available for each exact current leaf while at least one other current leaf remains. It requires confirmation and creates a logical deletion on only the selected branch without changing the current Vault file.
  • Discard unreadable revision remains available as a recovery action when an unreadable revision is the only current leaf. It requires confirmation because no other database branch remains.

Every mutating action rechecks that the selected revision is still a current leaf. If another operation resolved or replaced it, the action fails and the card is refreshed instead of extending an obsolete branch.

The card uses compact, mobile-friendly diagnostic rows with an emoji and a text label. 🧩 Missing chunks: N identifies an unreadable revision. In the database row, Δsize is decoded size minus recorded size; in the Vault row, Δsize vs DB is Vault size minus decoded database size. Δtime is Vault modification time minus database modification time. The ordinary two-second comparison window still labels which side is newer. These values help diagnose a mismatch; path, size, and modification time do not prove revision identity or decide which content should win.

A shared ancestor is informational. An ancestor which is no longer a current leaf cannot be discarded independently through this workflow. If its body is unavailable, conservative three-way merge remains disabled, although readable current leaf revisions can still be selected manually.

Logical deletion does not recreate missing bytes, purge the document history, or prove that the deleted version was unimportant. Another replica or backup may still contain the missing chunks. Recover from that source before discarding a revision whenever possible.

Recreate chunks for current Vault files can recreate chunks only from files which are readable in the current Vault. It cannot reconstruct unique bytes from an unavailable historical or conflict revision.

Garbage Collection V3 treats every conflict leaf and its nearest available shared ancestor as reachable. Their locally available chunks are retained until the conflict is resolved. After resolution, chunks used only by the discarded branch or no-longer-needed merge ancestry can become eligible for collection. See the Garbage Collection V3 specification.

A generation-one revision has no parent. When its body is unavailable, LiveSync cannot preserve a changed Vault file as a sibling branch without inventing ancestry. It leaves the operation unresolved. Recover the missing chunks from another replica or backup, or explicitly discard that current leaf. If the current Vault file is the intended replacement, it can be stored after the unreadable revision has been logically deleted.

Two devices independently create the same path

If two devices create the same full synchronised path before either device has received the other creation, the two generation-one leaves have no shared revision. Files with the same name in different directories remain separate paths and do not form this conflict.

When the independently created files contain identical bytes, LiveSync deletes one duplicate leaf without synthesising merged content. A device which still records the deleted duplicate as its displayed revision already has the same bytes as the surviving revision, so it does not recreate the conflict. It rebinds its device-local provenance to the surviving revision.

When the independently created files contain different bytes, conservative three-way merge has no valid base. LiveSync therefore leaves the two versions for manual selection; it does not guess an empty base or concatenate unrelated files. If both versions instead descend from a revision which the devices had previously synchronised, they are ordinary divergent branches: LiveSync may merge non-overlapping text or structured-data changes from that shared base, and otherwise asks the user.

Stale and concurrent resolutions

A device can resolve only the leaves which it has observed. If another device has already extended a branch, later replication can reveal another current leaf and require another resolution. Two devices can also produce different resolutions concurrently, leaving multiple current leaves after their trees meet.

A higher revision generation or modification time does not make either result authoritative. The resolver must examine every current leaf again until one result remains or user action is required. This is continued conflict processing, not a reset of the synchronisation checkpoint.

More than two current versions

When three or more versions remain, LiveSync compares the current PouchDB winner with one conflict leaf at a time. Commonlib orders the remaining candidates by revision generation ascending, original leaf modification time ascending, then the complete revision ID in code-unit lexical order. A missing or non-finite modification time is ordered before a finite value. Modification time makes pair selection reproducible here; it does not decide which content wins.

For each pair, LiveSync first collapses identical content, then attempts a conservative sensible merge, and finally asks the user when neither automatic action is safe. A completed action is written to the ordinary revision tree and its losing observed leaf is deleted before LiveSync reads the remaining current leaves again. There is no separate persistent merge accumulator.

Concat both writes the concatenated result as a new child of the displayed PouchDB winner, then deletes only the other leaf shown in that dialogue. With two current versions, that action resolves the conflict. With three or more, the new child remains a current leaf against every untouched leaf and becomes part of the next pairwise review; it does not create an unrelated root or consume an unseen branch.

Consequently, choosing Not now or closing Obsidian cannot undo a completed pair. After restart, LiveSync reconstructs the next pair from the current revision tree. If replication changes either revision while a dialogue is open, LiveSync discards the stale selection, refreshes the current-version count, and rechecks the path rather than deleting a revision which was not the one shown.

Device-local file provenance

LiveSync composes Commonlib's injected FileReflectionProvenance with its local key-value database. Each device stores:

path -> { revision, observedStorageMtime? }

revision identifies the exact database revision most recently saved from or reflected in this device's Vault. It is the base for subsequent local edits, rather than a certificate that the current file still contains those bytes. observedStorageMtime is the raw local modification time of the saved snapshot or the file observed after reflection. It is not rounded, combined with another device's value, or used as proof of branch identity. No content hash is persisted.

The record changes only after a successful database-to-Vault reflection or Vault-to-database write. Reading a file does not change it. The recorded revision remains authoritative even if the user edits the file to bytes which equal another branch; otherwise content equality could silently move the edit to a branch which was not displayed.

Saving and reflection for the same Metadata document run one at a time, including the final provenance update. An ordinary save holds one captured file body and its base until the database write completes. An edit made while that save is running belongs to the next operation; the save does not reread the file to prove that it remained unchanged. Different files retain their existing concurrency limits, and the handler acquires the lock before loading a file body from storage. Conflict checking runs after the lock is released so that an immediate resolution can safely call the file handler again. The host queues count document-lock waiters against their concurrency limits, so a burst for one document can temporarily delay unrelated files.

The common lock does not stop Obsidian edits, external filesystem writes, or replication into the database. Incoming overwrite and deletion protection still checks current storage. Pending events restored at startup retain bounded rechecks because they run before file watching begins and cannot rely on another change notification.

LiveSync creates the namespaced store handle during service composition, before the key-value database is open. The sequential onSettingLoaded lifecycle opens that database before Vault scanning, watching, or replication starts. Store operations do not wait for implicit readiness: a lifecycle violation fails promptly, avoiding an indefinite or self-referential initialisation wait. Local database reset is a transient unavailable boundary, after which scanning reconstructs derived state.

For ordinary saves and incoming reflection, a missing or unreadable recorded base permits reconstruction only from exactly one matching current non-deleted leaf. Matching several current leaves avoids duplicate storage but does not identify a displayed branch. No current match creates an independent branch, even when an older ancestor has the same bytes. Deletion and rename retain their existing provenance-recovery contracts.

Operations while a conflict exists

  • Editing a file writes a child of its recorded or uniquely reconstructed displayed revision.
  • Deleting a file writes a logical-deletion child of that revision. It uses LiveSync's deleted marker, rather than a PouchDB _deleted tombstone, so the deletion remains a current branch which can replicate and be resolved against the other branch.
  • A case-only rename writes the new path as a child in the same document tree.
  • A cross-path rename stores the target document first, then writes a logical-deletion child on the displayed source branch.

If an edit's base cannot be proved, LiveSync keeps the bytes as another manual-resolution branch instead of attaching them silently to the database winner. If a deletion's displayed branch cannot be proved after the file body has gone, LiveSync preserves every branch and requests conflict review. For an unproven cross-path rename, the new target remains stored and every source branch is preserved for review. These fallbacks can leave a temporary duplicate or unresolved source, but they do not discard an unproven branch.

Interactive dialogue policy

Choosing Not now postpones repeated merge dialogues for the same uninterrupted conflict episode in the current plug-in session. Ordinary file checks and replication do not reopen the dialogue while at least one conflict leaf remains. If the in-editor status display is enabled, the active file shows This file has 3 unresolved versions. They will be reviewed one pair at a time. for three or more current versions, using the current count, and This file has unresolved conflicts. for two. Postponement therefore does not make the conflict invisible.

The command Resolve if conflicted., and selecting a file through Pick a file to resolve conflict, explicitly clear the postponement and request the dialogue again. Cancellation caused by another conflict dialogue does not count as Not now. Once the document has no remaining conflicts, the episode ends; a later conflict at the same path prompts normally. The postponement is not persisted across a plug-in reload. Completed pairwise resolutions are persisted in the ordinary revision tree, so a reload forgets only the postponement and does not repeat an already committed stage.

When synchronisation supplies a resolved document, the existing incoming-file processing event closes an open conflict dialogue for that path. The same event rechecks the local revision tree: if no conflict leaf remains, it ends any postponed episode and removes the active-file warning. If conflict leaves still exist, the stale dialogue closes and the warning changes to the current-version count. A postponed episode stays postponed; otherwise, subsequent conflict processing may open a fresh dialogue for the current revision tree. Each dialogue owns its completion result, so a prompt which is answered or closed immediately still completes the waiting conflict operation; the result does not depend on a later global listener being ready. The end of an automatic repeat is silent. An explicit Pick a file to resolve conflict request which starts with no conflicts may show one confirmation Notice.

Example device scenarios

A user edits the branch shown on one device

Mac and Android have produced two branches of shared.md. Mac's local database selects revision C1 as its deterministic winner, but the file currently shown in the Android Vault came from revision C2:

A1
├── B1 ── C1     database winner
└── B2 ── C2     displayed on Android

Android recorded C2 when it wrote that revision into the Vault. If the user edits the file on Android, the new revision extends C2:

A1
├── B1 ── C1
└── B2 ── C2 ── D2     Android edit

After synchronisation, both devices receive C1 and D2 as the current branches. The edit is not moved silently onto C1, and ordinary conflict resolution can compare the real descendants.

A user deletes the branch shown on one device

If Android deletes the file while it still displays C2, LiveSync writes a logical-deletion revision below C2:

A1
├── B1 ── C1
└── B2 ── C2 ── D2 (deleted: true)

The deletion remains one current leaf of the conflict. The user can still choose between the content at C1 and deleting the file. LiveSync does not delete C1 merely because PouchDB selected it as the winner.

A user renames a conflicted file

If the user changes only the spelling case, such as Note.md to note.md, LiveSync keeps the rename in the same revision tree and extends the revision displayed on that device.

If the user renames draft.md to published.md, LiveSync stores published.md before it marks the displayed draft.md branch as logically deleted. If an interruption occurs between those operations, the recoverable result is a duplicate which can be reviewed, rather than loss of the only copy. Any other conflict branch of draft.md remains available for conflict resolution.

A remote resolution reaches a device which still shows the losing content

Android may resolve a conflict and continue editing while Mac still shows the losing revision. When Mac receives the resolved tree, LiveSync compares Mac's bytes with the exact revision recorded for its Vault. If that body remains readable and matches, it can apply Android's resolution without asking Mac to resolve the same unchanged conflict again.

If the user edited the file on Mac before the resolution arrived, the bytes no longer match that historical revision. LiveSync preserves the Mac edit as an unsynchronised conflict instead of overwriting it.

A three-version review is interrupted

Mac receives three current versions of shared.md. The active-file status reports three unresolved versions, and the first dialogue compares the deterministic winner with the first ordered conflict leaf. The user completes that pair, leaving two current versions, then chooses Not now on the next dialogue and closes Obsidian.

The first decision has already changed the ordinary revision tree. On restart, LiveSync reads the two surviving versions and presents only that remaining pair; it does not reconstruct the original three-version state. If another device resolves the remaining pair before or while the dialogue is open, the warning disappears and the stale dialogue closes.

The device-local record is missing

A local-database reset removes revision provenance. When an ordinary save or incoming reflection examines the file, exactly one matching current non-deleted leaf can reconstruct the record. Multiple current matches prevent duplicate storage but leave branch identity unproved. A match only in past history is insufficient; differing current content is preserved as an independent branch. An unchanged-time scan alone does not guarantee that a record is created.

If no current non-deleted leaf contains the file bytes, an ordinary save retains them as another independent branch. An unproven deletion leaves all existing branches intact. A cross-path rename stores the target but leaves every unproven source branch for review. The result can require an extra decision, but it does not discard data by guessing the winner.

Start-up or reset overlaps a provenance operation

LiveSync creates the provenance handle during composition, then opens its backing store during the sequential settings lifecycle before starting scans, watchers, or replication. If the store cannot open, start-up stops rather than leaving file processing waiting indefinitely.

During reset, the store can be temporarily unavailable. A racing provenance lookup fails promptly and follows the same conservative missing-record behaviour. After reopen, ordinary saving or reflection can reconstruct a record from a unique matching current non-deleted leaf.

Unsafe shortcuts

Do not:

  • infer a common ancestor from generation numbers alone;
  • assume that the PouchDB winner is the version currently displayed in the Vault;
  • replace recorded displayed provenance merely because current bytes match another branch;
  • classify a file as unchanged solely because it matches an ancestor somewhere in history;
  • discard local content when revision-history lookup fails;
  • infer revision identity from path, size, modification time, or content hash without a revision ID;
  • select the newest modification time unless the user has explicitly chosen that destructive policy; or
  • merge overlapping text edits or unrelated binary contents automatically.

Verification

LiveSync also exercises three and four independently editing devices through real CouchDB, using the installed Commonlib package and the CLI conflict-resolution command dispatcher. These tests check unchanged losing files before and after resolution, genuine edits on a losing branch, missing provenance, compacted bases, independent-root deduplication, and propagation of the selected result. See the multiple-device regression procedure and coverage boundaries.

Commonlib owns the real-PouchDB and injected-boundary tests for revision ancestry, content preservation, provenance, independent branches, and repeated file events. LiveSync owns persistent host composition and actual Obsidian restart coverage. The focused test:e2e:obsidian:stale-file-restart scenario advances the local DB while old Vault bytes remain, persists pending file events, and restarts the same isolated profile. It requires an unchanged recorded file to reflect the DB without a new revision, an unknown file to remain on an independent branch alongside the DB content, and repeated processing after provenance loss to leave those branches unchanged. It uses real local storage and startup processing; transport replication and mobile lifecycle coverage are separate.

LiveSync's optional real-Obsidian two-Vault checks have two scopes. E2E_OBSIDIAN_INCLUDE_MARKDOWN_CONFLICT=true resolves and edits a Markdown conflict, propagates it to a Vault which still displays the deleted losing content, and requires one current result to remain. E2E_OBSIDIAN_INCLUDE_CONFLICT_OPERATIONS=true edits, deletes, case-renames, and cross-path-renames files while conflicts remain active; it verifies the parent revision of each resulting branch, replicates those exact trees, and confirms that the other conflict branches remain intact.

The focused test:e2e:obsidian:conflict-dialog-policy scenario creates three current versions in one real Obsidian Vault. It verifies the count warning, commits a concatenated child of the displayed winner, confirms that the untouched leaf remains as one conflict, postpones that remaining pair, restarts the isolated Obsidian profile, and confirms that only the current pair is reconstructed. It also verifies that an incoming resolution closes a stale dialogue, completes the waiting conflict operation, and clears the warning. The repair scenario removes a referenced local chunk, confirms that the exact unreadable current leaf remains in the tree, and exercises explicit retry and discard controls without deleting another current leaf.