← All posts
Post context-bridge · 6 min read

The same path is not the same directory

context-bridge keys a project by the directory you are standing in. That sounds like the boring part. It took sixty-eight lines, a native library, and a test that deliberately recycles an inode.

context-bridge keys a project by the directory you are standing in. That is the whole model: one directory, one set of linked agent sessions, one conversation history. It sounds like the boring part.

Getting it right took about sixty-eight lines, a native library, and a test that deliberately recycles an inode.

A path is a name, not an identity

The obvious key is the path, and it fails in every direction. The same directory answers to more than one path — symlinks, bind mounts, and on Windows a case alias and an 8.3 short name are all the same place. And the same path outlives the directory: delete ~/work/api, create ~/work/api again tomorrow, and every path-keyed record now points at something that shares nothing with what it described.

So the key has to come from the filesystem, not from the string you typed.

Device and inode are not enough either

The next obvious key is device plus inode, which is what most tools settle on. Inode numbers are reused. Delete a directory and create another, and the kernel is perfectly entitled to hand you the number it just freed.

The identity the bridge actually stores is a triple:

v2:<device>:<inode>:<birthtimeNs>

Creation time in nanoseconds is what separates the new directory from the ghost of the old one. The test that guards it does not simulate the collision — it arranges it, with a comment that gives the whole game away:

Recycle the original inode for the replacement; only birth time differs.

Two directories, same device, same inode, one truthful field between them.

Except when birth time is not there

birthtimeNs is 0 on plenty of real systems — including, awkwardly, modern Linux CI runners, which needed their own commit to make the absence explicit rather than surprising. So the triple is unavailable exactly where you would most like a guarantee.

The response is split by how long the caller intends to live, which I think is the right instinct.

One-shot commands

Check identity once Do the work A short window is an acceptable window

Long-lived readers

Refuse without a positive birth time Re-check before every single call "Project identity changed"

bridge watch and the MCP server both refuse to start at all if they cannot get a verifiable creation identity:

MCP requires a verifiable directory creation identity;
no project state was changed.

And having started, MCP re-runs the comparison before each tool call rather than trusting the one it did at boot. A server that has been up for six hours has had six hours for the ground to move.

What identifying a tmpfs directory actually costs

Rather than accept that, there is a second identity scheme for the case that provoked it: Linux tmpfs, where projects live in CI and containers and where birth time is most likely to be missing.

It is built from four things, and each one is there because the previous three were not enough:

fstatfs — confirm the magic number is really tmpfs the filesystem id — separate one tmpfs mount from another name_to_handle_at — an opaque handle carrying the inode generation /proc/sys/kernel/random/boot_id sha256 of all four → v3:linux-tmpfs:…

The boot id is the detail I would not have thought of. tmpfs contents do not survive a reboot, and mount ids get recycled, so without it two different boots could produce the same identity for two unrelated directories.

This is also where the new runtime dependency earns its place: fstatfs and name_to_handle_at are not in Node, so they are called through koffi. That is a story for its own post, but it is worth noting that the first thing the dependency buys is not speed or convenience — it is the ability to answer which directory is this on a filesystem that will not say.

And the scope is stated rather than assumed, in a comment sitting directly above the check:

Only tmpfs semantics have been reviewed: a fresh filesystem UUID and an opaque inode-generation handle. Do not generalize this to network stores.

Everything else — wrong platform, wrong architecture, big-endian, an unrecognised magic number, a handle the kernel will not produce — returns null and falls back to refusing. The clever path is narrow on purpose.

The lookup is itself a race

Reading identity means several syscalls, and a directory can be replaced between any two of them. So the sequence is deliberately paranoid:

stat            → device, inode, is it a directory
open            → O_RDONLY | O_DIRECTORY | O_NOFOLLOW
fstat           → same device and inode as before?     else: replaced before opening
…compute identity from the descriptor…
stat again      → device, inode and birth time all unchanged?
                                                        else: replaced during lookup

O_NOFOLLOW is doing real work there: without it, swapping the directory for a symlink mid-sequence is a supported way to get the bridge to identify somewhere else entirely. And the final stat exists because everything between the open and it took time.

If any of that fails, the function does not return a best guess. It throws BRIDGE_PROJECT_IDENTITY_UNAVAILABLE, and the message says exactly what was not done:

Directory identity could not be verified.
No existing project context was selected.

Processes have the same problem

Once you have looked at directory identity properly, you start seeing the shape elsewhere. A PID is a number the operating system reuses, and the bridge records PIDs — in lock owner stamps, in interrupted-operation recovery records. A stale record naming PID 4812 and a live unrelated process that is now PID 4812 look identical.

On Windows this got its own fix: the recovery path takes a creation token from GetProcessTimes alongside the PID, opening the process with PROCESS_QUERY_LIMITED_INFORMATION purely to ask when it started. A PID plus a start time is a process. A PID on its own is a guess with a reuse window.

Same lesson, different kernel object: a number that gets recycled is not an identity.

What it bought

The visible payoff is subtraction. Project identity used to be resolved partly through Git — shelling out to find a repository root and a stable id — which quietly meant Git had to be installed for ordinary use, and meant a subprocess on a hot path.

Now identity lookups use the registry and the filesystem, and never launch Git. The Git annotation is still captured, but only once at registration, and the docs say plainly what it therefore is not:

the identity annotation is not a live Git-config mirror.

An ordinary project no longer needs Git at all. Worktree operations still do, because those genuinely are Git.

The part that generalises

Identity felt like a lookup and turned out to be a claim with an expiry.

Every layer of this ends in the same place: check it, then check it again, because the thing you checked is allowed to change and nothing will tell you when it does. The directory can be replaced between two syscalls. The project can be swapped while an MCP server is mid-session. The process behind a PID can exit and be replaced by an unrelated one wearing the same number.

None of that produces an error on its own. It produces a correct-looking answer about the wrong thing — which is the failure mode this whole release kept walking into, in about thirty different disguises.