Two people, one folder, and no lock anywhere

The claim we started from, the four layers we tested it in, and the six gaps that came out

10 min read | KEIBIDROP Series | August 2026

By Marius-Florin Cristian

Two computers share one folder. Both people work inside it with ordinary applications: a text editor, Blender, Word. No server owns the files, and nothing anywhere takes a lock. The first question a newcomer asks is why that does not lose work.

We wrote the answer down as a claim before testing it, tested the claim in four layers, and then fixed what the testing found. This is that round, in the order it happened.

The claim: at exactly two computers, one extra number per change is enough to detect every concurrent edit. Every change we send carries the newest version its author had seen when the edit started. A receiver holding a version above that number can prove the incoming change was made without seeing what it replaces. It keeps its own bytes as a visible file beside the winner, and that file syncs to both machines. No version vectors, no operation log, no clock synchronisation.

Most of what applications do needs no coordination at all

Creating a file nobody else has, appending to the end of one, and the usual save pattern of writing a temp file and renaming it over the original: none of those need two machines to agree on anything first. They can run at full speed and still end up in the same state. The CALM theorem gives that class its name. An operation whose result does not depend on having seen the whole history is monotone, and monotone operations are coordination-free.

The residue is small, and the literature says in advance what is in it: two people changing the same file at the same time, a delete racing an edit, and two people moving folders into each other. Those three are where the work went.

For that residue there are two obvious answers and we take neither of them. Locking is out because a lock makes every save wait on a round trip, and the whole point of a mount is that it behaves like a local disk. Merging is out because the file formats coming out of Blender and Word cannot be merged safely by anyone, and a system that tries will eventually hand somebody a project file that opens to garbage. What is left is narrower and reachable: the same state on both machines, the newer version holding the name, and the losing version surviving as a file on both machines.

One number per change carries the whole mechanism

The number is the edit base. It is max(HeldMtimeNs, LastAnnouncedMtimeNs), snapshotted at the first dirtying operation of an edit session, and sent on the announce as base_mtime_ns. In words: this is the newest version of this file whose bytes I actually had when I started changing it.

A receiver compares that against what it holds. If its own copy is dirty and its version is above the incoming base, the sender never saw the receiver's version, so the two edits crossed. The newer wall-clock stamp takes the real name, and the other version is written beside it:

report.docx
report.conflict-20260826-113045-123456789.docx

Same folder, same extension, UTC stamp. It syncs like any other file, so both machines end up holding the same pair.

The full per-file ordering state is four int64 fields. There is no version vector, no operation log, and no join semilattice anywhere in it. At two peers the concurrency test is a single comparison, so none of the general-N machinery earns its cost.

The error in the base is one-sided by construction. A base can only understate the version its author held, never overstate it, and understating produces an extra conflict file rather than a lost one.

We checked the claim in four layers before trusting it

LayerWhat it reachesWhat it costs to run
The literature, and the one comparable systemWhat to adopt and what to avoid, before writing codeReading
Exhaustive model checkingEvery ordering of writes, deliveries and disconnects at small scopeMilliseconds, so it runs in every suite pass
Real mountsThe accept and reject rules in unit tests, then two peers with kernel mounts, then the same suites on macOS, Linux and Windows440 to 448 s for the full package on the Mac
The real networkBucharest to Timisoara over the production relay and the paid bridgeMinutes, and a machine in another city

The layers are ordered by what they can reach, and each one catches things the one below it cannot.

The model checker reached orderings no test can schedule

Two saves landing on the same nanosecond is not something a test can arrange on purpose. A checker walking every interleaving of a small abstract model gets there in milliseconds. The largest scope we explored came to 659 states.

Two results came out of it, and both were real behaviour in shipped code. Without a rank to settle an exact stamp tie, 7 of 15 terminal states diverged at one tie plus two writes. With the rank, none did. Without re-announcing conflict files on reconnect, 2 of 12 terminal states left a conflict file on one machine only after a single session drop. With it, none did.

The tie had been seen once before, in a CI run with coarse timestamps, and there was no way to reproduce it on demand. The model reproduces it every time in under a millisecond.

