Skip to content

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
mainfrom
claude/session-mail-followups
Open

fix(coord): two silent-failure defects in the session-mail installer and broadcast path, with regressions#239
wshallwshall wants to merge 9 commits into
mainfrom
claude/session-mail-followups

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

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.

  1. THE INSTALLER FILTER SELECTED NOTHING AND SAID SO WITH EXIT 0. pwsh -File hands every
    argument over as a STRING, so -Only PreToolUse,UserPromptSubmit arrives as ONE element
    and 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.

  2. 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 all invoked it with
    2>$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 NO
receipt 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.ps1 is on origin/main and ABSENT from the primary
checkout'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.

…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
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant