GroupThink
Mechanism: Clocks You Cannot Trust
20 min read
Two of your devices edit the same note a minute apart, and one of them has a clock that runs four minutes fast. Under the obvious merge rule — newest timestamp wins — the wrong edit survives, nothing raises an error, and you find out weeks later, if at all. The repair is to stop asking when an edit happened and start asking what it had already seen: one small integer per device, and a comparison that never reads a clock.
Prerequisites: Mechanism: Reconciling by Digest for how a divergence gets found in the first place, and Staying in Sync for the loop this merge rule sits inside.
1. An edit that was never there
The digest round tells you that a namespace diverged and hands you the rows that differ. It does not tell you which version is right — and here that decision is entirely local, because no server holds an authoritative copy to defer to.
The obvious rule is the one almost every sync layer reaches for first. Every mutable row already
carries an updatedAt; on conflict, keep the larger. Last-write-wins. It was the rule here
too, in a function called mergeNewestWins, until the commit that generalized causal merge
deleted it.
Here is what it does. Your iPad's clock is four minutes fast. At 10:00 you jot a note on the iPad; it is stamped 10:04. At 10:02 you open the same note on your iPhone and rewrite the second half; it is stamped 10:02. The iPhone edit is genuinely later — it happened after, and it was made by someone who had just read the iPad's version — and it carries the smaller number. When the two devices reconcile, the iPad's text wins and the iPhone's rewrite is deleted.
Notice what does not happen. No conflict object is created, no error is logged, no second copy is kept for you to resolve. From inside the merge function, "the incoming row is older, keep mine" is byte-for-byte the same code path as "nothing to do here" — the overwhelmingly common case, and the one every test exercises. The loss has no symptom. You will not notice until you go looking for a paragraph you are sure you wrote.
Two separate things are wrong here. Even with perfect clocks, last-write-wins throws away one of two edits; that is a known and sometimes acceptable cost. With skew, it throws away the edit you expected to keep, and the effective rule becomes whichever device's clock runs fast wins — which no user can predict and no test will reproduce.
And clocks have the worst possible property for a correctness argument: they are usually fine. A phone on a network is NTP-synchronised to within milliseconds, so the bug is invisible in development, in review, and for most users most of the time. It surfaces on the device whose battery died and whose real-time clock reset, or the one a user set forward by hand. A guarantee that holds "usually" is a latent bug with a long fuse.
2. Counting instead of timing
Back up and ask what the merge actually needed to know. Not what time it was: whether the device that made the second edit had already seen the first. If it had, the second edit supersedes the first and there is no conflict at all — only a sequence. If it had not, the two are a genuine fork, and no timestamp can make either legitimately newer, because neither device knew the other existed. "Had seen" is a claim about information flow, and information flow can be counted.
A version vector is a map from a device identifier to a monotonically increasing integer. In
VersionVector.swift it is exactly that and nothing else — counters: [String: Int] — and every
row in a namespace that needs causal merge carries one. A missing key means zero. Two operations
define the whole semantics:
Bump your own dimension, never anyone else's. bump(_ device:) does
counters[device, default: 0] += 1, and the only device name it is ever called with is this
device's own. That restriction is the entire integrity story. Your counter counts changes you
made; the other entries record what you have received. If a device could write another
device's number, it could claim to have seen edits that were never delivered to it, and every
later comparison would believe the claim.
Merge by elementwise maximum. merged(with:) takes, for each device, the larger of the two
counts — reading a sibling's row means you now know everything it knew plus everything you knew,
and the elementwise max is exactly that union. It is commutative, associative, and idempotent,
which is what lets the reconcile run from both ends independently without the two runs fighting.
Comparison falls out of those two rules. relation(to:) walks the union of both key sets and
tracks two flags — is there a dimension where I am ahead, and is there one where you are? — which
gives four outcomes: .dominates (ahead somewhere, behind nowhere), .dominated (the mirror),
.equal, and .concurrent (ahead somewhere and behind somewhere). If my vector dominates
yours, every change you have counted I have counted too, plus at least one you have not — and the
only way I could have obtained your counts is by merging your row or by making those changes
myself. My version therefore descends from yours: a proof of ordering built from integers and a
> operator, with no clock anywhere in it.
One detail decides whether the dimensions mean anything: what a device calls itself.
SettingsSyncClock.configure(deviceID:) pins it to the device's envelope public key in hex, so
the axes are the real fleet, and until that happens the clock refuses to bump at all — because if
it bumped under the throwaway UUID it mints as a fallback, a later configure would orphan every
vector recorded under the old name, splitting one physical device into two axes that look
permanently concurrent.
3. The same edit, worked through
Take the scenario from §1 again, this time with vectors. There is a test for it —
notebookCausalEditBeatsWallClock — and the trace below is what it asserts.
Device A creates a note titled v1 and stamps updatedAt at wall-clock time 100.
RowClock.observe sees a row with no bookkeeping entry, registers one, and bumps A's dimension.
B then pulls the notebook namespace from A; B has no local row, so there is nothing to resolve —
it inserts the note and records the merged vector, which is A's:
A: "v1" updatedAt=100 {A:1}
B: "v1" updatedAt=100 {A:1}
Now B edits the note to v2. B's clock is behind, so the new updatedAt is 50 — earlier
than the value already on the row. On the next save, RowClock.observe notices that the live
row's updatedAt no longer matches the syncedAt it last recorded, and bumps B's own dimension:
A: "v1" updatedAt=100 {A:1}
B: "v2" updatedAt=50 {A:1, B:1}
A pulls from B and compares {A:1} against {A:1, B:1}. Dimension A: 1 versus 1, neither ahead.
Dimension B: 0 versus 1, the incoming vector is ahead. So selfGreater is false, otherGreater
is true — .dominated. A adopts v2.
That is the right answer, reached without either device reading a clock. B's version wins because
it descends from A's. The updatedAt values, 100 against 50, played no part; had they been
consulted, A would have kept v1 and the rewrite would have vanished.
Change one thing and the answer changes completely. Suppose B never pulled A's note and instead
materialised its own row under the same identifier. Then B's vector is {B:1}, A's is {A:1},
and the comparison finds each ahead on its own dimension: .concurrent.
4. Detected is not resolved
That last case is where a version vector stops helping, and it is worth being blunt about it,
because the machinery has a way of feeling like it solves conflicts. It does not. It classifies
them. Three of its four answers are decisions; the fourth is a question handed back to the
caller. (.equal belongs with .concurrent here, and the code groups them: two rows with
identical vectors and different content have no causal relationship either.)
VersionVector.resolveMerge holds the one policy the whole system shares. Where causality
decides, it obeys causality. Where it does not, two rules apply:
- Add-wins on presence. A live value beats a tombstone; two tombstones stay deleted. A concurrent delete and edit resolve toward the data existing. (Deletion has a whole article of its own — Deleting Things That Stay Deleted.)
- A caller-supplied tie-break between two live values. For rows it is
lexGreaterover the canonically encoded entry bytes; for settings it is the lexicographically larger string value.
Both tie-breaks are deterministic and clock-free, which is all convergence requires: every device, shown the same two candidates, picks the same winner. But be clear about what is purchased. The winner is not the better edit, or the newer edit, or the one you meant to keep — it is the one whose bytes sort higher. The rule buys agreement, not correctness, and correctness is not on the menu for any rule that must produce one row out of two independent ones.
A namespace can, though, choose an encoding that makes the arbitrary rule mean something:
per-conversation read marks ride the settings namespace as fixed-width integer-seconds strings
(ReadState.encode), so a lexicographic > agrees with a numeric one and the "larger string"
tie-break picks the later watermark. What the tie-break is deliberately not is "larger
updatedAt" — that would reintroduce the skew rule in precisely the case where it still had
teeth.
5. The weakest rule that works
A version vector is not free: a bookkeeping row per synced row, a field on the wire, a permanent
dimension per device. So the design pays for it only where the data genuinely needs it, and
RowClock.causalEntities is the single table that says where — notebook, saga, plans, and
contacts (with RelationshipState folded in beside Contact). It is deliberately the only
such list, because a split between "which namespaces are causal" and "which entities map to them"
would silently stop bumping a namespace's vectors: no error, only lost merges. Everything else
gets a weaker rule, justified by the shape of its data.
Messages: monotone bits, and a fold. Delivery only ever goes from false to true, so the merge
is if e.isDelivered, !existing.isDelivered { existing.isDelivered = true } — order-independent,
idempotent, needing no history at all. The identity backfills (peerUserIDHash, senderName)
are fill-if-empty and never overwrite a held value, so they can only gain information. Message
content, however, is no longer immutable: edits and deletes exist. The recorded reason for not
mutating the rows is that it would have forced vector clocks onto messages and lost the edit
history, so instead a message became a fold over an append-only set of immutable ops, resolved by
MessageFold.merge — a delete dominates by scope precedence (everyone > me > live) and
purges the plaintext; otherwise edits are last-writer-wins on (editedAt, editOpID). That last
rule has a wall clock in it; hold that thought for §8.
Memory: insert-if-absent. The memory log is append-only, so applyMemory is one line of
logic — if this id is not present, insert it — with no vector, no tombstone, and no comparison.
It is the weakest rule in the system, commutative and idempotent for free.
The precondition is doing real work, and there is a scar to prove it. Calendar events were once
ingested into the memory log by deleting a time window and reinserting it with fresh ids on
every pass. Under insert-if-absent, siblings dutifully resurrected the rows the ingester had just
deleted and every device minted new ids each cycle: unbounded duplication, and a memory root that
never converged. The fix was not a stronger merge rule but the recognition that these rows were
never append-only — each device re-derives them from EventKit — so they left sync entirely, which
applyMemory still enforces against stale peers that ship them.
A contact's device roster: single-writer, so not causal — on purpose. peerDevices is
mutable and does sync between your devices, and it deliberately merges last-write-wins on the
contact's own lastAdvertisedAt. The roster has exactly one author, the contact, whose
advertisements are already totally ordered by their own clock — and causal add-wins would
resurrect a device the owner deliberately dropped the instant a stale advertisement raced the
drop. The stronger, more general machinery would have been the less safe choice.
6. Finding a local edit without a hook at every write site
Something has to call bump. The obvious place is every write site — and counting them is the
argument against it: notebook edits from the UI, the agent's distillation writer on its own
context, settings screens, contact import, plan updates, the reconcile applier itself. A missed
call is not a crash; it is a row whose vector never advances and therefore loses every future
comparison — the same silent, unfalsifiable loss this article opened by removing.
So the bump is not at the write sites. It is at the one place every write passes through: a
single observer on ModelContext.didSave, registered with object: nil so it also catches saves
committed on the background agent runner's own context. RowClock.observe fetches the live rows
and the RowVersion entries for the affected namespaces and bumps any row whose updatedAt
differs from the syncedAt we last accounted for.
updatedAt is still being read — but look at how. It is a change detector, never an
ordering. The comparison is e.syncedAt != updatedAt, between the row's current value and one
this same device recorded from this same clock, and only inequality is tested, never <. Skew
cannot reach it: a device with a wildly wrong clock still notices that a value changed.
Two details keep the loop from feeding itself. On the apply side, RowClock.record stores the
post-merge syncedAt, so a row just adopted from a sibling is not misread as a fresh local edit
on the next save. And on the registration side, a namespace with no RowVersion entries at all
is being seen for the first time, so its existing rows register with an empty vector rather
than a bumped one. The reason is a one-line derivation: an empty vector is dominated by every
non-empty one, so "I have no history" resolves to "adopt whatever the fleet has" — exactly the
claim a device that merely launched is entitled to make. The same rule runs after a cloud-anchor
recovery, so a device restored from an old backup cannot out-version the fleet.
7. Why the clock stays out of the digest
From Reconciling by Digest: each namespace hashes into a Merkle root over per-row content leaves, and equal roots mean the two devices are provably in sync. It is tempting to fold the vector into the leaf, so equal roots would mean "same content and same history". That sounds strictly better. It is self-defeating, in three steps.
Content-equal with vectors unequal is the normal steady state, not an anomaly. The pull is one-directional and each end runs it independently, so the instant A merges B's row, A's vector is the elementwise max and B's is still its own — identical bytes, different histories, and B will not catch up until its own pull fires. A digest committing to the vector calls that divergence while the data is identical.
The repair for the pseudo-divergence manufactures more of it. resolveMerge returns the
merged vector for every outcome, including .keep. So "nothing about my content changed" would
still move my leaf, change my root, trigger a re-advertise, and pull a bucket of rows out of my
sibling — whose own merge then moves its root. The clock's bookkeeping would have become the
sync traffic.
And some vector divergence is genuinely durable. One recorded case: a version of respond
that scoped shipped tombstones to the mismatched key ranges withheld the tombstones of keys
deleted on both devices, leaving their vectors permanently divergent. Recoverable, precisely
because vectors are not hashed — had they been, the namespace would have advertised a mismatch no
transfer could close.
So RowVersion is the one @Model that backs no digest leaf — bookkeepingEntityName is
derived from the type rather than written as a string, so a rename cannot silently desync it —
and affectsDigest returns false for a save that touched only RowVersion, so the clock's own
bump saves schedule no advertisement at all. The set of entities that do back a leaf is an
allowlist rather than a blocklist, learned the hard way: under an earlier "anything but
RowVersion dirties the state" rule, sealing a digest advertisement rewrote the libsignal
ratchet store, which dirtied the state, which scheduled another advertisement — a loop between
siblings that never quiesced.
The rule underneath all of it is one sentence. The digest answers do we hold the same content?;
the vector answers how did we come to hold it? Only the first is a fleet-wide fact — the second
is per-device by nature, the same family as a message's local observedAt and its per-device
peerPubkey, both excluded from the message leaf for exactly this reason.
8. What this does not give you
Dimensions are forever, and they grow with devices, not with time. A vector entry is a 64-character hex device key mapped to an integer, stored on every row that device has ever edited, and nothing in the code prunes one. It is not even bounded by the devices you currently own: a device removed from the account re-onboards with a fresh identity, so a phone removed and re-added is two dimensions in every row it ever touched.
Pruning is harder than it looks, which is why it does not exist. If A drops a departed device's
counter from a row's vector and B has not, a pair that previously compared .dominated can come
back .concurrent, flipping a merge from "adopt" to "tie-break" — silently, toward the wrong
answer. Safe pruning needs every device to drop the same dimension: the fleet-wide unanimity
problem the convergence barrier builds for tombstones,
and it is not wired to vectors.
Concurrent edits still lose one, and lose it silently. Vectors tell you a conflict is real;
they do not repair it. The unit of conflict is the whole row, so two devices editing different
sentences of the same note produce one surviving note, not a merged one — and the code knows the
difference (relation returned .concurrent, the tie-break ran) without surfacing it anywhere.
The honest residual: the improvement over §1 is that the loss is deterministic and explicable
rather than dependent on whose clock drifted, not that the loss is gone. Making it visible would
mean a conflict surface; making it unnecessary would mean a per-field CRDT. Neither is built.
One wall clock survives, in messages. MessageFold.editIsNewer compares editedAt, which
ComposeService.editMessage stamps from the editing device's Date() at whole-second
resolution, tie-broken by a random 16-byte op id. That is last-write-wins on a wall clock, in the
system that removed last-write-wins on a wall clock everywhere else. The exposure is bounded —
only the author may edit their own message, so the race is between two of the author's own
devices editing inside the skew window — but it is the §1 hazard, still standing, in the
namespace with the most rows.
And the row is the granularity. A Contact and its RelationshipState share one clock, so a
contact-only edit can causally dominate while carrying a stale relationship — hence the explicit
guard against downgrading a newer local one. Splitting the relationship out is recorded as
deferred: the guard is a patch over a granularity choice, and everyone knows it.
References & further reading
- Leslie Lamport, Time, Clocks, and the Ordering of Events in a Distributed System (CACM, 1978) — the origin of the happens-before relation §2 implements.
- D. Stott Parker et al., Detection of Mutual Inconsistency in Distributed Systems (IEEE Transactions on Software Engineering, 1983) — where version vectors were introduced, for exactly the problem in §1.
- Marc Shapiro, Nuno Preguiça, Carlos Baquero, Marek Zawirski, A comprehensive study of Convergent and Commutative Replicated Data Types (INRIA RR-7506, 2011) — the formal setting for "commutative and idempotent merges converge", the property §5's weaker rules must satisfy.
- Wikipedia: Version vector.
- Mechanism: Reconciling by Digest — how divergence is detected, and why the digest commits to content only.
- Mechanism: Deleting Things That Stay Deleted — the tombstone half of the causal merge.
- Mechanism: The Convergence Barrier and the Cloud Anchor — the fleet-unanimity machinery pruning would need.
- Mechanism: Memory & the Notebook — why the memory log is append-only, which is what makes §5's weakest rule legal.