The op log was peer-to-peer the whole time

ethanplant1 pts0 comments

The op log was peer-to-peer the whole time - Thiago AvelinoThe op log was peer-to-peer the whole time<br>Posted on Jun 25, 2026

Newsletter about software engineering, team management, team building, books and lots of notes I take after reading/studying (mine or yours)… :D

sign!

A month ago I wrote about building outl on the Kleppmann move-op paper. That post ended with a working sync: a terminal client on macOS and an iOS app, converging over iCloud Drive, no server, no merge dialogs.<br>iCloud was a deliberate shortcut, and I want to be honest about that before I bury it. I had one question to answer first: does this CRDT actually converge across real devices, two separate machines, not two processes on my laptop pretending to be peers? iCloud Drive answered it without me writing a line of networking. Drop the per-actor ops-*.jsonl files in the ubiquitous container, let Apple's daemon push them between devices, watch two replicas merge. A file-pusher I borrowed to validate the algorithm, and for that job it was exactly right.<br>The proof landed. The CRDT converged on two real devices, and once it had, the borrowed transport's ceiling was the only thing left in the room. Linux: no iCloud. Android: no iCloud. Anyone who doesn't want their notes on Apple's servers: no thanks. The algorithm was ready for a real network; iCloud was never going to be one.<br>So I swapped it. outl now syncs peer-to-peer over iroh: QUIC, hole punching, a relay only when the network forces one. Desktop, mobile, TUI, the CLI, and the MCP server all talk the same protocol. iroh is the default transport, transport = "file" is the opt-out for iCloud/Syncthing diehards, and pairing is one command.<br>The interesting part is what didn't change. The op log, the CRDT, the on-disk format: untouched. Both transports deliver the exact same ops-.jsonl files. iCloud pushed those files so I could trust the merge; iroh ships them so anyone can. This post is the swap and everything under it.<br>A transport swap, not a rewrite<br>The thing that made this a transport swap and not a rewrite: the data model never depended on iCloud. It was built for replication from the first commit, I just hadn't pointed it at a real network yet.<br>The shape on disk, from the last post:<br>ops/<br>├── ops-01HXY...A.jsonl ; only device A writes here<br>├── ops-01HXY...B.jsonl<br>└── ops-01HXY...C.jsonl

One append-only file per device. Every mutation any client makes serialises into a LogOp (an HLC timestamp, the actor, the operation) and appends to that actor's file and nowhere else. Each replica reads everyone's files, merges them by HLC, replays the move-op algorithm. That's the whole CRDT.<br>Three properties of that layout are the reason P2P was a port and not a project:<br>The op log is the offline buffer. Each device accumulates its own ops while disconnected. There is no separate queue, no outbox, no "pending changes" table. The thing you sync is the thing you already wrote to disk.<br>Each device knows exactly what it has seen. Storage::last_ts_per_actor() walks the merged log and returns the highest HLC per actor. That map is a vector clock, free, computed from data already in RAM:<br>fn last_ts_per_actor(&self) -> ResultHashMapActorId, Hlc>, StorageError> {<br>let mut map: HashMapActorId, Hlc> = HashMap::new();<br>for op in self.cache.read().iter() {<br>map.entry(op.actor)<br>.and_modify(|h| if op.ts > *h { *h = op.ts })<br>.or_insert(op.ts);<br>Ok(map)

Replay order doesn't matter. Ops merge by HLC with an actor tiebreak, so a device can receive them in any order and land on the same tree. A transport that delivers bytes late, out of order, or in bursts can't corrupt the result.<br>The only thing I had to build was a SyncTransport trait so iCloud and iroh were interchangeable, and a new implementation behind it. The file poller from the last post became FileSyncTransport. The new code is IrohSyncTransport. Client call sites didn't move.<br>The generalisation, once: if your sync is a CRDT over per-actor append-only logs, the transport is a detail. Swapping iCloud for QUIC touched zero lines of the algorithm. That's the payoff for putting the proof at the center and everything else at the edge.<br>The wire: vector-clock delta sync over QUIC<br>When two devices connect, neither dumps its whole log. They exchange vector clocks and ship only the gap.<br>The protocol is small. ALPN outl-sync/1. Every message is a 4-byte big-endian length prefix and a body. The handshake is four messages on a single bidirectional QUIC stream:<br>1. initiator → responder: SyncRequest { workspace_id, vector_clock: A }<br>2. responder → initiator: SyncResponse { vector_clock: B }<br>3. responder → initiator: ops blob (ops where op.ts > A[op.actor])<br>4. initiator → responder: ops blob (ops where op.ts > B[op.actor]), finish()

Both directions converge in one connection. The initiator sends its clock, learns the responder's, and each side computes the difference locally:<br>fn ops_missing_for(ops_dir, actor, peer_clock) -> ResultVecLogOp>> {<br>// ... load every...

icloud actor peer transport whole sync

Related Articles