ADR-0008 — Change-journal sync with conflicted-copy resolution
- Status: Accepted
- Date: 2026-06-11 · Revised: 2026-06-12 (Architecture Freeze V1)
- Related: 01 R1, 06 §6, 02 G2, 09 P2, ADR-0004, ADR-0024
V1 Freeze (2026-06-12): Accepted. Blocker-3 resolution: the change journal is the source of truth, written transactionally at commit by File & Metadata — it is not a projection of
NodeChangedevents. This removes the at-least-once ordering hazard the pre-freeze review identified. Delta transfer in V1 is whole-object (sub-file chunk-delta is deferred with ADR-0017).
Context
Synchronization is BitVault’s headline competency and its highest-severity risk (R1). Multi-device, offline-tolerant sync creates concurrent divergent histories. The cardinal sin is silent data loss — last-writer-wins clobbering a user’s work. We must choose a model that is correct, implementable by a solo developer, and demonstrable. Full CRDT/OT co-editing is a non-goal (NG1), so we do not need character-level merge.
The pre-freeze review (review §3.3) found a fatal ambiguity: the journal was
described both as “the source of truth” (this ADR, ADR-0024) and as “the
projection of NodeChanged events” fed from an at-least-once, unordered bus (data
model I4, roadmap P3). A monotonic, gap-free total order cannot be safely derived
from an at-least-once unordered consumer. The sync engine’s correctness
(ADR-0022 leans entirely on the journal
providing a reliable total order), so this had to be resolved before coding.
Decision
A change-journal + content-addressed identity + conflicted-copy model:
- Per-tenant monotonic change journal — source of truth, written at commit.
Every namespace mutation appends a
CHANGE(seq, node, op, version, actor)whose tenant-monotonicseqis assigned inside the File & Metadata commit transaction — the same transaction that inserts theVERSIONrow and theOUTBOXrow (the commit protocol, 06 §4). Becauseseqis allocated under the commit’s transactional ordering, the journal is gap-free and totally ordered per tenant by construction, with no dependence on event delivery. File & Metadata owns and writes the journal (05); Sync reads it and owns onlydevice_cursorsandconflict_records. - Cursor-based delta pull (authoritative) + lossy notify (ADR-0024).
Each device stores
sync_cursor; pulling changes isseq > cursor ORDER BY seqagainst the journal. A lossy “namespace advanced” notification merely prompts a pull; losing it costs latency, never correctness. - Read-your-writes for sync. Because
seqis assigned at commit (not by an async projector), a device that commits a change sees it in the very next delta pull — there is no projection lag between the namespace and the journal. - Content identity by hash (ADR-0016). Unchanged content is never re-transferred (per-tenant dedup, ADR-0018). A “change” with an identical hash is a no-op move/touch. V1 transfers whole objects; sub-file delta is deferred (ADR-0017).
- Optimistic concurrency on commit. A push carries the
baseVersionit edited. IfbaseVersionis current → fast-forward commit. If stale (a concurrent change landed) → create a conflicted copy (e.g.report (conflict, alice, 2026-06-11).docx) and keep both versions. Never overwrite. - Version history as the safety net. Every change is a recoverable
VERSION(FR B4), so even an unexpected outcome is reversible.
CRDTs are explicitly out for whole-file sync (overkill, NG1); reserved only if real-time co-editing ever becomes a goal.
Consequences
Positive
- No silent data loss — the core trust property (R1). Conflicts are visible and both histories survive.
- The total order the sync planner depends on is real: it is the commit order in one Postgres transaction, not an emergent property of event delivery. The hardest correctness assumption in the system is now satisfied by construction.
- Simple, debuggable, and demonstrable (a deterministic conflict harness).
- Cursor journal is cheap to serve (covering index on
(tenant_id, seq)) and supports selective sync later (FR C6) without redesign.
Negative / costs
- Conflicted copies are a visible UX cost — users must reconcile manually. This is a deliberate, honest trade vs. silent merge/loss; mitigated by clear UI and version history. (Whole-file auto-merge is unsafe; we refuse it.)
- The journal grows; needs compaction/retention for tombstones and a snapshot mechanism for fresh devices (initial sync = snapshot + tail; long-offline devices hit the cursor-reset path, ADR-0024).
- The per-tenant
seqallocation is a per-tenant serialization point on the write path. Bounded per tenant and shards naturally; the ceiling is documented in the freeze (NFR-4). - Whole-object transfer (V1) re-uploads a whole file on any content change; sub-file delta efficiency waits for ADR-0017 to be un-deferred.
Alternatives considered
- Journal as an event projection (pre-freeze design): rejected — deriving a
gap-free total order from an at-least-once unordered bus is unsafe and was the
blocker; assigning
seqat commit is simpler and correct. - Last-writer-wins: rejected outright — silent data loss; disqualifying.
- CRDTs / Operational Transformation: rejected for v1 — solve real-time co-editing (a non-goal, NG1); high complexity; conflicted-copy meets the requirement with far less risk. Revisit only with NG1.
- Three-way auto-merge of file contents: rejected — unsafe for arbitrary binary content; only meaningful for text and still risky.
Verification
The conflict harness (P2 acceptance, 09): two simulated offline devices edit the same file from the same base → assert a conflicted copy exists, both versions are retrievable, and the journal/cursors converge. This is the single most important test in the project.