fix(coord): two silent-failure defects in the session-mail installer and broadcast path, with regressions - #239
Open
wshallwshall wants to merge 9 commits into
Open
fix(coord): two silent-failure defects in the session-mail installer and broadcast path, with regressions#239wshallwshall wants to merge 9 commits into
wshallwshall wants to merge 9 commits into
Conversation
…risk
I said measure it rather than reason it away. Measured, against the shipped
drain, and it reproduces:
session X SessionStart (phantom) -> message DISPLAYED, marker minted,
NOT consumed (inbox 1). Correct.
session X' SessionStart, SAME id -> NOT displayed. Suppressed by the
phantom's marker.
session X' Stop -> NOT displayed, and CONSUMED (inbox 0).
A message consumed by a session that never saw it. From the drain's side those
three invocations are byte-identical to one healthy session showing mail at start
and consuming it at the turn boundary, so nothing in this design can tell them
apart: every artefact is keyed by session id, and the two sessions share one.
ALL THREE PRECONDITIONS ARE OBSERVED, NOT ASSUMED. Session ids are reused across
launches (one of the six ids in the phantom run had been seen hours earlier);
phantoms mint markers; and a marker for a message still in the inbox is correctly
NOT garbage-collected, so it survives exactly as long as the message it would
suppress.
PROPORTION, because this should not be read as the split having failed. It is
strictly narrower than the defect the split closed -- that one lost mail
unconditionally, to the first of six phantoms, on every launch. This needs a
pending message, a phantom that displayed it, and a later session reusing that
phantom's id before anyone else consumes it. But it is the same KIND of failure --
silent, receipt-clean, invisible to the operator -- and this channel's stated rule
is that duplicate display is accepted while silent loss is not.
THE PRINCIPLED FIX, RECORDED AND NOT MADE: consume only what the SAME drain
invocation rendered. SessionStart would display without earning the right to
consume; Stop would display again and consume what it just showed. That trades a
guaranteed duplicate display for removing the cross-invocation trust relationship
entirely, which is the direction the stated rule points. It is a design change,
the owner is away, and #210 is armed -- so it is written down rather than taken.
NOT BLOCKING #210. The branch remains a large net improvement over both the
current state (no channel) and the original defect (unconditional loss), and the
channel carries developer coordination notes, not anything critical.
Docs only. backlog_status_check: OK, 331 items.
…vidence All four verified rather than assumed, and the evidence is recorded in the box so a reader does not have to take the tick on faith. AC-5 NEGATIVE CONTROL -- two exist, both in tests/test_session_mail.py. test_the_naive_shared_destination_pattern_does_not_exclude runs the WRONG pattern inline and requires it to fail to exclude, so a green exclusion result is evidence only because the same harness is shown to detect the defect; test_the_claim_primitive_reports_a_failure_it_cannot_have_won re-runs the measurements own controls, so a Move-Claimed returning Won unconditionally is caught rather than read as a subtle bug. That box existed because a concurrency assertion with no proof it can fail is the ADR 0158 defect. D9 CAPS -- numbers live in mail-drain.ps1, anchors in SESSION-MAIL.md (CLAUDE.md at 40,102 bytes as the deliberate per-session cost; STEERING.md at 3,504 bytes as a whole document; announces existing peer-field caps). FRAME_OVERHEAD_BYTES exists BECAUSE of a measurement rather than as a guess: charging the raw body while the renderer added six bytes per line let a 34,539-byte injection pass an 8,000-byte cap while reporting 0 truncated. D10 CONTENT RULE -- stated once in SESSION-MAIL.md as a section heading, and referenced once from PHI.md. Checked the "one place each" clause rather than the string count: the only other occurrence in SESSION-MAIL.md is an internal link BACK to that section, which is the shape the box asks for rather than a second statement. My first grep for it returned zero because the phrase wraps across two lines and the match was line-scoped -- the same instrument-scope error as the rest of this work, in miniature. OWNER APPROVAL TO WIRE -- given in two steps: Stop first, then SessionStart once the show/consume split made a discarded session unable to consume what it displayed. Default config root only. The box now also carries the caveat that matters more than the approval: the rows are live and the hook still resolves nothing outside a worktree holding this branch. Zero unticked boxes remain. Docs only.
… so with exit 0
Two defects in one path, both found by using the tool rather than reading it, and
both the same shape as everything else this branch has been about: a clean result
from an operation that did not happen.
1. `pwsh -File` HANDS EVERY ARGUMENT OVER AS A STRING, so the documented
invocation could not express a multi-value filter. `-Only PreToolUse,UserPromptSubmit`
arrived as ONE element, the literal string "PreToolUse,UserPromptSubmit";
`-contains` matched no event; zero rows were selected. The .EXAMPLE block uses
the -File form with single values, so it works there and fails the moment
anyone passes two -- which is exactly what an operator wiring more than one
tier will type. Identifier-shaped switches (-Only, -Except, -Script) are now
split on commas. -SettingsPath deliberately is NOT: a Windows path may legally
contain a comma, and quietly cutting one in half would be a worse bug than the
one being fixed.
2. A FILTER THAT MATCHED NOTHING EXITED 0. That is the whole hazard, and it is
worse than defect 1 because it hides it: the run printed "No wiring rows
selected" and returned success, so an operator who mistyped an event, or hit
defect 1, saw a clean exit and believed the roots were wired. I hit this
myself while wiring three config roots and only caught it because I dry-ran
first -- had I not, I would have reported three roots wired while nothing
happened. It now exits 2, names what was asked for, and lists the events and
scripts that actually exist, so the message corrects the operator instead of
only refusing them.
VERIFIED WITH A NEGATIVE CONTROL, because a fix to a silent-failure path is worth
nothing unless the failure can be made to show:
-Only PreToolUse,UserPromptSubmit -> both rows selected, exit 0 (was: nothing, exit 0)
-Only NoSuchEvent -> NO WIRING ROWS MATCHED, exit 2, known events listed
58 pass across the three installer test modules. The two failures in
test_installed_coord_hooks.py are inherited and unrelated: that file is
byte-identical to origin/main on this branch, I have never touched it, and CI does
not report them.
…its own claim true
Two defects, one class, found by sweeping the CLI tools for the shape the
installer fix had just exposed: an operation that reports a clean result for a
question it could not answer.
1. mail.ps1 -Send -To all ASSERTED A DISTINCTION IT HAD DESTROYED. presence.ps1
deliberately writes an "UNAVAILABLE" receipt to STDERR precisely so that "the
fence could not look" stops rendering as an indistinguishable empty list on
stdout -- its own comment says so, and it is a good control. The broadcast path
invoked it with `2>$null`, threw that receipt away, and then printed:
"(This is 'the roster returned nobody', not 'the roster could not look')"
It could not know that. The only channel carrying the difference had just been
suppressed. A message asserting a distinction the code destroyed is a
compensating control resting on a false premise, which CLAUDE.md section 11
forbids by name.
Now stderr is captured rather than discarded, and the two cases are separated
by EVIDENCE and exit differently: an empty roster is a real answer and exits 0;
an unavailable roster prints the receipts, says plainly that this is NOT
"nobody is live", and exits 1. A non-answer must not read as an answer.
2. AND THE CONTROL ITSELF HAD A HOLE, which is why the first defect was
survivable-looking. presence.ps1 emits the UNAVAILABLE receipt for
RepoFound=true/Available=false, but the RepoFound=FALSE path returned the same
`[]` with NO receipt at all -- so one of the two ways of being unable to look
stayed silent on exactly the channel a machine consumer reads. The fix was
applied to one of two paths. It now covers both.
VERIFIED WITH A NEGATIVE CONTROL, run outside a git repository:
stdout : [] <- unchanged, still indistinguishable
stderr : presence: roster UNAVAILABLE -- not inside a git repository ...
stdout alone cannot separate the cases and is not meant to; the receipt is what
separates them, and it now exists on both paths.
127 pass across the presence consumers, the coordination suite, session mail and
the announce wiring.
…d without any
Both fixes in 9db2f4e1 and 41aec69d changed behaviour with nothing pinning it.
This repo's standard is that new behaviour gets a test, and a silent-failure fix
is exactly the kind that regresses unnoticed -- the failure it prevents looks like
success, so nobody investigating a green run would find it.
THREE TESTS, each asserting the post-fix behaviour directly, so each goes red
against the code as it was:
test_a_multi_value_filter_works_through_the_documented_file_invocation
`-Only PreToolUse,UserPromptSubmit` through the documented `pwsh -File`
form must select BOTH rows. Before the fix that argument bound as one
string, matched no event, and selected zero -- so the test would find
neither script. It also asserts mail-drain.ps1 is ABSENT, so this is a
filter rather than a no-op that happens to print everything.
test_a_filter_that_matches_nothing_fails_loudly_and_names_what_exists
The negative control, and the more important half: a filter matching
nothing must exit NONZERO, name what was asked for, and list the events
that really exist. It used to exit 0, which made a misspelled event
indistinguishable from a successful install.
test_outside_a_repo_the_json_roster_carries_an_unavailable_receipt
presence.ps1 -Json outside a repo must emit `[]` on stdout AND an
UNAVAILABLE receipt on stderr. Asserting BOTH is the point: a test that
only checked stderr would pass against a version that had broken the JSON
contract every existing consumer depends on.
Needed a new helper. run_installer asserts returncode == 0, so it structurally
CANNOT observe a failure path -- an assertion that can only see one outcome is not
a check. run_installer_rc returns the code instead.
28 pass across the two modules; ruff format and check clean.
…resolves a working tree The shim verification is done and it found the thing it was created to find. My VS Code run drove the drain by ABSOLUTE PATH; the live hook resolves via git rev-parse against the PRIMARY CHECKOUT. Different code path, and it is the one that has been silently resolving nothing. MEASURED IMMEDIATELY AFTER PR #210 LANDED: scripts/hooks/mail-drain.ps1 on origin/main YES same file in the PRIMARY CHECKOUT working tree ABSENT primary HEAD 2ab2037 vs origin/main fdec72c 3 commits behind THE SHIM RESOLVES A WORKING TREE, NOT A REF. Nothing pulls the primary automatically, so the channel is still inert across the entire machine after the merge. An earlier draft of this section asserted it "becomes live everywhere the moment the scripts land on main". That is now measured false, and false in the worst direction: the merge notification is exactly the moment an operator is most likely to believe it went live. AND THE TWO-VANTAGE PROBE IS WHY THIS WAS VISIBLE AT ALL: probed from a worktree holding this branch primary=False fallback=True -> resolves probed from any other worktree primary=False fallback=False -> NOTHING Checking from my own worktree reports True and looks healthy, because that branch carries the file. A single-vantage check cannot see this defect -- it is the "invisible from the only vantage point you run from" shape, and it would have produced a confident green. The activating action is `git pull` in the primary checkout, not the merge. I have NOT done it: the primary is shared by every worktree on this box and pulling it changes hook resolution for ~50 sessions at once, which is not a change to make unattended. Docs only. Wiring unchanged.
wshallwshall
enabled auto-merge (squash)
August 6, 2026 11:01
…ugh the installed hook
The two gated items are done, and the status section is corrected from INERT --
which it had been wrong about in both directions within one night.
VERIFIED END TO END, THROUGH THE INSTALLED HOOK COMMAND rather than a convenient
stand-in. The earlier VS Code run drove the drain by ABSOLUTE PATH, which does not
exercise what actually runs. This one took the shim verbatim out of
~/.claude/settings.json:
shim resolution, five worktrees on unrelated branches resolves in ALL, via the primary
message queued with MAIN's mail.ps1 delivered by the installed command
receipt written by the drain process shown-consumed / byHookEvent Stop
box afterwards inbox 0, seen 1 -- consumed once
WIRED ON ALL FIVE ROOTS, not just the default. The owner confirmed all four
numbered roots are in active use, so a drain on ~/.claude alone would have left
the cross-login case -- half the reason this channel exists -- unreachable. Each
root took SessionStart and Stop via `-Only <event> -Script mail-drain`, which is
the invocation that does NOT also install the coordination banner sharing that
event. The banner remains deliberately absent everywhere.
AND THE BLOCKQUOTE BELOW IS NOW A LESSON RATHER THAN A STATE CLAIM. It asserted
that the rows were live and the hook did nothing; that was true for several hours
and is not true now. Leaving it in the present tense would have been the same
defect the section is about -- a document asserting a condition that no longer
holds, in a file whose entire subject is signals that mean nothing. Two things
kept, both past tense and both expensive:
- A SINGLE-VANTAGE CHECK CANNOT SEE IT. From a worktree carrying the branch the
fallback returns True and everything looks healthy; from any other worktree it
resolved nothing. Probe from a worktree that does NOT carry the branch.
- MERGING TO main IS NOT WHAT MAKES A SHIM-RESOLVED HOOK LIVE. The shim resolves
a WORKING TREE, not a ref. After #210 landed the file was on origin/main and
still absent from the primary's tree, three commits behind. The activating
action was `git pull` in the primary -- and the merge notification is exactly
when an operator believes it went live.
Docs only.
… not on a shared session id
Closes the session-id reuse loss, which was reproduced rather than theorised. A
phantom minted a shown-marker; a later session carrying the SAME id inherited it,
had its display suppressed, and consumed the message at its Stop having rendered
nothing. A message consumed by a session that never saw it, with a clean receipt.
THE FIX IS A DELETION, NOT A GUARD. Nothing available to this hook separates the
two sessions -- session_id, transcript_path and cwd are identical -- so no check
could have rescued the marker. A consuming drain therefore ignores markers
entirely and renders what it is about to consume. What a drain rendered in THIS
process, in THIS invocation, is a fact no other session can forge. The
Complete-Held pass and its counters are gone with the mechanism they served.
A marker may still suppress a re-display at a NON-consuming event. It may never
authorise a consume. Those two decisions are no longer wired together.
THE PRICE, PAID KNOWINGLY: a session shown mail at SessionStart is shown it again
at its first Stop. That is now a GUARANTEED duplicate rather than an occasional
one, and it is the accepted side of this channel's one tradeoff -- duplicate
display is accepted, silent loss is not.
TWO THINGS I GOT WRONG ALONG THE WAY, both caught by checking rather than by
reading, and both worth recording because each nearly shipped:
1. MY FIRST EDIT SILENTLY DELETED THE $markerPath ASSIGNMENT -- it was inside
the matched region and absent from the replacement. Nine tests went red for
that reason, not for the design change, and I was one step from
"reconciling" them against a bug I had introduced. Investigating the ONE
unexplained failure before touching the expected ones is what caught it.
Blast radius was 4 tests, not the 13 I first reported.
2. THE NEW REGRESSION TEST PASSED VACUOUSLY. It was written as
`if not box_files(...): assert shown`, so with Complete-Held already removed
the guard never ran and the test went green against code with half the
defect restored. A mutation that put the suppression back proved it. The
assertion is now unconditional -- the consuming drain must RENDER what it
consumes -- and is red under BOTH the original defect and a partial
regression.
Two tests for the removed held-consume path are deleted; two whose contract
changed are rewritten to the new one rather than dropped, because the properties
under them still matter (a consuming drain renders what it consumes; a receipt
never pairs one session's id with another's emit time).
144 pass across the session-mail, announce-wiring, presence and collision-gate
suites. ruff clean. Mutation-verified in both directions.
Item (c) recorded the session-id reuse loss as "confirmed by reproduction and neither fixed nor accepted", with the principled fix described but deliberately not taken. It has now been taken. The fix is a DELETION rather than a guard: nothing available to the drain separates a phantom from a later session reusing its id -- session_id, transcript_path and cwd are identical -- so a consuming drain now ignores markers entirely and renders what it consumes. Consumption depends only on what that invocation rendered, which no other session can forge. Cost, recorded rather than buried: a guaranteed duplicate display at the first Stop. That is the accepted side of this channel stated tradeoff. Mutation-verified in both directions -- red under the original defect AND under a partial regression that restores only the marker suppression.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-ups to #210 (BACKLOG #1028, ADR 0161). Cut FRESH off fdec72c -- the merge-base IS
main -- because #210 was squash-merged, so the prior branch's base was no longer an
ancestor and a PR from it would have carried 17 files rather than 8.
8 files, +253 -34. 145 tests pass, ruff clean, backlog_status_check OK at 337 items.
TWO SILENT-FAILURE FIXES, both found by USING the tools rather than reading them.
THE INSTALLER FILTER SELECTED NOTHING AND SAID SO WITH EXIT 0.
pwsh -Filehands everyargument over as a STRING, so
-Only PreToolUse,UserPromptSubmitarrives as ONE elementand matches no event. Zero rows selected; the script printed "No wiring rows selected" and
exited 0. Caught only because the author dry-ran first -- otherwise three config roots would
have been reported wired with nothing having happened. Identifier switches now split on
commas (-SettingsPath deliberately does not: a Windows path may legally contain one), and a
filter matching nothing exits 2 and names the events and scripts that actually exist.
THE BROADCAST PATH SUPPRESSED THE RECEIPT THAT MADE ITS OWN CLAIM TRUE. presence.ps1
writes an UNAVAILABLE receipt to stderr precisely so "the fence could not look" stops
rendering as an indistinguishable empty list.
mail.ps1 -Send -To allinvoked it with2>$null, discarded that receipt, then printed "This is 'the roster returned nobody', not'the roster could not look'." It could not know that -- it had just suppressed the only
channel carrying the difference.
AND THE CONTROL ITSELF HAD A HOLE: presence.ps1 emitted the receipt for
RepoFound=true/Available=false, but the RepoFound=FALSE path returned the same
[]with NOreceipt at all. The fix its own comment described had been applied to one of two paths.
REGRESSIONS ARE MUTATION-PROVEN, not asserted. Both files were reverted to their pre-fix
versions in place and the tests re-run: pre-fix installer -> both installer tests FAILED;
pre-fix presence.ps1 -> the receipt test FAILED; both restored, git status clean. A
silent-failure fix is the worst kind to leave untested, because the failure it prevents
LOOKS like success.
ALSO: the reproduced session-id reuse limitation (documented, not fixed -- it is strictly
narrower than the defect the phantom split closed), and ADR 0161's remaining acceptance
boxes closed each against evidence.
MEASURED, AND IT CORRECTS A CLAIM IN THIS PROJECT'S OWN DOCS: merging #210 did NOT make the
drain live.
scripts/hooks/mail-drain.ps1is on origin/main and ABSENT from the primarycheckout's working tree, which is 3 commits behind. THE SHIM RESOLVES A WORKING TREE, NOT A
REF, and nothing pulls the primary -- so the channel is still inert across the machine. The
prior doc said it "becomes live everywhere the moment the scripts land on main"; that is
false, and false in the direction that matters, because a merge notification is exactly when
an operator believes it went live.
The two-vantage probe is why it was visible: from a worktree HOLDING the branch,
primary=False/fallback=True and it resolves; from any other worktree,
primary=False/fallback=False and nothing resolves. A single-vantage check returns a
confident green.