tomo docs
home github

Internals: the engine

The core of Tomo is a state machine with no I/O: no disk, no clock, no network. That's what lets two machines converge without negotiating, and what makes it testable with property tests.

The state-machine contract #

The tomo-engine crate is one pure function:

(index, event)  →  (index′, actions)

No I/O. No threads. No wall-clock reads. Feed it an event — a local file change, an incoming change from the peer, a tick — and it returns the next index and a list of actions for the adapters to execute: ship this change, apply that one, record a version, note a conflict. The platform watchers, the SSH transport, and the SQLite history store are thin adapters that turn the messy outside world into events and carry out the actions.

Because it's pure, the entire correctness surface can be driven by property tests: generate simulated event storms and deterministic time, replay them in every order, and assert the invariants hold. No sleeps, no flakiness — the engine either converges or it doesn't, deterministically.

Vector clocks, never wall time #

Every replica has a stable id, generated at tomo init and stored in .tomo/. Each version of a file carries a vector clock — a map from replica id to a counter. Comparing two clocks yields exactly one of three answers:

ComparisonMeaningEngine's response
A happens-before BB is strictly newerfast-forward: apply B
A equals Bsame versionno-op
A concurrent with Bneither saw the otherconflict — resolve deterministically

Wall-clock time is recorded for human display (tomo log's timestamps) and never orders anything the clocks can decide. A peer whose clock is three years wrong syncs correctly, because causality lives entirely in the vector clocks. The lone exception is the genesis adoption tiebreak below — and it fires only when the vector clocks are provably empty of ordering information, so there's nothing for a wrong wall clock to corrupt.

Multi-value registers and the lattice join #

Here's the subtle part. A naïve design stores one version per path and merges on conflict — and it provably diverges when a superseded intermediate version is redelivered out of order. Tomo instead models each path's index entry as a multi-value register: the set of concurrent causal heads (Dynamo-sibling style), bounded by the replica count.

Absorbing a version is a join-semilattice operation: drop any head the new version dominates, add the new one if nothing dominates it. Because a join is commutative, associative, and idempotent, replicas converge under arbitrary delivery order — including redelivery of old versions. A local edit collapses the head set: its clock is the merge of all current heads plus a tick, which keeps each replica's per-path version stream totally ordered.

concurrent {A:1, B:1} common ancestor {A:2, B:1} Present ★ winner {A:1, B:2} Tombstone · kept {A:2, B:2} join = a local edit

two concurrent heads (a conflict); a local edit ticks past both and joins them

The deterministic winner #

When the heads are concurrent, the on-disk file must show one of them. Both replicas pick the same one, with no messages exchanged, using a total order applied in sequence:

  1. Present beats Tombstone. An edit always wins over a concurrent delete — so a delete-vs-edit conflict keeps the edited content as the winner.
  2. Then higher content hash. A deterministic, content-derived tiebreak.
  3. Then the executable head, then larger canonical clock encoding. The final backstops.

(Equal hashes mean identical content, so the original "then replica id" tiebreak is unreachable for choosing state.) The winner is arbitrary but consistent. Correctness doesn't come from picking the "right" side — it comes from the guarantee that the loser, the winner, and the vector-clock evidence are all written to the history DB as a conflict record. Nothing is ever lost; recovery is tomo conflicts show / resolve --take-loser.

The one exception: first contact

That content-hash tiebreak is fine mid-session, but it falls apart at the first sync between two trees that already existed — say your laptop and a fresh git clone on a server that's since been edited. Every file pair is concurrent (the clocks share no history: {laptop:1} vs {server:1}), so "higher hash" is a coin flip. Half the time your stale copy wins. That's a real bug people hit.

So Tomo detects exactly this case and handles it differently. An entry is in adoption mode when its heads' clocks have disjoint replica support — no replica appears in two of them. That is the precise fingerprint of genesis, and the only time it can happen: after the trees have synced even once, every clock carries the other side's counter, and the support overlaps forever after. In adoption mode the order becomes Present > Tombstone, then the newer mtime wins (the copy you actually edited most recently), then the usual hash → exec → clock chain. The mode is a pure function of the head set, so both machines still compute the identical winner with no negotiation.

The mtime rides along as metadata, never identity: it's excluded from equality and from the change-detection signature, so a bare touch is never a sync. It's consulted only here, at genesis, where the vector clocks are provably empty of ordering information. One honest caveat: git clone stamps fresh mtimes on old content, so a genuinely older local edit you never pushed can lose to a fresher-looking clone of the same file. As always, the loser is kept in history — nothing is dropped.

Directories fall out of the same idea. Directories are implicit, so a path can collide as both a file and a directory. The applier resolves it with one structural rule — the directory wins (it contains real present data), and the colliding file converges to a tombstone recorded in history first. "Has a present descendant" is a pure function of the converged index, identical on both sides, so no negotiation is needed here either.

Echo suppression #

The nastiest correctness bug in a two-way sync is the echo: Tomo writes a file the peer sent, the local watcher fires on that write, and — if nothing stops it — the change ping-pongs back, potentially resurrecting a deleted file forever.

Tomo journals every write it performs, recording the path and the expected post-write signature (hash, size, mtime). A watcher event that matches a journaled expectation is swallowed and its journal entry retired. Combined with writing through .tomo/staging + atomic rename, this means a Tomo-applied change never re-enters the engine as if it were a fresh local edit — while a genuine user edit to the same file (a different signature) still gets through.

Why delivery order can't diverge replicas #

Put together, the guarantees are:

Two replicas, any delivery order, any clock skew, any crash and restart: same tree, same history, and no messages exchanged to agree on it.