Mutations a form can’t express: merges, legal moves and stale edits

A CRM merge, legal issue transitions and stale-edit fences: treating changes as named server operations, with history in the write and a readable state after a crash.

November 20265 min readPlatformsInterfaces

A deep red slash, navy twist, flat mustard patch and chalk scrape cross a dirtied ground, two colliding in a ridge near centre.

The same buyer phones twice. Someone creates lead L-1 on the first call, someone else creates L-7 on the second, and over the following weeks both collect notes, stage changes and a history of who did what. What the CRM needs now is a merge, and a merge doesn’t fit the way a lot of business software changes data, which is to load a record, let somebody edit its fields and write it back. A merge touches two records, has to preserve what other people wrote, can be illegal and must not happen twice.

The CRM, issue tracker and kanban board I built with coding agents in August are reference applications on a shared framework, and they treat this as the normal case. A change is a named operation. The server decides whether it is legal, history goes into the same write as the state, and when an operation has to cross records, the order of the writes is chosen by asking what a crash should leave behind.

Finding the duplicate comes first, and the CRM is deliberately loose about it. Each lead gets index entries under its normalised phone number and email address, created if absent and never rewritten. The normalisation errs on the side of surfacing a possible match, because the merge is a human decision. A false match costs somebody a glance at two records, and a missed one leaves you with the duplicate you started with. These entries aren’t removed when a merge happens. Readers filter out leads that have already been merged, so a merged lead does not reappear as a merge candidate even though its index entries remain.

The merge itself has two records to change and no atomic envelope that covers both, so it runs as a saga, a sequence of separately committed phases that a retry can resume at the first unfinished one. It has two phases. Absorb copies the loser’s notes onto the winner with their original author and time, sorts them stably, renumbers them and adds a history entry, all in one compare-and-swap on the winner. It refuses to merge a lead into itself, into a lead that has already been merged, or from a lead that has already been merged. Archive then marks the loser as merged into the winner. The state after each phase is:

Completed phaseState left behind
AbsorbThe winner has notes from both leads; the loser is still live.
ArchiveThe loser is archived and readable, pointing at the winner.

I picked the visible mess. A crash after absorb leaves the same notes in two places, and anyone looking can see that something is unfinished. The opposite order would leave the loser archived with its notes not yet on the winner, a state that looks finished and isn’t. The difference is what you are left with if a retry never comes: notes in two places that someone can see and sort out, or an archived lead whose notes have dropped out of the live view, which nobody may notice until the customer mentions them.

Between the two phases a reader can see the notes twice. The engine records each completed phase, but that record is a separate write from the phase’s effect, so a crash between them runs the phase again. Absorb looks for its own stamp on the winner before copying anything. That recognises the earlier effect while the stamp remains; if another edit replaces it before the checkpoint is saved, the expected-timestamp fence can stop the replay with a conflict. Opting out of that fence does not turn the latest-action stamp into a history of previous merges. Archive has a different recognition rule: it looks at the loser’s merged-into pointer. If it names this winner the work is already done, and if it names a different winner then someone else’s merge got there first and this one gets a conflict. The loser is never deleted. It stays archived and readable, pointing at the winner, so an old link to L-7 still leads somewhere useful.

Legal transitions are the second thing a form can’t express. In the issue tracker an issue goes from open to in progress to done and back again, and there is no jump straight from open to done. The server owns that set and publishes the legal moves with every issue it returns, so a client has no reason to keep its own copy of the rules or to offer a move that will fail. A client that tries anyway, because it is out of date or because it is somebody’s script, gets a 400 with a field error listing the moves that are legal. The CRM’s pipeline stages are data, seeded per business and read on every legality check. Won and lost stages archive a lead, and an archived lead can be reopened but never deleted.

Stale edits are the third. A compare-and-swap protects the instant between the server reading a record and writing it. It does nothing for the person who opened that record ten minutes ago, because a compare-and-swap loop will happily re-read and apply their old edit on top of whatever happened since. So a field mutation also carries the updated-at timestamp the client loaded. If it doesn’t match, the server answers 409, saying the record has changed since you loaded it, and a client can send zero to opt out. That timestamp is written as the later of the current time and one past its stored value, so it advances on every write even when two writes land in the same millisecond, and a record that went from A to B and back to A can’t pass for one that never changed. The absorb phase checks the same kind of fence before it touches the winner. The two status codes tell the person different things. A 400 says the move you asked for isn’t legal from where the record is now. A 409 says the record isn’t where you thought it was, because someone changed it while you were looking at it.

History goes in the same compare-and-swap as the state, so the two can’t disagree, and the actor it names comes from the session and never from a request header, so a client can’t sign someone else’s name to a change.

The other apps apply the same ideas to different data. A card’s position on the kanban board only means something relative to its neighbours, so a move names those neighbours by id and takes a fractional position between them, which avoids renumbering the column. If a neighbour has moved to another column in the meantime the answer is a 409, and the card doesn’t land in a position nobody asked for. The board’s React interface, plain HTTP API and MCP tools all call the same loaders and actions, so an agent moving a card goes through the same guards as a person dragging one.

All of this makes the path for a mutation longer than a form post. What it buys is that each refusal means one specific thing that a person or an agent can act on, and that a partial merge leaves its source records readable.

Chris Chabot, November 2026