Files
atelier/domains/edge/sync.md
T
Jon Chery 20992883ff docs(P01): complete edge domain phase — v0.4
---ci---
project: atelier
phase: 1
milestone: v0.4
status: complete
phase_role: execution
phase_tag: v0.3.1
requirements:
  covered: [ATELIER-92, ATELIER-93, ATELIER-94, ATELIER-95, ATELIER-96]
  partial: []
---/ci---
2026-08-05 15:51:03 +00:00

18 KiB

Sync — Derived Rules

Derives from domains/edge/first-principles.md. Applies P4 (Sync Conflicts are Bounded, Not Infinite) primarily, with P5 (idempotent merge operations), P2 (offline as the precondition), and P10 (sync is observable). Cross-links domains/data/migrations for schema migration under sync and domains/concurrency/patterns for the immutability-aid-merge principle.

What the Sync Problem Is (P4 Sync Conflicts are Bounded, Not Infinite)

  • Sync is the discipline of reconciling divergent state across partitioned nodes. While partitioned, each node accepts writes independently; on reconnect, the divergent state must converge. The correctness contract is that the merge terminates and converges — oscillation and infinite sync loops are correctness failures, not eventual consistency (P4).
  • Sync is the edge domain's deepest problem: it is the reconciliation layer above P2 Offline is a First-Class State. Without offline operation there is nothing to sync; with it, the reconnect reconciliation is the correctness mechanism. See domains/edge/offline-first.md for the offline write-queue that produces the divergent state to be reconciled.
  • The boundary is per D-061: edge owns the partitioned-reconcile angle; concurrency owns the in-process analog (concurrency/P1 Immutability by Default — immutability aids merge); data owns the generic migration discipline (data/migrations). Sync is an edge concern because its defining trait is partitioned divergence, a concern that only arises at the network edge.

Conflict Resolution Strategies (P4, C1 Correctness, C5 Reversibility)

  • A conflict is when two nodes have divergent state for the same logical entity and no total order determines which is correct. Resolution strategies fall into two families:
    • Conflict-free: the data type guarantees convergence by construction (CRDTs). The merge is deterministic; no conflict surfaces to the user or the application.
    • Conflict-tolerant: the data type can conflict; the resolution policy (last-write-win, three-way merge, application-specific) arbitrates. Conflicts may surface to the user or be silently resolved per a documented policy.
  • The choice is a P4 decision: conflict-free types guarantee the bound (convergence) but constrain the data model; conflict-tolerant types are flexible but require the resolution policy to be correct and bounded (no oscillation). See the decision matrix below.

CRDTs — Conflict-Free Replicated Data Types (P4, C5, C6)

  • A CRDT is a data type whose merge operation is associative, commutative, and idempotent. Given any set of divergent replicas, merging them in any order converges to the same state — the merge is deterministic and terminating (P4 bound). CRDTs derive from C5 Reversibility (divergent state reverses to convergence) and C6 Composability (CRDTs compose: a CRDT map of CRDT registers is itself a CRDT).
  • State-based (CvRDT — convergent): each replica carries its full state; merge is a least-upper-bound on a semi-lattice. The payload is larger (full state per merge); the merge is simple (one function). Fits small state and unreliable networks.
  • Operation-based (CmRDT — commutative): each replica carries operations; merge is applying the operations in causal order. The payload is smaller (ops, not state); the delivery must be reliable and causally ordered. Fits large state and reliable transport.
  • The tradeoff: state-based is simpler but heavier; operation-based is lighter but requires causal delivery. Both guarantee convergence (P4); the choice is a C8 Economy decision (bandwidth vs delivery complexity).
// CRDT register: LWW-element-set (state-based, CvRDT). The merge
// is deterministic — the register with the later timestamp wins.
// Convergence is guaranteed (P4); the merge is idempotent (P5).

interface LWWRegister<T> {
  value: T;
  timestamp: number;   // monotonic clock; ties broken by node id
  nodeId: string;
}

