Node has no exclusive rename
This package said zero dependencies for its whole life, and I meant it as a promise. In 0.13.0 the core declares one. What it bought: an exclusive rename, a kernel lock, and a filesystem identity Node cannot provide.
For its whole life this package said zero dependencies, and I meant it as a promise rather than a statistic. It is on the project page. It is in the README. In 0.13.0 it is no longer true: the core declares exactly one runtime dependency, koffi, which loads native functions from the C library.
I want to write down what that bought, because "we added a dependency" is the kind of thing that happens by drift, and this one did not.
The primitive that is missing
Start with the smallest possible operation. Publish a file at a path, and fail if something is already there.
Node cannot do it. fs.rename overwrites the destination silently and there is no flag to ask it not to. fs.link followed by fs.unlink is the traditional substitute, because link() does fail on an existing destination — but it is two syscalls, and a process that dies between them leaves the source still present under two names, which is its own commit in this release: publish exclusive files without a hardlink crash window.
For most programs an overwrite is a nuisance. Here the files being published are checkpoints and audit manifests — the evidence of what was delivered to which agent. Overwriting one does not corrupt a cache. It destroys the record of a handoff while reporting success.
Three kernels, one guarantee
The operation exists. It is just not in Node.
Windows MoveFileExW(from, to, MOVEFILE_WRITE_THROUGH)
no REPLACE_EXISTING, no COPY_ALLOWED, no delayed operation
macOS renamex_np(from, to, RENAME_EXCL)
Linux renameat2(AT_FDCWD, from, AT_FDCWD, to, RENAME_NOREPLACE)
Each is the same guarantee in local dialect, and each returns a proper EEXIST the bridge can act on rather than a silent success it cannot.
Then there is a fourth case, and it is my favourite thing in the file. Some older musl builds do not export a renameat2 wrapper even though the kernel underneath supports the call perfectly well. So when the symbol is missing, the code drops to the raw syscall with the architecture's UAPI number — and leaves a comment making sure nobody mistakes what just happened:
These numbers are Linux UAPI, not a weaker publication fallback.
That distinction is the whole discipline of this release in one sentence. A different route to the same guarantee is fine. A different guarantee wearing the same name is not.
If none of the routes is available, publication does not degrade. It fails with a message that says precisely what it declined to do:
Exclusive file publication failed on <platform>/<arch>.
No overwrite or copy fallback was attempted.
Ownership does not live in a PID file
The second thing the dependency buys is real locking, and the locking module opens with its own thesis as line one:
Kernel ownership lives on a stable file, never on the disposable PID marker.
PID files are the usual answer and they are wrong in a specific way this release had already learned about elsewhere: PIDs get recycled, so a stale lock naming process 4812 and a live unrelated process that is now 4812 are indistinguishable. That is the same recycled-number problem as inodes, one kernel object over.
A kernel lock has no such ambiguity — the operating system knows whether the holder is alive, because the holder is a file descriptor and the kernel drops it when the process dies.
What is built on top of it is where the care shows.
A timeout never evicts anyone. The message is unusually explicit for an error string, and it is explicit on purpose:
Lock wait timed out after 30000 ms. The owner was not evicted.
Wait for the other process to finish and retry;
stop old bridge processes before upgrading.
"The owner was not evicted" is there because the natural assumption on a timeout is that the tool broke the lock and continued. It did not. Timing out is the bridge giving up, not taking over.
Nested locks share one budget. Several operations take a lock while already holding another, and the naive implementation gives each its own timeout, so three nested waits become three times the wait you configured. Instead a single budget is charged by actual elapsed time and passed down, and repeated EINTR interruptions are charged to it too rather than restarting the clock.
The wait is a real sleep. Retrying uses Atomics.wait on a throwaway SharedArrayBuffer for up to 25ms at a time — which is how you sleep synchronously in Node without burning a core. It replaced a busy spin in the registry.
And it has to hold across languages, not just across processes: the Aider adapter drives a Python helper, so there is a test asserting that a lock held by the Python writer excludes Node's recovery path. A lock that only works between processes that happen to be Node is not a lock.
The third thing
Identity on Linux tmpfs, where directories have no birth time, needs fstatfs and name_to_handle_at. Neither is in Node either. That story belongs to the identity post, but it is the third item on the same bill.
Three purchases: an exclusive rename, a kernel lock, and a filesystem identity. None of them is a convenience. All three are the difference between a guarantee and a hope.
What happens when it is not there
A native dependency can fail to load — an unsupported platform, a stripped install, a libc that does not match. The handling of that case is the part I would point at if someone asked whether the dependency was taken seriously.
Reads never need it. The module is loaded lazily, with a comment stating the rule: inspection commands must not have to load native code. bridge status, bridge project inspect, storage plan keep working on a machine where the native backend is broken.
Writes refuse rather than fall back.
Native locking is unavailable on <platform>/<arch>; mutation refused.
… Read-only inspection remains available.
There is no PID-only degraded mode. That was available and was deliberately not built, because a degraded lock is not a slower lock, it is an absent one.
And doctor proves it rather than assuming it. Rather than reporting that the package is installed, doctor probes a real acquire-and-release in a bounded temporary subprocess, and fails if that does not work. Installation is not evidence that mutations work — which is the same argument bridge verify makes about routes, one release earlier.
The accounting
The same release that added a dependency to the core also took a much larger one out of it. MCP moved to a separate package, @serdardb/context-bridge-mcp, which owns the MCP SDK and its transitive tree. Installing the core no longer installs any of that; you opt in.
So the honest summary is not "we gave up on zero dependencies". It is that the count went from a number I was proud of to a number I can defend: one, in the core, for three things the runtime does not provide, with every read path still working without it and every write path refusing rather than pretending.
Zero was a better headline. One is a better guarantee.