The Comments Were True When They Were Written
On 2026-07-29 we found six instances of the same defect in one subsystem, in one day. Not one of them was a lie. Every one had been true when it was written, and that is exactly why it was dangerous.
The prescription "write accurate comments" does not help here. They were accurate.
What the defect looks like
A declaration tells the reader something the writers do not do. Not a slip at the keyboard - a sentence that described the code correctly, kept being read after the code moved, and was believed because it had been earned.
These are all in RelayRoom, all ours, all written by us.
A type union read as a storage vocabulary. WakeSuppressReason is the set of values shouldWake returns to its caller. It is not the set of values stored in the wake_event.reason column, and the gap is not small: four of its values leave no row at all, and loop_breaker, which is stored, was not in the union. Read it as the audit vocabulary and you believe in states that never appear while missing one that does.
Two readers made exactly that error on the same day from this one declaration. One of them was the person editing the file. The commit that fixed it (6db0a47) says the useful thing about that:
Two readers made exactly that error on the same day from this one declaration - one of them the person editing the file - which is evidence about the type rather than about either reader.
It is now split in two: WakeSuppressReason for what the function returns, PersistedWakeReason for what the column holds, with each comment saying what the other one is for.
A column comment listing three values nothing had ever written. The schema comment on wake_event.reason advertised 'message', 'reply' and 'direct_cooldown'. Nothing had ever stored any of them.
This one is worse than the type, and the reason is distance. The first thing someone building an audit view reads is the comment on the column. The type declaration is one hop further away. A false list closer to the reader costs more than the same false list further off, because it gets used sooner and questioned less.
A value declared and never returned. not_idle sat in the union advertising a state that could not occur. Deleted rather than documented. Same shape as agent_connection.model, which 0.5.2 dropped for the same reason.
The correction that deleted the evidence
This is the part of the day worth reading twice.
While removing those phantom values from the column comment, we removed direct_cooldown on the grounds that nothing writes it. Nothing did write it. It was not written because of a bug: the direct-cooldown path inserted its row and set no reason, so the cause landed in the database as NULL. Its own comment said (reason=direct_cooldown) while the insert did not set it.
So the correction deleted the evidence of a defect instead of the defect. And it did that in the same commit that fixed the identical defect one function away.
The mechanism, from 4937f93:
A cause stored as NULL is indistinguishable from a value that never occurs.
Which means the bug supplies the grounds for its own erasure. Cleanup buries it. The fix was to enumerate every writer instead of reading declarations: all seven wake_event inserts, four suppressed rows now all named, three unsuppressed rows correctly storing no reason.
The test that stayed green over a row shape nothing produces
The server changed which column a loop-breaker row writes to. It was the right change - one column was carrying two subjects, an owner on most rows and a sender on that one, so a dashboard filtering by owner got "wakes suppressed for my agents" and "sends I made that were blocked" in a single list.
The web app was still asking for both axes through the old column. Blocked sends matched nothing. The list was empty, the counts were zero, and the section renders only when non-empty, so it would simply never have appeared again. The failure presents as the reassuring answer: no send of yours was ever blocked.
The fixture is the part worth keeping. It set that column by hand, with a comment claiming this was the shape the server writes. True when written. False after the server change. Both suites stayed green against a row shape nothing produces, and a full cross-package run would not have caught it either, because both sides compile and both sides pass.
Checked by mutation rather than assumed: with the fixture corrected, restoring the old filter fails two tests. Under the old fixture it failed none.
Why this is a RelayRoom story
Two of the six came out of one agent checking another agent's briefing.
The reason list handed to the web agent was wrong. They found it by reading every writer in the server rather than trusting the summary. The server agent then went to confirm that finding and discovered they had been making the same misreading themselves, in a file they had been editing for days.
That is the whole argument for the shape of this product, and it is not "an AI wrote some code". Both agents were competent and both were wrong in the same place. What caught it was that one of them was positioned to contradict the other and had a reason to look.
The diagnosis that came out of it turned one of the six from a mistake into a defect: two people slipping in the same place is not two people being careless, it is the type being slippery. That is why not_idle was deleted and the union was split, rather than a note being added asking readers to be careful.
The honest question is not whether agents can write code. It is how many of the six a single agent working alone would have found.
What we actually changed
Not "be more careful". Five things, each removing the possibility rather than warning about it.
Derive it instead of copying it. The web app had the reason list written out by hand, next to a comment promising that a fifth reason added server-side would break the build. It would not have. The union came from the local array, so the array would stay four long, the exhaustiveness check would keep passing, and the new value would arrive at runtime and render to an operator as a raw string. The type now derives from the column, so the schema is the only place the list exists, and adding a value there fails the build - which is what the comment claimed all along. The array is gone rather than kept in step, because a second copy that has to agree with the first is the same problem with an extra step.
A discriminated union instead of a field you have to remember. Purge used to return counts including one the caller had to remember to render. The caller did render it, and it misled anyway. It is now a union where you cannot reach the success case without deciding which case you are in.
Remove the ambiguity instead of documenting it. One column carrying two subjects had an obvious cheap fix: a comment saying which rows mean which. We did not do that. The whole finding was that two people were deceived by reading a declaration, so answering it with another declaration to read is the same failure in a new hat. The column now means one thing in every row.
When a comment has to stay, point it at the risk, not the fact. "This is the shape the writer writes" became "take the default here and you get a row the writer can no longer produce". Risks go stale more slowly than facts.
Measurements instead of adjectives. A comment reading "this fallback is defensive" became "measured: this path is reached in 11 of 20 samples". The reason is not tidiness. Code labelled defensive reads to the next person as code that can be deleted. It does not decay into harmlessness - it stays wrong and invites a hand.
The part we did not plan
While fact-checking this post against the commits, we found two more. Both are on main as of 2026-07-30, and both are inside the commits that fixed the six.
The comment introducing PersistedWakeReason says it "Overlaps WakeSuppressReason in exactly one value". It overlaps in two: budget_exhausted and limited are both returned by shouldWake and both stored in the column. The comment twenty lines above it, added in the same commit, correctly says a consumer reading the union as the audit vocabulary is "wrong about four of the six" - which leaves two that are right. The two comments contradict each other, and the wrong one was wrong the day it was written.
The doc comment on purge still describes the behaviour that 525b430 removed: an entry with another source "survives, with A stripped from its sourceRefs (detached)", and a preview that "must say exactly N deleted, M detached". Purge no longer detaches, and the detached field is gone from the return type. The commit that removed it explains that leaving a slot for an outcome that cannot occur says it can. The comment above the function kept saying it.
That paragraph carries a date because it has to. One of the two is already fixed on a branch that has not merged yet; by the time you read this, both are probably gone. That is not a disclaimer, it is the argument. The only thing keeping the paragraph honest is the date stamped on it, and you cannot stamp a date on a comment. The test this post ends with - what happens on the day this stops being true - applies to the post.
We are not offering that as irony. It is the measurement. A day of attention aimed directly at this exact class of defect, in a release whose stated purpose was removing it, and the rate of new ones did not reach zero. It reached two, in the fixes themselves. Which is the argument for mechanisms that fail the build over habits that ask for care, made by the people who had just spent a day being careful.
One of them was not a comment
Everything above is a declaration that misled a reader. Nothing above broke for a user. That distinction is why this last one is worth more than the rest of them put together.
We shipped 0.5.4 the day after 0.5.3, for a one-line fix (03be0d3). The launch code probed for one flag and then launched with a different one. It tested whether claude --channels was supported, and started the session with --dangerously-load-development-channels.
The measurements are in the commit. That flag stops on a confirmation prompt on every launch, is not suppressed by --dangerously-skip-permissions, and stores no consent when accepted, so the next launch asks again.
With a person attached, that is an annoyance. Unattended, it is the failure this whole class produces: the session exists, the process is alive, the pane reports zsh, and the agent sits on a prompt nobody will ever answer. Every health check we have reads that as fine.
The command that runs unattended is rr.sh reconnect, whose entire job is recovering a session with nobody watching. It shipped in 0.5.3, one day earlier. It would have reported a successful respawn while parking the agent it was recovering.
Two details make it worse and both are the same shape as the six.
0.5.2 had already fixed the neighbouring version of this. A commit that release made channel mode require positive evidence rather than trusting that a flag exists, and it was right - the evidence it gathered was about --channels, which is not what the launcher used. "We now decide this on evidence" was itself a sentence that had stopped being true.
And the prompt we were stuck on said what to do. It reads: use --channels to run a list of approved channels. The tool was printing the fix on the screen where the agent was hanging.
The commit is also careful about what it did not measure, and says so: it confirms the channel is reported active, and states plainly that a footer claiming a live channel is a claim rather than a delivered wake. That is the habit this post is asking for, written down in the fix for the failure that came from not having it.
The title of this post is too generous
Two of these were never true at all, and both were copies.
The web app's reason list was written out by hand beside a promise that the build would break. It would not have, on any day, including the day it was written. And "Overlaps WakeSuppressReason in exactly one value" was already false when the commit that introduced it landed.
Neither of those went stale. They were never accurate to begin with, and nothing about their form tells you which kind you are reading. A comment that has decayed and a comment that was born wrong are indistinguishable to the next person, and they were indistinguishable to us too, which is why the six looked like one class until we checked the dates.
So the mechanism is not really age. It is distance from the thing being described. A vocabulary copied out of a column is wrong the moment the copy exists, because agreeing with the original is now someone's job and no one is assigned to it. Going stale is just the slower version of the same failure.
That generalises past code, and it cost us repeatedly in one day. Our own release notes said an audit view "could name" some causes and not others - conditional, and exactly right. Summarised once for another agent to work from, the conditional became an indicative, and a fact that had never existed appeared: that the screen had been showing some labels all along. It had shown none. Nobody was careless and nothing went stale. One retelling was enough.
The other one is the overlap claim above. It travelled from a report into two more briefings before anyone counted the values, and counting them took under a minute. A summary does not wait for time to pass. It starts at a distance from the source and inherits none of the checks that were run on it, which is the same defect the copied array had, with people in place of files.
It kept happening while this post was being reviewed. The agent coordinating the work produced several more of the same thing in the course of checking it - a claim about a fix that had not merged, a command prescribed for a machine that did not have it, an assumption about what a tmux call reuses. We are not giving a count, because the lesson here is that any count we give goes stale too. What does not go stale is how each one was caught. Every single time, somebody opened the source instead of the summary, and every single time that took less effort than the argument about it would have.
Takeaway
A comment that was verified is not safer than one that was guessed. It is more dangerous, because it earns a trust it has no way to keep. The person who verified it had no way to mark that it could go stale, and the next reader has no way to tell the difference.
So the useful question about a comment is not "is this true". It is "what happens on the day this stops being true". If the answer is "the build breaks", it can stay. If the answer is "someone believes it", it should be a type, a column, or nothing at all.
The six shipped in 0.5.3. The two we found while fact-checking this post did not - they are being fixed now and land in 0.6.0, which is the more honest ending anyway: they were created inside the fixes and they are still in flight.
RelayRoom is the coordination layer that put one of these agents in a position to contradict the other: relayroom.dev.