function mergeLWWRegister<T>(
  local: LWWRegister<T>,
  remote: LWWRegister<T>,
): LWWRegister<T> {
  // The merge is associative, commutative, idempotent (P4, P5).
  // (local.timestamp, local.nodeId) > (remote.timestamp, remote.nodeId)
  // is a total order — no oscillation, no infinite loop.
  if (local.timestamp > remote.timestamp) return local;
  if (local.timestamp < remote.timestamp) return remote;
  // Tie: break by node id for a deterministic total order.
  return local.nodeId > remote.nodeId ? local : remote;
}

// The register is a CRDT: merge(merge(a, b), c) === merge(a, merge(b, c))
// for any replicas a, b, c. Convergence is guaranteed (P4).
// CRDT set: add-wins last-write-wins element set (state-based).
// Each element carries a timestamp; remove only wins if the
// remove-timestamp is later than the add-timestamp. This avoids
// the remove-wins-vs-add race (P4) without surfacing a conflict.

interface AWLWWSet<T> {
  adds: Map<T, number>;     // element -> add-timestamp
  removes: Map<T, number>;  // element -> remove-timestamp
}

function mergeAWLWWSet<T>(a: AWLWWSet<T>, b: AWLWWSet<T>): AWLWWSet<T> {
  const adds = new Map<T, number>(a.adds);
  const removes = new Map<T, number>(a.removes);
  for (const [el, ts] of b.adds) {
    adds.set(el, Math.max(adds.get(el) ?? 0, ts));      // add-wins union
  }
  for (const [el, ts] of b.removes) {
    removes.set(el, Math.max(removes.get(el) ?? 0, ts));
  }
  return { adds, removes };
}

function contains<T>(set: AWLWWSet<T>, el: T): boolean {
  const addTs = set.adds.get(el) ?? 0;
  const rmTs = set.removes.get(el) ?? 0;
  return addTs > rmTs;   // add wins on equal timestamp (P4 bounded)
}

Last-Write-Win (LWW) with Vector Clocks (P4, C1, C5)

  • LWW is the simplest conflict-tolerant strategy: the write with the latest timestamp wins. It is cheap, but it silently discards concurrent writes — the "lost update" is the correctness cost. LWW is correct only when the timestamp is a total order (a monotonic clock, not wall time), and when lost concurrent writes are acceptable (e.g., caching, presence, ephemeral state).
  • Vector clocks are the timestamp that knows about concurrency. A vector clock records the logical time of each node; two writes are concurrent iff neither vector dominates the other. LWW with vector clocks: a write that is causally later wins; a write that is concurrent conflicts and is resolved by a tiebreak (node id, wall time, or application policy).
  • The tiebreak is the P4 bound: the conflict must be resolved deterministically (no oscillation) and the resolution must be documented. A tiebreak by wall time alone (no vector clock) is a P4 violation waiting to happen — wall time skews across nodes, and a clock skew can flip the tiebreak, oscillating the merge.
// LWW with vector clocks (conflict-tolerant, P4 bounded). The
// vector clock records causal order; concurrent writes conflict;
// the conflict is tiebroken deterministically (no oscillation).

type VectorClock = Record<string, number>;  // nodeId -> counter

function compareClock(a: VectorClock, b: VectorClock): "before" | "after" | "equal" | "concurrent" {
  let aBefore = false, bBefore = false;
  const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
  for (const k of keys) {
    const av = a[k] ?? 0;
    const bv = b[k] ?? 0;
    if (av < bv) aBefore = true;
    if (av > bv) bBefore = true;
  }
  if (aBefore && bBefore) return "concurrent";
  if (aBefore) return "before";
  if (bBefore) return "after";
  return "equal";
}

interface LWWVectorState<T> {
  value: T;
  clock: VectorClock;
  writerId: string;  // tiebreak: deterministic, no oscillation (P4)
}