Each gap was a dropped number rather than a missing algorithm

Where it brokeWhat it didWhat changed
Both sides edit offline, then reconnect The older side's bytes were destroyed with no copy kept anywhere The reconnect announcement carries the stored base, so the older version survives as a conflict file on both machines
Two saves in the same nanosecond One code path had both sides refuse each other forever. Another swapped the contents The higher key fingerprint wins, computed at the handshake with swapped operands so exactly one side accepts. The loser is preserved
Creating the conflict file fails The code fell back to overwriting and left a log line behind Close local handles, retry the rename, fall back to a byte copy, and refuse the incoming change if every route fails
A delete races an edit, outside the one second buffer The delete won and the edit was gone Deletes carry a base too. An edit proven to have crossed the delete is preserved, and the announce recreates it on the deleting machine
A conflict file minted just before a disconnect It could exist on exactly one machine, forever Every connect re-announces the conflict files this machine created. The operation is idempotent, so a redelivery mints nothing
Renaming a folder Children stayed reachable under the old path on the peer forever. Two crossing folder moves produced two different nested tangles Both sides re-key the children. A crossing pair resolves to exactly one move by the same fingerprint rule: one side undoes its own move, the other skips the incoming one

In every one of those the machinery was already there. What was missing was the number: an announce built without the base field, a base zeroed by the disk restore path, a delete that carried none at all. That is the finding worth keeping from this round. The audit did not produce an argument about which algorithm to use. It produced six places where a value the algorithm depends on was absent.

The folder move broke exactly where the theory said it would

Najafzadeh, Shapiro and Eugster proved in 2018 that create, remove and edit run coordination-free while preserving the tree invariant, and that move is the sole exception: unsynchronised concurrent moves provably break it, producing cycles and disconnected components. Gap 6 above is that theorem arriving in our stack, on schedule.

Reading it first changed what we did about it. Kleppmann's highly-available move operation puts a total order on moves with undo and redo, proven in Isabelle, and it exhibits real move bugs in Google Drive and Dropbox. On a mounted filesystem the rollback is the problem, because an application can be holding a file while the rollback moves it. Maram skips a conflicting down-move by priority and never rolls back, which suits a live mount. That is the shape we took. At two peers the fingerprint rank picks the surviving move, and the loser undoes its own before applying the winner's.

elmerfs shows what the other road costs

The one published CRDT filesystem is elmerfs, from the HotStorage 2021 paper, archived read-only in August 2026. It turns every filesystem call into an AntidoteDB transaction and lets server-side CRDTs do the merging. Its structural work is the part worth learning from. Concurrent same-name creates coexist because the creator's view uid is part of the directory set element, so both survive on both replicas deterministically, and each application keeps finding its own file under the plain name. Remove-wins inode maps mean a delete never leaves a half-updated inode.

File content is where it stops. Each 8 MiB page is one last-writer-wins register with whole-page read-modify-write, so two concurrent writes anywhere inside the same page lose the entire page. The README says as much: concurrent update on file content is not well handled yet. The paper's evaluation section says the authors are in the process of performing experimental evaluation, and no benchmark was ever published.

That settled our content path by counterexample. A CRDT store at register granularity still loses data at the granularity applications care about, and it puts a database on the write path while doing it. Our bytes stay on plain files, and a true conflict keeps both versions.

The save path did not get slower

A conflict rule that taxes every save would not be worth having, so the branch was measured against a main worktree at the commit it forked from, on the same machine, back to back, twice. Each run is 100 saves of a 256 KiB file through the mount, written to a temp file and renamed over the original, which is what applications actually do.

PairTreeRename only, p50p95Full save, p50
1branch706 us917 us1.54 ms
1main800 us953 us1.73 ms
2branch1.24 ms1.64 ms2.71 ms
2main1.07 ms1.61 ms2.33 ms

In the first pair the branch is ahead on every column. In the second pair main is. The sign of the difference flips between pairs, and the spread between pairs is larger than the difference inside either one, so this measures ambient noise on a laptop and shows no regression. Quoting the first pair on its own would be a speedup claim the second pair contradicts.

