This one didn't start as a project. It started as a chapter. I was reading Designing Data-Intensive Applications, Martin Kleppmann's database book, going through it slowly: B-trees, how an index is actually stored on disk, encoding and schema evolution, replication, message brokers. Then I hit the part on collaborative editing, and it mentioned Operational Transformation, the algorithm behind Google Docs. The bit where two people type into the same document at the same time and it all just works.

I'd used Google Docs a thousand times and never once wondered how the cursor-dance underneath it stays consistent. So I stopped and pulled on the thread. And the thread, it turned out, went a very long way down.

The question that wouldn't leave me alone

OT is clever, but it's old, and the more I read the more it looked fragile. It needs a central server to be correct. The decentralized versions have a long graveyard of published-then-disproven algorithms behind them. Merging two long-offline edits can be catastrophically slow. My actual question was simple and a little naive: is this the best we can do, or has somebody fixed it?

So I did the obvious thing and tried to reason out how I'd improve it. Store the original operations instead of the transformed ones. Only do expensive work when edits genuinely conflict, which in real documents is almost never. Keep a lean event log and rebuild the conflict-resolution machinery only for the rare overlapping bits.

Then I went looking, and found out I'd just described an algorithm called Eg-walker, published in 2024. Best-paper award at EuroSys 2025. And here's the part that made me laugh out loud: one of its two authors is Martin Kleppmann, the author of the very book that sent me down this hole. The other is Joseph Gentle, who'd built the OT engine inside Google Wave a decade earlier and spent years concluding it was the wrong path. I'd reinvented the state of the art by accident. Which is humbling, but it's also the best signal you can get that you're asking the right question.

The actual gap

Reinventing something that already exists isn't a contribution. So I kept reading, looking for where Eg-walker actually stops. And it stops in a very specific place: it only does plain text. The paper says so directly. Rich text, the bold and the italics and the links and the comments, is listed as future work. No shipping implementation had it.

Then I found the sentence that turned a curiosity into a project. The team that got closest to it, the people behind a CRDT library called Loro, had effectively published an impossibility claim: that Peritext (the reference design for collaborative rich text) cannot be modeled on Eg-walker. Their reasoning is sound on its face. Eg-walker replays operations and needs each one to mean the same thing no matter what state the document is in. Rich-text formatting is the opposite: whether a character ends up bold depends on the order things merged. So they routed rich text onto a separate layer entirely, and paid for it with permanent per-character metadata.

A stated impossibility is the most interesting thing a person who likes problems can find. I didn't want to disprove Loro, their engineering is excellent and their workaround is reasonable. I wanted to know whether the impossibility was real or just a framing.

The idea: stop asking replay to do the hard part

The whole thing turned on one observation. The impossibility argument assumes rich-text resolution has to happen during replay. But it doesn't have to happen there at all.

So I split the problem in two. Replay stays completely pure and order-independent: it treats a "bold starts here" marker as nothing more than a zero-width character, drops it into the sequence, and never once asks what it means. All the order-dependent Peritext logic, which character is bold, which formatting is newer, where a paragraph breaks, gets pulled out into a separate pure function that runs only when you actually read the document. Replay places the marks. Resolution interprets them. Neither one ever does the other's job.

That separation is the entire trick, and it's enough to dissolve the impossibility. Replay never needs to be order-dependent because it never decides anything. Resolution is allowed to be order-dependent because it's a deterministic function of the final history, so every replica computes the identical answer.

1 · Causal graph immutable event graph · (agent,seq) ids + parents vendored 2 · Op log ins · del · markStart · markEnd · blockBoundary new ops 3 · Pure replay base FugueMax · anchors integrate as zero-width items no stickiness · no order-dependence unchanged replay places anchors but never interprets them 4 · Pure resolution deterministic fn(items, causal graph) → spans + blocks expand / contract · per-position LWW paragraph-start inheritance · block partition the idea 5 · Resolved snapshot { text, spans, blocks } O(document) · zero per-character metadata new
Replay (grey, unchanged Eg-walker) only places zero-width anchors. Every order-dependent rich-text decision lives in the pure resolution function (green). That split is the whole refutation.

The hurdle: a fuzzer found a bug twenty tests missed

My first working version had a subtle optimization. When you type at the edge of a bold span, the engine decided right then, during placement, whether your new character should join the bold or fall outside it. It passed every test I wrote. Twenty of them, including the full suite of Peritext's famous failure cases. I thought I was done.