function mergeLWWVector<T>(
  local: LWWVectorState<T>,
  remote: LWWVectorState<T>,
): LWWVectorState<T> {
  const order = compareClock(local.clock, remote.clock);
  if (order === "before") return remote;       // remote causally later
  if (order === "after" || order === "equal") return local;
  // Concurrent: tiebreak by writer id (deterministic, P4 bounded).
  return local.writerId > remote.writerId ? local : remote;
}
  • The merge is idempotent (P5): merging the same two replicas twice yields the same result. The tiebreak by writerId is a total order, so the merge cannot oscillate (P4 bound).
  • A vector-clock merge that surfaces the concurrent conflict to the application (instead of tiebreaking) is also valid — the application resolves per its own policy. The P4 bound is that the resolution terminates; the policy determines whether the user sees the conflict or the system silences it.
  • The merge function is the heart of sync. Its properties (P4):
    • Associative: merge(merge(a, b), c) === merge(a, merge(b, c)).
    • Commutative: merge(a, b) === merge(b, a).
    • Idempotent: merge(a, a) === a (P5 — retried merges are safe).
  • Immutability aids merge: an immutable state representation (the CRDT payload, the LWW register with a clock) makes the merge a pure function of two inputs, with no in-place mutation race. See domains/concurrency/patterns (concurrency/P1 Immutability by Default) for the in-process immutability principle; sync is the cross-partition instance of it.
  • A merge that mutates in place is a P5 violation waiting to happen: a retried merge mutates the same state twice, and the result is not idempotent. Always merge into a new state; never mutate the inputs.

Conflict-Free vs Conflict-Tolerant Data Types (P4, C3 Simplicity)

  • Conflict-free (CRDTs): the data type guarantees convergence. The application never sees a conflict; the merge is deterministic. The cost: the data model is constrained (counters, sets, registers, maps of these). A conflict-free type for arbitrary JSON is hard; a conflict-free type for a counter is a PN-counter.
  • Conflict-tolerant (LWW, three-way merge, application policy): the data type can conflict; the resolution policy arbitrates. The cost: the policy must be correct and bounded (no oscillation), and the conflict may surface to the user. The benefit: any data model can be made conflict-tolerant (just pick a tiebreak).
  • The choice is the decision matrix below. It is a P4 decision (which bound), a C1 decision (which correctness cost is acceptable), and a C3 decision (which simplicity is affordable). See also domains/data/migrations for the schema-evolution angle — a schema change under sync must be compatible with both replicas, or the merge fails on the new shape.
  • A schema migration under sync is harder than a single-node migration: both replicas must understand the new shape, or the merge fails. The migration must be forward-and-backward compatible across all replicas that may still hold the old shape — see domains/data/migrations for the generic compatibility discipline.
  • A breaking schema change under sync requires a staged migration: deploy the new-shape-aware merge first (it accepts both shapes), then deploy the new shape, then deploy the old-shape-removing merge. A big-bang schema change under sync is a P4 violation: the replicas that have not yet upgraded will fail the merge, and the sync will not converge.
  • The merge function's version awareness is the P4 bound: the merge must handle every shape version that may exist in the fleet, or reject (and surface) the merge rather than silently corrupting.

CRDT vs Last-Write-Win — Decision Matrix (D-069)

