An error is not an empty result
The 0.13.0 branch is 225 commits, 145 of them labelled fix. Read end to end they are mostly one bug wearing seven disguises: a read that failed and an answer that said nothing was there.
The 0.13.0 branch carries 225 commits. A hundred and forty-five of them are labelled fix, which is usually a sign that a release is mostly repair work. Reading them end to end, that turns out to be the wrong reading. The same sentence keeps appearing in different clothes:
refuse failed source reads instead of acknowledging empty context
distinguish unreadable transcripts from missing sessions
distinguish unreadable switch history from an empty history
disclose incomplete audit sources instead of reporting empty work
refuse unresolved checkpoint containment instead of assuming absence
report incomplete checkpoint cleanup without inflating removals
do not treat missing delivery evidence as consumption
Those are seven separate commits, in seven different parts of the system, written over three days. They are the same bug.
An error had been quietly answering a different question
The shape is familiar enough that most codebases contain it. A read fails. Something catches the failure, returns an empty array, and the caller carries on. The call site asked what is here, and got back the answer for nothing is here — when the honest answer was I could not look.
In most programs that is a bad day. In this one it is worse, because empty is a legitimate result. A bridge exists to carry what the other agent has not seen yet, and the normal state of a project is that there is nothing new. An empty delta is not an alarm; it is Tuesday.
So a transcript that could not be opened produced exactly the same value as a quiet afternoon — and the bridge did what it does with a quiet afternoon. It marked the work delivered and advanced the watermark past it.
What the caller asked
What had happened
Nothing crashed. No error reached a log. The next agent simply started a little emptier than it should have, and nobody could tell which of the two afternoons it had been.
Refuse, disclose, distinguish
The fixes divide almost cleanly into three verbs, and the choice between them is the interesting part — because "just throw" is not always right.
Refuse is for the cases where continuing produces a wrong state rather than a thin one. refuse failed source reads instead of acknowledging empty context is the canonical one: acknowledgement is a write, and a write made on unread evidence is a lie that persists. Same with refuse unsafe registry reads instead of resetting identity — a registry that cannot be read is not a registry that is empty, and treating it as empty would have handed the project a brand-new identity and orphaned everything attached to the old one.
Disclose is for reads that are still useful while incomplete. A search over local evidence that cannot open two of forty files should still return the thirty-eight — but it must say so, which is what disclose incomplete evidence searches in CLI and MCP does. The result is not wrong. The count would have been.
Distinguish is the one that changes an interface rather than a branch. distinguish unreadable transcripts from missing sessions does not add a refusal; it adds a state that had been collapsed into another. Before it, one value meant two things. After it, a caller can decide which of the two it can live with — and different callers genuinely do.
Collapsed
- could not read → empty
- no session → empty
- read changed underneath → empty
- nothing new → empty
Kept apart
- unreadable, with a coded cause
- absent
- changed during collection
- genuinely nothing new
The inverse also had to be fixed
refuse empty search filters instead of broadening scope is the same error running the other way, and it is the more dangerous direction. The whole bug is one truthiness check:
- if (wanted) {
- if (!isValidLaneName(wanted)) throw ...
+ if (wanted !== null) {
+ if (!isValidLaneName(wanted)) throw ...
bridge search migration --lane= passes an empty string. An empty string is falsy, so it skipped the validation and the filter, and the search quietly ran across every lane instead of the one that was asked for. It did not fail. It returned more than it should have, confidently, with no indication that a filter had been requested at all.
Absence of a filter is not permission to match everything, in the same way that absence of a result is not proof of absence. The test that now holds it asserts two things: a non-zero exit, and that nothing is printed — "refused filters must not emit evidence".
A cleanup command had the same problem from a third angle. report incomplete checkpoint cleanup without inflating removals — it had been reporting what it intended to delete rather than what it had deleted, so a partial failure looked like a successful prune. The number was not a lie about the world so much as a lie about the work.
Saying why, without saying too much
Deciding to carry the reason is easy. Carrying it is not.
A failure that starts at an open() has to arrive at a manifest, a warning and a CLI line without turning into a paste of somebody's filesystem. Two commits do that work: preserve checkpoint read failure causes without exposing details and disclose specific audit reader failure codes. The second one is worth reading closely, because it is full of decisions that only show up once you try:
- bridge-owned diagnostic codes are allowlisted, so a cause survives the trip while a raw system message does not;
- when a source was mutated mid-read and the transcript was oversized, the outer diagnostic wins, because the size explains the read and the mutation explains the file;
- repeated per-agent errors keep the coded failure rather than being flattened into a generic one;
- and cause traversal is bounded by hops, because an error chain is a linked list somebody else built and it is allowed to be circular.
That last detail is the one I like. Carrying causes upward is a small feature with a cycle-detection problem hiding in it.
It ends at identity
The strongest version of this is 68 lines in directory-identity.mjs, and it ends like this:
Directory identity could not be verified.
No existing project context was selected.
The bridge keys a project by its directory. If it cannot prove which directory it is standing in, the tempting move is to fall back — match on path, take the nearest registered project, start fresh. Every one of those is a guess that attaches a real conversation to the wrong work.
So it refuses. It does not select a project at all. A tool that cannot tell you where it is should not be writing anything.
Why this got urgent now
This class of bug has always been there. What changed is who reads the answer.
A person who sees "0 results" squints at it. They know the difference between a quiet day and a broken pipe, often just from the feel of the number, and they go and check. An agent does not squint. It takes the empty result as a fact about the world and acts on it — and because the bridge feeds agents, an empty delta becomes a confident agent working from a context that was never actually read.
That is the same failure I wrote about in a different context a few weeks ago: fresh is not the same as correct, and an observation that is individually valid can still describe a world that never existed. It was a comfortable argument to make about somebody else's data pipeline. It is a less comfortable one to find in your own codebase, thirty times, wearing seven different disguises.
The fix is not clever. It is only tedious: every place that turns a failure into a value, and there were more of them than I expected.