The write-time retirement signal, behind a channel that was measured rather than assumed - #743
Conversation
CLOUD-1131 A governed shell path's refusal is tree-scoped and `slow`-profiled, so an agent learns at `verify` that none of the work can land — and the commit hook skips it too
Why
What is not designed is when it speaks. Measured 2026-08-28, an agent that opens
No The cost is the whole session, not the one call. A refusal that arrives after the work is the shape that produces the wrong conclusion rather than the right one: the reader has a finished edit and a gate saying no, so the cheapest reading is "the gate is wrong and needs changing" rather than "this should have been a retirement". That reading was taken twice in one planning session before it was corrected. The surface already carries what a write-time signal needsNo new capability is required.
What #735 measured, and why this row reopened
**First, a live defect, now fixed. ** **Second, the precondition — answered NO. "The write-time signal the row asked for is NOT shipped, and that is its own finding rather than an omission: Claude Code declares no advisory channel at That is exactly the disposition §2 demanded — the row said "if it cannot surface, that is this row's finding and the mechanism changes rather than the row quietly shipping." It did not quietly ship. The row is now about finding a reader, not about writing the predicate. Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Deliberately not in scope, named so it is not absorbed. Whether Found by pressure-testing a dispatch plan's preconditions: asking not just what the gate decides but when it says so. CLOUD-1141 The protected-path gate enumerates shell write verbs, so any interpreter writes a protected file unrefused — `python3 -c` edited `batten.toml` repeatedly in one session
Why
Measured 2026-08-29 over the shipped binary against this repository's committed config, one protected path (
This is not hypothetical and was not found by reading. It was found because an agent used it by accident: while building CLOUD-1131 this session edited Why the deny half looks healthyNothing is misconfigured.
The obvious fix is the wrong oneAdding Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Found while building CLOUD-1131, by noticing that four of this session's own Generated by Claude Code CLOUD-1133 The protected-path gate compares a host's ABSOLUTE `file_path` against repo-relative globs, so every Write tool call walks straight past it
Why
Claude Code sends Measured 2026-08-29, in this repository, against the committed config. Two runs of the shipped binary, same tool, same target, one difference:
And end to end rather than only through a hand-built payload: a Every Found while building CLOUD-1131, whose predicate is over the same field: a Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Found by probing a mediated write while building CLOUD-1131, rather than by reading the gate. |
|
Warning Review limit reachedNext included review available in 53 minutes. View limit detailsLimit details: You’ve used the included review currently available. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (12)
Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/settings/billing. Comment |
2386021 to
b706d53
Compare
…han assumed `AdvisoryReach.delivered_on` omitted `PreToolUse` for its whole life, and `an_advisory_is_silent_on_a_surface_that_would_not_deliver_it` pinned the omission with a doc comment asserting that this event's only model-facing channel is exit 2 — so an advisory there could only be discarded or become a deny. That was never probed, and it is false. MEASURED 2026-08-29 as a discriminating pair over one command, one word of the list apart. `jq --version` trips `pinned-toolchain-preset`, a live `severity = "warn"` mediated row that demotes rather than denies. With `PreToolUse` in `delivered_on` the agent received `PreToolUse:Bash hook additional context: pinned-program-via-the-pin: V-PIN-BYPASSED … jq`; with it absent, nothing. The call was ALLOWED both times and the exit code never moved, which is the half that matters: the advisory arrives as `additionalContext` rather than by becoming the verdict the old comment feared. The only thing that had ever suppressed it was `encode_advice` consulting this list before building a wire shape. THE DISCIPLINE WAS READ THE WRONG WAY ROUND. Leaving an unprobed surface out is the right default for DELIVERY — it costs silence rather than a notice that vanishes — but it is not evidence about the host, and both the comment and the test had hardened into claiming it was. `ADVISORY_GAPS` says so in terms for `PostToolUse` and `UserPromptSubmit`, which stay out because nothing has probed them; the row now also records that a surface LEAVING that table is what closing a gap looks like. The test keeps its shape and moves its example to `PostToolUse` — documented, genuinely unprobed — so it still pins that the channel is asked about the EVENT and never about the host. A second case asserts the pre-tool advisory carries `additionalContext` and no `permissionDecision` field, so an advisory that arrived by becoming a verdict fails. CONSEQUENCE, AND IT IS WIDER THAN THE POLICY SIGNAL. `emit_advisory` writes to stdout wherever the channel is reachable and falls back to the operator's stream only where it is not, so opening this event moves EVERY advisory at it into the model's context — handler-contract diagnostics included. Read as correct rather than as a regression: the same diagnostic at `PostToolBatch` already reached the model, so this removes an asymmetry that was an artefact of reachability rather than a routing rule anyone designed. Recorded on CLOUD-1131 with the alternative (route by audience, not reachability) named as its own larger change. Four assertions across two door suites read one stream and now read both, since which pipe carries a report is a property of the event rather than of the report. Two of those repairs were load-bearing rather than mechanical: `allowed()` and a sibling assertion tested stdout for the bare token `"deny"`, which is satisfiable by a report that merely NAMES a refusal once stdout carries reports as well as the decision document — so each would have answered whether or not a document became a verdict, which is the entire question they exist to ask. Both narrowed to `"permissionDecision":"deny"`. `.claude/rules/toolchain.md`'s `contract-drift` bullet carried the same refuted reason and is corrected rather than quietly dropped; the clause's conclusion is unchanged, because a batch boundary is the right cadence for a once-per-change-set notice on its own merits. Refs: CLOUD-1131
`shell-retirement` admits one disposition for a governed shell gate — port and retire — and it is `scope = "tree"`, so its refusal first arrives at `mise run verify` with the work already finished. Measured twice in one planning session, that ordering produces the wrong conclusion rather than the right one: the reader has a finished edit and a gate saying no, so the cheapest reading is "the gate is wrong" instead of "this should have been a retirement". This changes nothing about what lands. `severity = "warn"`, so it demotes to advice and the tree gate keeps the verdict. What it buys is the ORDER. IT SHIPS BECAUSE THE CHANNEL WAS MEASURED FIRST. CLOUD-1131's §2 made a probed advisory channel the precondition, and the previous attempt asserted the answer instead of measuring it. `a9a10b1` measured it: a `warn` at `PreToolUse` reaches the agent as `additionalContext` with the call allowed. A module emitting into a channel nobody reads is the sensor-with-no-reader defect this row exists inside, which is why the probe came before this file. WARN RATHER THAN THE DENY THE ROW WAS REGROOMED TO RECOMMEND, for two independent reasons and the second is structural. A deny would have to be narrowed to a shape that is never part of a retirement — and the mediated surface cannot narrow that far, because `governed_at_head` reads `input.tree.lines` to look for a shebang or a `#MISE description=` line, and `input.tree.*` does not exist there. Asking for it anyway is the silent-dead-gate class: undefined reads as "does not hold", and a dead gate is byte-identical to a clean tree on the decision surface. So this module can only use the PATH-ONLY predicates, which are `governed_when_deleted`'s and are WIDER than the edit-time set. Over-approximating is sanctioned for advice and is a false positive in a deny gate — the class the last PR's review already caught once in `named_and_alive`. THE PREDICATE IS RESTATED AND THAT IS A DEFECT WITH A MECHANISM. §1 asks the two authorities never to disagree, and calling the owning module's own predicate does not compile: `data.batten.shell_retirement.under_mise_tasks(path)` is refused with `could not find function`. One bundle shares one engine, so a shared VALUE resolves across modules — a FUNCTION rule in another package does not, which narrows `policy.rs`'s "a helper defined in one module is callable from another" to data. The two can therefore drift invisibly, each still passing its own suite. The agreement gate over one corpus is owed in the compiled-binary tier, and both the module header and the `#MUTANT-EXEMPT` row name it rather than leaving it to review. The deletion a retirement performs cannot reach the predicate, structurally rather than by a heuristic worth trusting: a deletion arrives as a Bash `git rm`, carrying no `writes` key, so the first conjunct fails. `is_string` is load-bearing for the same surface reason — `writes` is `null` on every non-write call, and `startswith(null, _)` is an evaluation error rather than a false answer. The suite carries a compound deletion (`git rm … && git rm …`), which is what a retirement actually looks like since a program and its suite are two paths. `policy test` requires it of a mediated module (CLOUD-857); this module reads no command at all so it is immune by construction, and the case is worth its lines because "immune" is a claim about the current predicate rather than the next edit. The `#MUTANT-EXEMPT` follows every sibling module's shape and its reason is this module's own subject: `mutant` resolves a gate's suite as `tests/$gate.bats`, and `shell-retirement` refuses adding one — so there is no named case a mutation could turn red, and the compiled-binary tier is the coverage. Refs: CLOUD-1131
The module's own `test_` rules are the load-time tier and pin the PREDICATE. They cannot pin that the ENGINE builds the input it reads: a fabricated envelope is exactly the shape the engine may be unable to produce, so a suite made only of them passes over a key nothing fills. Both live instances of that class in this repository were found by adding this tier rather than by reading. It matters more than usual here, because `input.call.writes` changed meaning under this row's feet. CLOUD-1133 found it carried the host's `file_path` verbatim and Claude Code sends that ABSOLUTE, so every repo-relative comparison silently missed — a `with input as` case written against the fixed shape would have passed against the broken engine. `the_absolute_spelling_the_host_sends` asserts the spelling the host actually sends, because this module is a consumer of that fix and would fail silently: no advisory looks exactly like a clean path. SHOWN ABLE TO FAIL, MEASURED RATHER THAN CLAIMED (CLOUD-418). With the row unregistered, all three signalling cases go red — `a_write_to_a_governed_shell_ path_signals_without_refusing`, `a_write_to_a_bats_suite_signals` and `the_absolute_spelling_the_host_sends_signals_too` — and green with it restored. The deletion cases are asserted over the REAL shape, which is the row's own acceptance: a retirement deletes the path as a Bash `git rm`, so the case drives that rather than a fabricated `Write` event, which would prove nothing about the shape that occurs. Both the bare and the compound form, since a program and its suite are two paths and the real deletion is one list. The exit code is asserted beside the signal, because a `warn` that moved the status would be the deny this row refuses — and a deny at write time refuses the one disposition `shell-retirement` admits. Both streams are read, because which one carries the advisory is a property of the EVENT rather than of the message: `emit_advisory` uses stdout wherever the channel is reachable and the operator's stream only as the fallback. A case reading one stream would pass against a build that silently stopped delivering. THE DRIFT GATE is the mechanism `de32e99`'s header promised. The advisory restates `shell-retirement`'s path predicate because calling it does not compile, and restatement without a gate is how two authorities drift while both keep passing their own suites. This reads the five clauses out of BOTH modules' source and requires each to carry all of them — deliberately not restating them a third time in the assertion, which would make the gate part of the drift it exists to catch. Refs: CLOUD-1131
`protected` crossed with `[[verb]]` is the gate `memory-guard` retired into
(CLOUD-442), and it decides by NAMING the program. The verb table enumerates
mutations, so a program it does not name wrote the same bytes to the same path
unrefused. Measured over the shipped binary, one protected path, five spellings:
`echo x >>`, `sed -i` and `tee` denied; `python3 write.py batten.toml`,
`perl -pi -e … batten.toml` and `ruby -e x .serena/memories/core.md` ALLOWED.
An allowlist-by-omission whose omissions are holes — and it was found by accident
rather than by reading, when four of one session's own `batten.toml` edits went
through a gate that had just correctly refused an `Edit` to a policy module.
THE ENUMERATION IS INVERTED, NOT EXTENDED. Adding the measured interpreters to
`[[verb]]` closes two instances, leaves the shape, and makes the table imply a
completeness it does not have — the row rules it out in as many words. Instead
`protected_readers` names programs that only read their operands, and an operand
that is a protected path refuses unless the program is KNOWN: present in
`[[verb]]` at all, or declared a reader. Forgetting a reader is a false refusal
somebody fixes in a minute; forgetting a writer is no longer a silent hole.
A PROGRAM IN `[[verb]]` IS ALREADY KNOWN, which is what keeps this survivable.
That table encodes the program's argv grammar, so a non-matching invocation is a
considered allow rather than an absence — `git add batten.toml` stays allowed
because git's mutating rows did not match. A clause keyed on "did any row match"
instead of "is this program known" would refuse every commit in the repository.
THE READER SET IS COMMITTED-AUTHORITY ONLY, and that is the mirror of every other
path set here. The layered three take a local contribution because contributing
can only NARROW them. A reader is an ALLOW, so a local file adding one would
widen what the gate lets through — a weakening dressed as an addition, which is
`verdict`'s reason and now this key's. `trust` carries it as `ProtectedReaderAdded`,
the ADDED direction like `WaiverAdded`: a name the base does not carry is a
program that used to be refused and now is not. Getting that backwards would have
let the config-trust diff wave through the exact edit that reopens this hole.
A READER WEAKENS BY JOINING AN EXISTING SET, NOT BY THE SET ARRIVING, and that
clause was written only after this branch's own gate refused it. `config-lint`
reported 24 weakenings — one per seeded reader — and demanded a groomed
`Weakens:` clause for each. Every one was false: relative to a base with no
`protected_readers`, allow-by-default made EVERY program an implicit reader, so
declaring 24 and refusing the rest is a large tightening that an entry-by-entry
diff reads backwards. Declaring them would have been worse than wrong, since two
dozen rubber-stamped trailers teach a reader that the token means nothing. The
residue is named in the code: emptying the set and re-seeding it wider across two
commits goes unreported, because the second commit's base is empty, and closing
that wants "did the base DECLARE this key" rather than "is it empty".
TWO SHAPES STAY OPEN AND ARE ASSERTED AS OPEN. `python3 -c "open('p','w')"` puts
the path inside one quoted word, and `python3 - <<'PY'` puts it in a heredoc body
that `hook::segments` drops by design (CLOUD-723). The wider scan that catches the
first — every word, split on punctuation a path cannot contain — was tried and
REVERTED: it immediately refused a `for` loop whose quoted body merely mentioned
`batten.toml`, and `echo "see batten.toml"` is the same shape. A guard that
refuses ordinary mentions is one people switch off within a day, which is how
this class of guard dies. Argv cannot tell a path being written inside an
interpreter's program text from one being talked about, so the operand boundary
is where a non-hostile predicate stops. `mediated_verbs.rs` pins the residue as
ALLOWED rather than omitting it, because a suite that looks complete over a shape
the gate never sees is the defect CLOUD-418 names.
The unit case that asserted the old behaviour is reversed rather than deleted,
with its reasoning quoted in place. It said the conservative reading of an unknown
program "belongs to the consumer's config, not to a guess here" — right, and the
fix implements it. What was wrong is that "belongs to the config" was spelled as
ALLOW BY DEFAULT, so a config that never spoke got the permissive answer.
`WeakeningKind::ProtectedReaderAdded` is appended rather than placed beside
`ProtectedRemoved`, where it reads better and was first written: the enum carries
no `repr`, so inserting mid-list renumbers every later variant and `semver` refused
that break as gratuitous. It was, and the reason is recorded on the variant so the
next author does not tidy it back.
BREAKING CHANGE: `Config` and `Resolved` gain a `protected_readers` field, so a
consumer constructing either with a struct literal must add it. Neither type is
`#[non_exhaustive]`, so `semver` reports `constructible_struct_adds_field` and it
is right — this is declared rather than engineered around, because the two ways
out are worse. Marking those types `#[non_exhaustive]` is itself a break and a
larger design decision about the whole config surface; keeping the key out of
`Config` would mean a second authority on what is protected, which §1 forbids.
Below `0.1.0` release-plz bumps the patch either way, so what this footer buys is
an honest record rather than a version number.
Closes CLOUD-1141
Refs: CLOUD-1131
`Envelope::relativise_writes` strips the repository root off an absolute `file_path`, and `Path::to_str` renders what it strips with the PLATFORM separator. Every reader compares that value against a repo-relative glob — `protected` through `PathSet::contains`, a consumer module over `input.call.writes` — and those globs are written in git's spelling, which is `/` on every platform. So on Windows the normalised target was `.serena\memories\core.md`, which matches none of them. CLOUD-1133 added this normalisation to close a silent miss — an absolute `file_path` compared against a relative glob — and reintroduced the same silent miss one platform over: since that fix landed, the protected-path gate has not enforced on Windows at all, for tool-named writes and for this branch's new advisory alike. CAUGHT BY CI RATHER THAN BY READING, and only because a case asserts the spelling the host actually sends. `the_absolute_spelling_the_host_sends_signals_too` was green on the Linux job and red on the Windows one, which is exactly the asymmetry a `MAIN_SEPARATOR`-rendered path produces — a Linux-only suite would have gone on passing while Windows enforced nothing. `the_normalised_write_target_uses_forward_slashes_on_every_platform` asserts the property directly rather than leaving a second platform to discover it, and it is a DENY assertion on purpose: a separator that stops matching turns the gate off, and off is byte-identical to a clean tree on the decision surface. Refs: CLOUD-1141 Refs: CLOUD-1133
b706d53 to
8e4c4f1
Compare
|
❌ The last analysis has failed. |
|
/fast-forward |
Two changes, and the first is why the second exists.
The measurement that should have come first
CLOUD-1131 made a probed advisory channel its precondition. #735 reported it failed — and that report was wrong in a specific way: I read Batten's own capability table (
AdvisoryReach.delivered_onomitsPreToolUse, pinned by a test whose comment asserted the event's only model-facing channel is exit 2) and reported the table's position as a fact about Claude Code.ADVISORY_GAPSsays in terms that an unlisted surface is unprobed, not unsupported. I turned anUnknowninto aNo, and the row was regroomed around it — inverting its deliverable to "find a reader" and recommending aPreToolUsedeny.A discriminating pair over one command, one word of
delivered_onapart.jq --versiontripspinned-toolchain-preset, a liveseverity = "warn"mediated row:delivered_on"PreToolUse"jq --versionjq-1.7, allowedPreToolUse:Bash hook additional context: … V-PIN-BYPASSED … jqjq --versionjq-1.7, allowedAllowed both times, exit code unmoved: it arrives as
additionalContext, not by becoming a deny. The only thing suppressing it wasencode_adviceconsulting that list before building a wire shape.The pinned test keeps its shape and moves its unreachable example to
PostToolUse— documented, genuinely unprobed, named inADVISORY_GAPS. A new case asserts the pre-tool advisory carriesadditionalContextand nopermissionDecision.toolchain.md'scontract-driftbullet is corrected rather than quietly dropped: its conclusion stands on its own merits, its stated reason did not.The cost, stated:
emit_advisorywrites to stdout wherever the channel is reachable and falls back to the operator's stream only where it is not, so opening this event moves every advisory at it into the model's context, handler diagnostics included. Read as correct — the same diagnostic atPostToolBatchalready reached the model, so this removes an asymmetry that was an artefact of reachability. Four assertions across two door suites now read both streams; two of those repairs were load-bearing, sinceallowed()and a sibling tested stdout for the bare token"deny", which a report merely naming a refusal satisfies. Narrowed to"permissionDecision":"deny".The module CLOUD-1131 originally specified
severity = "warn", so the tree gate keeps the verdict and this only changes the ORDER the doctrine arrives in.Warn rather than the deny the regroom recommends, for two reasons and the second is structural:
governed_at_headreadsinput.tree.lines, which does not exist on the mediated surface, so the module can only use the path-only predicates — wider than the edit-time set. Over-approximating is sanctioned for advice and is a false positive in a deny gate.The predicate is restated and that is a defect with a mechanism. Calling the owning module's predicate does not compile — a function rule in another package is unreachable even though the bundle shares one engine, so
policy.rs's "a helper defined in one module is callable from another" holds for data only.the_two_authorities_agree_on_what_is_governedreads the five clauses out of both files and requires each to carry all of them.CLOUD-1141, fixed here rather than filed
Filing it named files this branch had open, and
filed-over-own-diffrefused that correctly. Cancelling the row did not clear the record — it is frozen at file time and reads no tracker — soR-FILE-IT-AFTER-LANDINGis unreachable once you have filed. Fixing it was the chosen route.[[verb]]enumerates mutations, so a program it does not name wrote a protected path unrefused. Measured, before → after:perl -pi -e s/a/b/ batten.tomlpython3 write.py batten.tomlruby -e x .serena/memories/core.mdtaplo lint batten.tomlgit add/git diff batten.tomlThe enumeration is inverted, not extended — the row rules out adding the interpreters in as many words. A new committed-authority
protected_readersnames programs that only read; an operand that is a protected path refuses unless the program is known (in[[verb]]at all, or a declared reader).gitstays allowed because the verb table encodes its argv grammar, so a non-matching invocation is a considered allow — a clause keyed on "did any row match" would refuse every commit here.trustcarries it asProtectedReaderAdded, the added direction likeWaiverAdded: a reader is an allow, so this set weakens by gaining an entry where every other path set weakens by losing one. Backwards, the config-trust diff would wave through the exact edit that reopens the hole.Two shapes stay open and are asserted as open.
python3 -c "open('p','w')"puts the path inside one quoted word;python3 - <<'PY'puts it in a heredoc body the segment projection drops by design (CLOUD-723) — the shape that found the defect. The wider word-fragment scan that catches the first was tried and reverted: it refused aforloop that merely mentioned the path, andecho "see batten.toml"is the same shape. A guard that refuses ordinary mentions is one people switch off within a day.mediated_verbs.rspins the residue as allowed rather than omitting it, because a suite that looks complete over a shape the gate never sees is the defect CLOUD-418 names.The unit case asserting the old behaviour is reversed rather than deleted, its reasoning quoted in place: "belongs to the consumer's config, not to a guess here" was right, and was spelled as allow-by-default, so a config that never spoke got the permissive answer.
Verification
mise run verifygreen, rebased on currentorigin/main, noHK_SKIP_STEPS. 2960 tests pass.git rm, bare and compound — not a fabricatedWrite.removing_a_protected_reader_is_not_a_weakeningis the load-bearing negative: a kind firing in both directions would report every tightening as a weakening.policy-test255 passed;mutant-census,regal lint policy/,schema-check,config-lintclean.A defect in CLOUD-1133's own fix
Envelope::relativise_writes— the normalisation #735 landed for CLOUD-1133 —renders the stripped path with the PLATFORM separator, so on Windows it produced
.serena\memories\core.md, which matches no repo-relative glob. The fix thatclosed a silent miss reintroduced the same silent miss one platform over: the
protected-path gate has not enforced on Windows since it shipped, for tool-named
writes as much as for this branch's advisory.
Caught by CI, not by reading:
the_absolute_spelling_the_host_sends_signals_toowas green on the Linux job and red on the Windows one, which is exactly the
asymmetry a
MAIN_SEPARATORpath produces. A Linux-only suite would have gone onpassing while Windows enforced nothing.
the_normalised_write_target_uses_forward_slashes_on_every_platformnow assertsit directly, as a DENY assertion, because a separator that stops matching turns
the gate off and off is byte-identical to a clean tree.
CLOUD-1133 is declined rather than closed: #735 already closed it, and this
repairs its fix rather than completing the row a second time.
Closes CLOUD-1141
Refs CLOUD-1131
Refs CLOUD-1133
DO-NOT-CLOSE CLOUD-1131
DO-NOT-CLOSE CLOUD-1133