Merge or refuse? Choosing a conflict model for text, cards and drafts
A test for when concurrent writes should merge and when they should be refused, applied to a text CRDT, a task board and fenced draft publishing.

Suppose two people open the same plan and both edit the line that reads “ship on Tuesday”. One makes it “ship on Wednesday”. The other makes it “ship after the security review”. A text CRDT handles this without complaint: the deleted characters become tombstones, the inserted characters get unique positions, and every replica converges on exactly the same line, which will be something like “ship after the security reviewWednesday”. The algorithm has done everything it promised. All the replicas agree, and nobody reading the plan knows when it ships.
That is why I don’t think the useful question is whether to use a CRDT. The useful question is what the two writes mean. Two edits can merge when both of them can be true at once. They should come back as a conflict when at least one of them is a decision about something the other has changed, because at that point no procedure knows what the person would have wanted, and the honest move is to ask. During August 2026 I built a text CRDT, a task board and a draft-and-publish flow for a planning app, all in my Ferrite project and with coding agents doing a good part of the work, and they sit at different points on that line.
When nothing can conflict
The CRDT belongs to the Logoot family, which goes back to a 2009 paper by Weiss, Urso and Molli. Every character gets a position identifier when it is inserted: a path of digit-and-site pairs, with 2^30 digits available at each level, plus a per-replica counter. The document is nothing more than a sorted map from those identifiers to characters, so convergence follows from the data structure rather than from anything the replicas negotiate. Apply the same set of operations in any order and you get the same map, and therefore the same text. Deletes leave tombstones, which lets a delete arrive before its insert and still come out right, and applying an update twice changes nothing. One test has two replicas type into the same gap in AZ and expects both to end on AabZ, with neither character lost.
What a CRDT cannot do is make conflicts disappear. It moves them out of the user interface and into an invariant: no two inserts may ever mint the same identifier, which depends on every minted identifier ending in the site of the replica that minted it. The package landed on 8 August, and on 9 August that invariant needed a fix. In the branch that handled neighbors with equal digits, the allocator had been stamping a site derived from the neighbors rather than the minter’s own, so the test that guards it now places neighbors at sites 1 and 3 and inserts between them from sites 5 and 7. A sibling test checks that concurrent inserts into such a gap keep every character. That is what bugs look like on this side of the line. A bad fence produces an annoying error message that someone reports. Two characters with one identifier would occupy one slot in a sorted map, and nothing anywhere would complain.
The planning app adopted the package the day it landed, merging updates on the server and snapshotting the merged state, with a relay kept for liveness. About two weeks later, after the app moved onto a new framework in a much larger migration, the package was gone from its manifest and editing had been rebuilt around owner-scoped drafts and a fenced publish. A change made inside a migration of that size is no verdict on CRDTs and I wouldn’t read one into it. The package stayed in the registry as a mergeable text primitive, deliberately not wire-compatible with Yjs and with no claim to be a live collaborative editor. What the history does provide is one product under two conflict models, and the second is worth looking at closely.
One draft each
The draft model hangs off two rules. The first is that a draft outlives the request that created it. Editor state is stored per owner, so two people working on one plan hold two drafts and neither can overwrite the other’s. A draft survives a reload without becoming a revision, and it lapses on idleness through the store’s own TTL, with no status field and no sweeper. The second rule is that a stale publish is refused, and the fence is the revision number the draft was based on.
Say Alice and Bob both start from revision 7. Alice publishes, the plan moves to revision 8 and her draft is cleared. Bob’s next save is flagged as behind, so he can find out while he is still typing. If he publishes anyway, the server answers 409 and tells him that the plan “is at revision 8 but you edited revision 7; reload and try again.” His draft stays where it was, still flagged. Nothing has been lost and nothing has been guessed: Alice’s text is published, Bob’s is in his draft, and the question of how they relate is with the one person who can answer it. Had he gone on holiday instead, the draft would have lapsed, and a publish would get a 400 saying so.
The same fence covers agents, and for agents I think the argument is even stronger. People and agents share a planning room, and a person can ask an agent to propose a revision and then review the proposal, which is fenced on the revision it was written against. An agent that has spent a while reasoning about revision 7 has made exactly the kind of stale decision Bob made, and the person reviewing its proposal is approving that specific text. Merging it into a document that has since moved would produce something nobody wrote and nobody reviewed.
Three answers on one card
A task board looks too simple for any of this, yet a single card needs three different answers, because its writes mean three different things.
Comments are the easy case at the level of intent: adding one does not replace the other, even if the people writing them disagree. The app therefore accepted comments without a client timestamp fence.
Moving a card is more interesting. A card’s position is a fractional index, a string over the alphabet 0-9a-z generated between the positions of its neighbors, with trailing zeros forbidden so that there is always room for another string between two distinct valid positions. A drag therefore writes one card’s record instead of renumbering the column, so two different cards can be placed in one gap without competing for a shared ordering record. The drag can opt out of the timestamp check and name its neighbors by ID instead. The server resolves them when the move is applied, and if one of them has left the column the move is refused as “no longer in” that status. A drag means “between these two cards”, so that is the premise the placement rule checks.
Editing a field is the opposite case. Choosing a value is a decision made in light of the value you saw, so an edit carries the updated_at_ms it read. When the server loads a card whose timestamp differs, it returns 409 rather than applying a decision made against an older value.
A fence also has to tell someone else’s change from your own retry. Once an admission has been recorded, a retry with the same idempotency key gets that answer before reaching the mutation again. The history entry lives inside the same task record as the change, so one record write carries both.
That last write exposes an important gap in the task board’s implementation. The timestamp was checked when the card was loaded, but the updated card was then written unconditionally. Two writers could both pass the check before either wrote, and one could overwrite the other’s fields or appended comments. Choosing the right conflict rule is only half the job; the storage operation has to enforce it. The planning app’s publish path did that with a compare-and-swap loop: it read the record and storage version together, wrote only if the version still matched, and re-evaluated the revision fence after a race.
Where I draw the line
Put side by side, these apply one test at different sizes: what did the writer actually decide, and does the other write take away the basis for that decision? Two comments, two characters typed in different places, two cards dropped into one gap: both can be true, so merge them. Two values for one field, two revised versions of one document, an agent’s rewrite of text that has since changed: somebody decided without seeing the other decision, so ask. A character-level CRDT sits awkwardly between these, because it merges characters and people mean sentences. I would reach for one when people are in the document together, watching each other’s cursors and repairing a bad merge the moment it happens. I would be much slower to reach for it when a draft stays open for an hour or when the other author is an agent that can’t see you typing.
Refusing a write sounds like the unfriendly choice, and it would be if a 409 threw away the loser’s work. What makes the fence affordable is everything built around it: the draft survives the reload, the behind flag can arrive before the publish does, and the error names both revisions. With those in place a conflict costs Bob a reload and some reading. Without them, almost any merge would look better than losing his afternoon.
Chris Chabot, November 2026