09 — Conflict Resolution
Topic: conflict resolution. Answers: How should conflicts be handled? Extends ADR-0008 with the full taxonomy and policy; recorded in ADR-0026.
The cardinal rule, repeated because it is the product’s trust contract: never lose data. A conflict produces a conflicted copy — never a silent overwrite.
1. When is it a conflict?
From the planner’s decision matrix (05 §2):
a node is in conflict when both Local and Remote diverged from Synced and they
differ from each other (S≠R ∧ S≠L ∧ R≠L). If they diverged to the same content
(R==L), it’s a convergence, not a conflict (no copy, just record).
2. Conflict taxonomy & resolution
| # | Conflict | Resolution | Rationale |
|---|---|---|---|
| 1 | edit / edit (different content) | keep both: loser → name (conflicted copy — <device>, <ts>).ext; both become versions |
never lose either edit |
| 2 | edit / edit (same content) | converge, no copy | nothing to resolve |
| 3 | edit / delete | edit wins: resurrect the file, surface the (undone) delete | a delete must never destroy an edit |
| 4 | delete / delete | converge (drop node) | agreement |
| 5 | create / create (same name, diff content) | one keeps name, other → conflicted copy | both creations preserved |
| 6 | create / create (same content) | adopt one node | identical, dedup |
| 7 | rename / rename (same node, diff names) | server order wins the name; other rename surfaced (optionally applied as a copy) | deterministic via total order |
| 8 | rename / edit (same node) | both apply (move + content) — not a conflict if separable | node-ID identity decouples move from content |
| 9 | type change (file ↔ dir at a name) | rename one side as conflicted | can’t coexist at one name |
| 10 | case-only collision (File vs file) |
keep both with disambiguating suffix on case-insensitive FS; surface | cross-platform hazard (11) |
| 11 | unicode normalization (NFC vs NFD same name) | normalize; if true collision, conflicted suffix | macOS NFD vs Linux NFC (11) |
Naming follows the Syncthing/Dropbox convention so users recognize it:
report (conflicted copy — Alice's MacBook, 2026-06-11 14:03).docx.
3. Where conflicts are resolved (and why all devices agree)
The resolution is anchored at the server’s total order (ADR-0022/0024), which is what makes it deterministic across devices:
sequenceDiagram
autonumber
participant A as Device A (offline edit)
participant S as Server (journal)
participant B as Device B (offline edit)
Note over A,B: both edit report.docx from base version V (offline)
A->>S: reconnect → Commit(base=V) → wins → version V+1 (seq 101)
S-->>A: ok (Synced advances)
S-->>B: notify (namespace advanced)
B->>S: GetChanges → sees report.docx is now V+1 (≠ B's base V)
B->>B: planner: S≠R and S≠L and R≠L → CONFLICT
B->>S: Commit B's content as NEW node:<br/>"report (conflicted copy — B, ts).docx"
S-->>B: ok (seq 102)
S-->>A: notify → A downloads the conflicted copy
Note over A,B: both devices now hold report.docx (A's) + the conflicted copy (B's)
The key property: the losing device materializes its version as a brand-new node on the server, so the conflicted copy becomes an ordinary file that propagates uniformly to every device via the normal pull. There is exactly one conflicted copy, not one per device — because creation happens once, at the server, and fans out.
4. Classification decision tree
flowchart TB
classDef d fill:#fde68a,stroke:#b45309,color:#111827;
classDef o fill:#bbf7d0,stroke:#15803d,color:#111827;
classDef c fill:#fecaca,stroke:#b91c1c,color:#111827;
n["node: compare S, R, L"]:::d --> q1{"both present and both ≠ S?"}:::d
q1 -- no --> simple["one-sided change → apply ([05])"]:::o
q1 -- yes --> q2{"R == L?"}:::d
q2 -- yes --> conv["converged → advance S (no copy)"]:::o
q2 -- no --> q3{"one side is a delete?"}:::d
q3 -- yes --> edel["EDIT WINS → resurrect + surface"]:::c
q3 -- no --> q4{"mergeable type AND auto-merge enabled?"}:::d
q4 -- yes --> merge["3-way text merge (opt-in)"]:::o
q4 -- no --> copy["CONFLICTED COPY (keep both)"]:::c
5. Optional auto-merge (opt-in, narrow)
For text-like types, an opt-in 3-way merge (using the Synced base) can auto-resolve non-overlapping edits, falling back to a conflicted copy on overlap. Default off. We explicitly do not do CRDT/OT whole-file or real-time co-editing — that’s a non-goal (NG1, ADR-0008). Binary files are never auto-merged.
6. User experience & recovery
- Conflicts are surfaced (UI badge, conflict list, CLI
bitvault conflicts), not hidden. - Version history (storage/07) is the safety net: even an unexpected resolution is reversible because every version is retained.
- Resolution is forward-only (a new node/version), never a destructive rewrite — consistent with the immutable version model.
7. Tradeoffs / Alternatives / Scaling
Tradeoffs. Conflicted copies put reconciliation work on the user — a deliberate, honest cost versus the alternative of silent auto-merge that can corrupt data. Version history + clear surfacing keep it humane.
Alternatives considered.
- Last-writer-wins: trivial, but silently destroys an edit — disqualifying for a data-custody product (ADR-0008).
- CRDT / OT auto-merge for all files: solves real-time co-editing (a non-goal), huge complexity, unsafe for binary content. Rejected; narrow opt-in text merge only.
- Server-side automatic merge: the server lacks app semantics to merge arbitrary content safely. Rejected.
Scaling concerns.
- Conflict storms (two devices rapidly editing the same file) → each loss materializes a copy; rate-limit conflicted-copy creation and coalesce, and surface a single “frequent conflicts” warning rather than thousands of files.
- Deterministic resolution (server-anchored) prevents the N-devices → N copies explosion that naive client-side resolution causes.
- Tie-breaks are total-order/version-based, with timestamp then device-ID as the documented fallback (mirrors Syncthing) so outcomes are reproducible.