The file rename path gained zero system calls by construction: the one lstat it needs was already there for the announce. Directory renames pay a re-key walk across the children, which is the rare case and the one that was broken before this round. Cold random access is untouched: on a 64 MiB remote file with prefetch off, n=32, a 4K read is 94 us at p50 on the branch against 96 us on main.

The real link agreed with the model

Loopback has no network in it. Bucharest to Timisoara does, over the production relay and the paid bridge, with the connection taking 15.27 s to establish.

On the final build with all six fixes, 10 of 10 bidirectional steps passed: seeding in both directions, in-place edit ping-pong, an engineered concurrent clash, and a swap-save crossing where both applications write a temp file and rename at the same moment. Each step converged in 0.55 to 0.63 s. In both conflict cases the conflict file appeared on both peers 0.02 s after convergence. That is one run with 8 convergence measurements inside it, so treat the range as a shape and not as a distribution.

The rest of the gates: the full ./tests/ package green in 440 to 448 s on the Mac, the race lane green on the four touched packages, and the fleet green on macOS, Timisoara Linux and Singapore Windows.

Two portability bugs surfaced along the way and both had the same shape. filepath.Dir returns backslashes on Windows while FUSE relative paths are slash-pure, so the crossing detection could never fire there. A security fuzz corpus from an earlier commit had the same problem with a unix-literal root, which failed every accepted seed. Treat filepath.* on FUSE relative paths as suspect in review.

The confidence is graded, and the grades are not the same

GradeWhat it coversWhere it stops
Proven by enumeration Convergence of the last-writer rule, the base rule preserving every overwritten version, both tie shapes diverging without a rank and converging with one, and the reconnect re-announce closing every disconnect ordering Exhaustive at small scope only: a few writes and one drop. This is not a proof for unbounded histories
Proven by test on real mounts All six behaviours, green on macOS, Linux and Windows, in suites that were red before the fixes The orderings a test can schedule. The nanosecond tie is not one of them
Measured on the real network 10 of 10 steps and both conflict cases end to end through a relay and a bridge One link, one pair of machines, one night, one run

The design stops at two computers, and it never merges

Questions we were asked

Two people, two applications, both saving the same file at the same moment. Is that a conflict?

Yes, exactly one, and it resolves the same way on both machines. Each application writes its own temp file, which involves no interaction at all, then each rename announces with its base. The announcements cross, the newer stamp takes the real name, and the losing save is proven concurrent by its base and preserved as name.conflict-TIMESTAMP.ext on both machines.

The crossing window is roughly one link round trip. A save that lands after the other side's save has already arrived is turn-taking: its base proves it saw that version, and no conflict file is made. That is why alternating saves and fast autosave bursts mint zero copies, which the turn-taking tests assert, and a true simultaneous double save mints exactly one.

How does it look to the person using it?

The file keeps its name and holds the newest save. One extra file appears next to it, identically on both machines, in the same folder with the same extension, so it opens in the same application with a double click and the stamp says when it happened.

That is the whole visible surface. The temp files an application writes during a save are gone again when the save finishes, so nothing else accumulates in the folder. Nobody loses a copy: the handling never deletes either version, and when the conflict file cannot be created the incoming change is refused and retried instead of overwriting. Cleaning up needs no tool. Open both, keep one, delete the other, and the deletion syncs like any other.

Two boundaries belong in the same answer. A genuine delete of a file nobody was editing removes it on both machines, because there is no recycle bin on the peer side. And the one narrow crossing shape above could in theory skip the copy, which we have never observed in measurement and which sits at the top of the follow-up list.

The one line version

We let the safe operations run free, we prove the unsafe ones with one number per change, and we answer them by keeping both versions on both machines. Nothing waits on a lock, and the failure mode is a visible extra file instead of invisible loss.

More on the project: keibidrop.com

Related: the write and release race that came before this, why a file version is not its bytes, and how we test a two-peer filesystem.