One node, two writers: how my split-brain fix doubled every title
Last month I gave every page root a deterministic id derived from its slug, so two devices that create the same day's journal land on the same node and merge instead of splitting. It worked. It also quietly doubled the title of every journal opened on two devices: '2026-06-25' became '2026-06-252026-06-25'. The Create op converged to one node exactly as designed, but each device also wrote the slug into that node's text CRDT, and two concurrent inserts at position zero don't overwrite, they concatenate. This is the story of two convergence mechanisms that are each correct and compose into a wrong answer, the two-replica test that proved it in eight lines, and why the fix was to stop using a sequence CRDT for a value that only ever has one writer's worth of meaning.
The sidebar had a page called 2026-06-252026-06-25.
The date was 2026-06-25. Written twice, no separator, mashed into one string. Two days below it, 2026-06-23 had done the same thing. Today’s journal, 2026-07-11, was clean. So were most days. Only some of them had doubled, and the ones that had were older.
I’d seen this shape of thing before. A month ago I wrote about two “today” pages flickering on my phone, a split-brain where the same day existed as two separate roots in the op log. The fix there was to give every page root a deterministic id derived from its slug, so two devices that create the same day always land on the same node and merge. That fix is why this bug exists. It did its job perfectly, and its job was the first half of the trap.
the setup, in one paragraph
outl is a local-first outliner. Every change is an operation appended to a per-device log, and the outline you see is a projection of that log replayed through a tree CRDT (Kleppmann’s move operation). A page is a node under the root, tagged with a slug property. The node’s title used to be the node’s own text, and block text is a separate CRDT: each block is a Yrs document, the Rust port of Yjs, so two people editing the same block converge character by character. Two things about a page root matter here. Its id is derived deterministically from its slug, sha256("outl-page:" + slug) folded into a fixed 128 bits, so every device computes the same id for the same slug. And its title lived in its text, which is a sequence CRDT.
Hold those two facts next to each other. That’s the whole bug.
it is not what it looks like
My first guess, and I’ll admit it out loud because it was wrong, was that this was a rendering artifact. Some snapshot cache leaking a stale copy, or the sidebar row rendering the title twice. Both were wrong, and ruling them out is worth a second.
The sidebar’s “recent” list is built from the real page list, filtered by a handful of slugs in local storage. Snapshots in outl are binary boot caches, one blob on disk that lets the app skip replaying the whole log on open. They are never nodes, they never appear in the page list. So the doubled entry was not a snapshot leaking in.
And the title itself resolves by precedence, not concatenation:
let title = match workspace.tree().property(id, TITLE_KEY) {
Some(PropValue::Text(s)) if !s.trim().is_empty() => s.trim().to_string(),
_ => workspace
.block_text(id)
.filter(|t| !t.trim().is_empty())
.unwrap_or_else(|| slug.clone()),
};
There is no format!("{}{}", a, b) anywhere in that path. The function reads one source and returns it. So if it returned 2026-06-252026-06-25, that string was not assembled at read time. It was sitting in the block’s text, already doubled, stored that way. The corruption was in the data, and I had to explain how it got written.
two mechanisms, each correct
Here is what happens when you open a journal that doesn’t exist yet. The code computes the deterministic node id from the slug, creates the node, tags it with the slug and kind, and sets its text to the title. For a journal the title is just the date, so the text becomes 2026-06-25.
Now run that on two devices, both offline, both opening the same day.
Device A creates node D (deterministic id for 2026-06-25) and inserts 2026-06-25 into D’s text. Device B, having never seen A, creates node D too, the same id by construction, and inserts 2026-06-25 into D’s text. Then they sync.
The Op::Create for D from both devices is idempotent. Creating a node that already exists is a no-op, the CRDT converges to one node D. This is exactly the property I added the deterministic id to get, and it works. No split-brain, one root.
But the two text inserts are a different CRDT with a different rule. Each device inserted 2026-06-25 at position 0 of D’s text, and neither had seen the other’s insert. To a sequence CRDT those are two concurrent insertions at the same position, and a sequence CRDT does not pick a winner. It keeps both and orders them by a deterministic tiebreak. Both copies survive. The text converges, deterministically, to 2026-06-252026-06-25.
That is not a bug in Yrs. Concatenating concurrent inserts is the entire point of a text CRDT, it’s why two people typing in the same paragraph at once both keep their words. The bug was mine: I used a sequence CRDT for a field whose two “concurrent edits” were the same fact written twice, where the correct merge is not “keep both” but “they’re the same, keep one”.
The deterministic id is what aimed both writers at the same node’s text. Before that fix the two roots had different ids, so the two title inserts landed in two different documents and never met. Closing the split-brain is what let the concatenation happen. One fix’s success was the other bug’s precondition.
the eight-line proof
I don’t trust a mechanism I’ve only reasoned about, so I wrote the smallest thing that would prove it. Two workspaces, two actors, the same deterministic node, each sets the same text, cross-deliver the ops, read the result.
let root = NodeId::from_slug("2026-06-25"); // same id on both devices
ws1.apply(create(root, &g1)).unwrap();
ws2.apply(create(root, &g2)).unwrap();
let e1 = edit(root, "2026-06-25", &g1); // A writes the date
let e2 = edit(root, "2026-06-25", &g2); // B writes the date
ws1.apply(e1.clone()).unwrap();
ws2.apply(e2.clone()).unwrap();
ws1.apply(e2).unwrap(); // sync
ws2.apply(e1).unwrap();
assert_eq!(ws1.block_text(root), ws2.block_text(root));
Both replicas agree. That’s the CRDT holding up its side, the two devices converge to the same string. And the string they converge to:
REPRO converged title = Some("2026-06-252026-06-25") / Some("2026-06-252026-06-25")
There it is. Not a divergence, not a race, not a lost write. Perfect convergence, to exactly the wrong value. That distinction is the whole lesson and I’ll come back to it.
It also explained the pattern in the sidebar. Today was clean because I’d only opened it on one device that session, one writer, one insert. The doubled days were the ones I’d opened on both the laptop and the phone. The workspace I drive only from the command line, single actor, never doubled anything, because there was never a second concurrent writer. The bug needed two devices and a shared day, which is why it hid for so long and then showed up the moment I lived on two devices.
the fix, part one: stop writing it to a sequence
The title of a page has one meaning. For a journal it’s the date. For a regular page it’s the name you typed. There is no scenario where two devices writing that value should keep both copies. So it shouldn’t live in a sequence CRDT at all. It should live in a last-write-wins register, where two concurrent writes resolve to one by clock order.
outl already has one. Properties are set through Op::SetProp, which is a register keyed on the node and property name, ordered by a hybrid logical clock with an actor tiebreak. Two devices writing title = "2026-06-25" concurrently don’t concatenate, they resolve to one value, deterministically, by HLC order. Same input, opposite merge semantics, correct answer.
So page creation stopped writing the title into the root’s text and started setting a title:: property instead:
let node = create_with_explicit_id(ws, hlc, node_id, root, position, None)?; // no text
set_prop(ws, hlc, node, SLUG_KEY, slug)?;
set_prop(ws, hlc, node, KIND_KEY, kind)?;
if title != slug {
set_prop(ws, hlc, node, TITLE_KEY, title)?; // LWW register, not a sequence
}
The if title != slug guard is the nice part. A journal’s title is its date, which is its slug, so the branch is skipped and a journal stores no title at all. The display falls back to the slug, which is the date, which is what you wanted on screen. A journal’s markdown file stays free of a redundant title:: 2026-06-25 line, and there is no title write to ever go concurrent. The whole class of bug is gone for journals, not patched. Regular pages, where the title genuinely differs from the slug, get a title:: register that converges to one value under exactly the concurrency that used to double it.
There’s a visible side effect I decided was an improvement, not a regression: a regular page created in the app now writes a title:: <name> line at the top of its .md. It didn’t before, the title lived only in the op log and never made it to disk in a form the file itself carried. Now the file is self-describing and round-trips cleanly. Four tests that asserted the old title-less markdown had to learn the new line. That’s the correct direction of change.
the fix, part two: heal what’s already doubled
Prevention doesn’t touch the days that already doubled. Those strings are sitting in the op log on my devices right now. So there’s a repair pass: for every journal root whose text is its slug repeated two or more times, clear the text, and let the display fall back to the slug.
fn is_repeated_slug(text: &str, slug: &str) -> bool {
if slug.is_empty() || text.len() <= slug.len() || text.len() % slug.len() != 0 {
return false;
}
let k = text.len() / slug.len();
k >= 2 && *text == slug.repeat(k)
}
Two of the design choices in that pass are load-bearing. It only matches k >= 2, so a normal single-copy journal title is left untouched and a clean workspace emits zero operations. And it’s restricted to journals, where the title equals the slug and slug-repetition is an unambiguous corruption signature. A regular page’s title is a name you chose, it is not a repetition of its slug by construction, so the pass never guesses at those. The clear itself is a normal Op::Edit through the log, so the repair converges to every device the way any edit does. One device cleans the doubled title, and every other device receives the clean state on sync.
The one thing I did differently from the split-brain repair in the last post: this one does not run on the boot path. It scales with the number of pages, and opening the app has to stay instant, so it rides the background reconcile pass each client already runs after the first paint, not the synchronous open. Boot pays nothing. The repair happens a beat later, off the critical path, and converges out from there.
converges is not converges-to-what-you-meant
The line I keep rereading in that test output is the assertion that passed. ws1 and ws2 agreed. The system was never inconsistent. Both devices, given the same operations, computed the same title, every time, deterministically. By every formal definition of convergence, the CRDT was correct.
And the answer was 2026-06-252026-06-25.
That’s the trap, and it’s a quieter one than a crash or a divergence. “This state is a CRDT, so it converges” is true and tells you almost nothing about whether it converges to a value you can live with. A sequence CRDT converges concurrent inserts by keeping all of them. A register converges concurrent writes by keeping the last. A counter converges by summing. Those are three different right answers, and picking the wrong one gives you a value that is perfectly consistent across every device and perfectly wrong on all of them.
Choosing the merge semantics is the actual design work, per field, and it’s easy to skip because the machinery underneath makes everything “converge” and the word lulls you. Block body text is a sequence, because two people typing should both keep their words. A fold flag is a register, last flip wins. A page’s title is a register too, and I’d accidentally filed it under sequence because it happened to be stored as text and text happened to be a sequence CRDT. The storage type had quietly chosen the merge semantics for me, and it chose wrong.
The deterministic id from last month was a good fix. It closed a real split-brain. It also pointed two writers at one node and handed the wrong merge semantics a loaded situation to converge, correctly, into garbage. Two fixes, each right on its own, composing into a bug neither one could cause alone. The lesson isn’t to distrust CRDTs. It’s that “it converges” is the beginning of the question, not the answer, and the answer is a different word for every field: to what.
The fix is in the outl repository.