I wasn't, and the thing that caught me is the part of this project I'm proudest of. I'd written a differential fuzzer: it generates random concurrent editing histories and runs each one through both my engine and a completely independent reference model I built on a different CRDT, then checks they agree. On its very first random seed, it found a disagreement. Three replicas of the same document, fed the same edits in different orders, produced different results. A convergence bug. The one thing a CRDT is never allowed to do.

The cause was exactly that clever optimization. Deciding bold-or-not during placement meant peeking at the half-built document, and the half-built document looks different on different machines depending on which concurrent edits have arrived so far. My twenty hand-written tests never caught it because none of them happened to interleave a formatting mark and a paragraph break and a concurrent insert in the one specific way that triggers it. The fuzzer found that needle on attempt number one.

The fix wasn't a patch. It was the architecture in the diagram above. I ripped the decision out of placement entirely and made placement dumb and pure, and moved every formatting decision into the resolution function where it can see the whole finished history. The bug that the fuzzer found is what forced the design into its correct, cleaner shape. I'd have shipped the subtly-broken version without it.

The payoff: merging paragraphs came almost for free

There's a moment in a project like this where the architecture starts paying you back, and for me it was paragraph merging. Splitting a paragraph was easy from the start: a paragraph break is just another zero-width marker dropped into the sequence. But the opposite, merging two paragraphs back into one by deleting that break, is the kind of thing that turns ugly in a naive design. You're removing a structural boundary while other people might be typing on either side of it.

In the split-brain version of this problem you reach for special cases. In the decomposed version it barely counted as work. Deleting a boundary is just one more operation that tombstones a marker by its identity, and the resolution function already knows how to ignore a tombstoned marker. So the boundary disappears, the text on both sides falls into a single block, and the merged block keeps the type of the paragraph that came before it. Every bit of that is resolution doing exactly what it already did, with nothing new to teach it. I added the op, wired up a backspace-at-the-start-of-a-paragraph helper and a delete-across-a-boundary one, taught the fuzzer to generate merges too, and it stayed green across the same hundred-thousand-plus concurrent histories.

That's the part of decomposition nobody warns you about. The first feature costs you the entire architecture. Every feature after it is suspiciously cheap.

Evidence over assertions

Because the whole point was to refute a claim, I couldn't just say "it works." I had to be able to show it, to a reader who knows this space and is rightly skeptical. So the repo is built evidence-first.

Every Peritext failure case the design has to survive is a test named after its citation: peritext_P1_concurrent_overlap_bold, yjs_197_orphaned_markers, and so on. The README's claims link straight to the tests that back them. The differential fuzzer ran clean across more than a hundred thousand random histories over dozens of seed bases, with the engine converging on itself every single time.

And here's the moment that justified the paranoia: at one point a check reported "ten thousand iterations, all green," and a verification pass caught that the ten thousand had all used the same starting seed. Fresh seeds surfaced real disagreements it had been quietly skipping. So I made the comparison genuinely sound, proved it by deliberately injecting a bug and confirming it fails on the first iteration, and only then believed the green. The benchmarks get the same honesty: my reference engine is slower than Yjs and Automerge and its log is larger, and the README says so plainly. The one real claim, that the resolved document carries zero per-character metadata, is the only one I make.

What it is, and what it isn't

It's a reference implementation and a proof of an idea, not a production library, and the README is loud about that. There's no binary codec yet, no network layer, no nested lists, no cursor presence. Those are real work and they're listed as exactly that: deferred. I'd rather ship a small true thing than a big thing with an asterisk I'm hoping you won't read.

I should also be honest about the method, since this whole blog is partly about building with agents: I didn't hand-type fifteen thousand lines of CRDT code over a weekend. I drove this the way I drive everything now, orchestrating agents to research, implement, and adversarially review each piece, with me steering the architecture and refusing to accept a green checkmark I couldn't independently reproduce. The convergence bug, the cherry-picked seed, the honest benchmark framing: those came out of building review and verification into the loop, not out of trusting any single pass. The discipline is the product as much as the code is.

Why I'm sharing this

I started this because a database textbook made me curious about Google Docs, and I followed that curiosity past the edge of what the textbook covered, past the edge of the published algorithm, and into a spot where someone had written down the word "can't." That word is an invitation. It turned out "can't" really meant "not the way we tried it," and the way around it was to stop asking one part of the system to do the hard part and let a different part do it instead.

The first implementation of Peritext-semantics rich text on Eg-walker is open source, MIT-licensed, with the evidence to back every claim sitting right next to the code. If you live anywhere near CRDTs, or you just like watching an impossibility turn into a decomposition, it's on GitHub.