Strategy When Correctness Guarantee Operational Cost Failure Mode
CRDT (state-based, CvRDT) The data model fits a CRDT (counter, set, register, map of these); convergence must be guaranteed without surfacing conflicts; the network is unreliable (full-state merge tolerates dropped ops) Strong eventual convergence — merge(a, b) === merge(b, a) for any replicas (P4 bound by construction) Medium — full state per merge (bandwidth); semi-lattice merge function per type; CRDT library or hand-rolled A bug in the merge function = silent divergence (C1); large state = bandwidth cost on constrained links (P3)
CRDT (operation-based, CmRDT) The data model fits a CRDT; bandwidth is constrained (ops are smaller than state); the transport is reliable and causally ordered Strong eventual convergence — same guarantee, smaller payload High — requires causal delivery (vector clock or broker with ordering); op transform must be idempotent (P5) Causal-delivery violation = lost ops = divergence; op-transform bug = silent divergence
Last-Write-Win (LWW) with vector clocks The data model is arbitrary (any JSON, any record); concurrent writes are acceptable to discard or tiebreak; a total order tiebreak (node id) is acceptable Bounded convergence — causally-later writes win; concurrent writes are tiebroken deterministically (P4 bound via tiebreak) Low — simple merge (compare clocks, pick winner); no CRDT library; small payload Concurrent writes are silently discarded (lost update); tiebreak by wall time = clock-skew oscillation (P4 violation); no vector clock = no concurrent-write detection = silent loss
LWW with wall-clock timestamp only The data model is ephemeral (cache, presence); lost updates are acceptable; the clock is roughly synchronized (NTP) Weak — convergence eventually, but concurrent writes may oscillate with clock skew; no concurrent-write detection Lowest — one timestamp per write; no clock vector Clock skew = oscillation (P4 violation); concurrent writes silently lost; not a correctness-safe strategy for durable state
Three-way merge (application-specific) The data model is structured (documents, forms); conflicts should surface to the user or a domain-specific resolver; the merge is field-level Bounded if the merge function is correct (associative, commutative, idempotent — P4, P5); conflicts surface per field High — application-specific merge function per type; UI for conflict resolution; user-facing conflict surface Merge-function bug = silent divergence or oscillation; unbounded conflict UI = user fatigue
  • The default for structured state that must converge silently is a CRDT (state-based for unreliable networks, operation-based for bandwidth-constrained reliable transport). The default for arbitrary JSON where lost concurrent updates are acceptable is LWW with vector clocks (never wall-clock-only for durable state). The default for user-facing documents where conflicts should surface is three-way merge with a documented resolution policy.
  • The failure-mode column is the P4 check: every row except wall-clock-only LWW carries a bounded failure mode (the bug is in the implementation, not the strategy). Wall-clock-only LWW carries an unbounded failure mode (clock skew = oscillation) and is a P4 violation for durable state. Use it only for ephemeral state where lost updates are acceptable.
  • The choice is a P4 decision (which bound) and a C1 decision (which correctness cost). A CRDT guarantees convergence but constrains the data model; LWW is flexible but discards concurrent writes. Neither is universally correct; the matrix is the decision tool.

Observability of Sync (P10 Edge Observability Survives Partition)

  • Sync is itself an observable operation: the merge count, the conflict count, the convergence lag (time from reconnect to convergence), and the divergent-replica count are first-class signals. A sync that runs forever without converging is the edge-sync-loop chaos anti-pattern (P4 breach); without observability it is invisible until the user notices the stale state.
  • A conflict that is silently resolved should be logged (the resolution policy applied, the discarded write's idempotency key, the winning write's clock). A conflict that surfaces to the user should be metricated (the conflict rate, the resolution time). See domains/observability/metrics for the generic discipline.
  • A divergent replica that has not converged after the expected window is an incident; without a metric it is invisible (P10 breach). Wire sync convergence to an alert — the divergent-replica-count is the sync analog of the messaging consumer-lag metric.

What Violates Sync Discipline

Violation Principle
Sync loop that oscillates forever (CRDT without merge-semantics, LWW without monotonic clock) P4 Sync Conflicts are Bounded, Not Infinite
LWW with wall-clock timestamp only on durable state (clock skew = oscillation) P4, C1 (no concurrent-write detection)
Merge function that mutates inputs in place (retried merge is not idempotent) P5 Edge Operations are Idempotent, domains/concurrency/patterns
Big-bang schema change under sync (replicas fail the merge) P4, domains/data/migrations
Conflict silently resolved with no log (the policy is invisible) P10 Edge Observability Survives Partition
Divergent replica with no convergence-lag metric (invisible stale state) P10, domains/observability/metrics
Three-way merge with an unbounded conflict UI (user fatigue, no termination) P4 (the merge must terminate)
Operation-based CRDT without causal delivery (lost ops = divergence) P4, C1 (the delivery contract is the bound)
Merge that surfaces every concurrent conflict to the user (no default policy) P4, C3 (the default policy is the simplicity bound)
Sync with no convergence test (the merge is untested under partition) P4, domains/testing/pyramid