Skip to content

The write-time retirement signal, behind a channel that was measured rather than assumed - #743

Merged
wenzowski merged 5 commits into
mainfrom
claude/retirement-gate-integrity-3mfqc9
Aug 29, 2026
Merged

The write-time retirement signal, behind a channel that was measured rather than assumed#743
wenzowski merged 5 commits into
mainfrom
claude/retirement-gate-integrity-3mfqc9

Conversation

@wenzowski

@wenzowski wenzowski commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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_on omits PreToolUse, 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_GAPS says in terms that an unlisted surface is unprobed, not unsupported. I turned an Unknown into a No, and the row was regroomed around it — inverting its deliverable to "find a reader" and recommending a PreToolUse deny.

A discriminating pair over one command, one word of delivered_on apart. jq --version trips pinned-toolchain-preset, a live severity = "warn" mediated row:

delivered_on call result reached the agent?
with "PreToolUse" jq --version jq-1.7, allowed yesPreToolUse:Bash hook additional context: … V-PIN-BYPASSED … jq
without jq --version jq-1.7, allowed no

Allowed both times, exit code unmoved: it arrives as additionalContext, not by becoming a deny. The only thing suppressing it was encode_advice consulting that list before building a wire shape.

The pinned test keeps its shape and moves its unreachable example to PostToolUse — documented, genuinely unprobed, named in ADVISORY_GAPS. A new case asserts the pre-tool advisory carries additionalContext and no permissionDecision. toolchain.md's contract-drift bullet is corrected rather than quietly dropped: its conclusion stands on its own merits, its stated reason did not.

The cost, stated: 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 diagnostics included. Read as correct — the same diagnostic at PostToolBatch already 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, since allowed() 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_head reads input.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_governed reads 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-diff refused that correctly. Cancelling the row did not clear the record — it is frozen at file time and reads no tracker — so R-FILE-IT-AFTER-LANDING is 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:

command before after
perl -pi -e s/a/b/ batten.toml allowed deny
python3 write.py batten.toml allowed deny
ruby -e x .serena/memories/core.md allowed deny
taplo lint batten.toml allowed allowed
git add / git diff batten.toml allowed allowed

The enumeration is inverted, not extended — the row rules out adding the interpreters in as many words. A new committed-authority protected_readers names 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). git stays 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.

trust carries it as ProtectedReaderAdded, the added direction like WaiverAdded: 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 a for loop that merely mentioned the path, and echo "see batten.toml" is the same shape. A guard that refuses ordinary mentions is one people switch off within a day. 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 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 verify green, rebased on current origin/main, no HK_SKIP_STEPS. 2960 tests pass.

  • Shown able to fail, measured (CLOUD-418): with the advisory row unregistered, all three signalling cases go red and green with it restored.
  • Deletion asserted over the real shape — a Bash git rm, bare and compound — not a fabricated Write.
  • The absolute spelling the host sends is asserted, since this module consumes CLOUD-1133's normalisation and would fail silently.
  • removing_a_protected_reader_is_not_a_weakening is the load-bearing negative: a kind firing in both directions would report every tightening as a weakening.
  • policy-test 255 passed; mutant-census, regal lint policy/, schema-check, config-lint clean.

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 that
closed 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_too
was green on the Linux job and red on the Windows one, which is exactly the
asymmetry a MAIN_SEPARATOR 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 now asserts
it 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

@linear-code

linear-code Bot commented Aug 29, 2026

Copy link
Copy Markdown
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

Regroomed 2026-08-29 against origin/main 2f1be52. This row went Todo → In Progress → In Review → Done → Todo again: PR #735 landed a real defect fix under it (5f9f430) but did NOT ship the write-time signal, and reopened the row rather than letting the close stand. §2's precondition is now ANSWERED, and the answer is no — see "What #735 measured" below. Every line number in the original body was read at 331ec5f, 64 commits back, and has been corrected.

Why

shell-retirement admits exactly one disposition for a governed shell gate: port and retire. V-SHELL-RULE-EDITED declares one route, R-PORT-AND-RETIRE, with no override and no bypass_env (batten.toml:4075-4090). That is the mechanism working as designed.

What is not designed is when it speaks. Measured 2026-08-28, an agent that opens mise-tasks/ready-lint.sh and edits it is told nothing at the edit, nothing at the next edit, and nothing at git commit. The first signal arrives at mise run verify, after the work is done.

stage does it fire? why
Write / Edit tool call no shell-retirement is scope = "tree" (batten.toml:3064-3081); Surface::Hook never evaluates a tree module
git commit no its hk step is profiles = List("slow") (hk.pkl:943-946) and pre-commit runs --profile '!slow' (.claude/hooks/git-hook.sh:77 — the tracked body .git/hooks/pre-commit symlinks to)
mise run verify yes reaches batten check through hooksci (mise.toml:1397-1399, :1428-1430)

No mediated_call rule fires on a write to those paths — every one selects a Bash command line or a named MCP tool. protected is three globs (batten.toml:87-95) and reaches neither mise-tasks/** nor tests/**.

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 needs

No new capability is required. input.call.operation and input.call.writes are projected for every Write, Edit, MultiEdit and NotebookEdit:

crates/batten/src/hook.rs:5500,5502   "operation" and "writes" enter the policy input
crates/batten/src/hook.rs:1966,2018   Envelope::operation, Envelope::writes
crates/batten/src/hook.rs:242-254     Harness::write_tools
crates/batten/src/hook.rs:2497-2506   the adapter derives `writes` from `write_tools`

.claude/rules/policy-modules.md documents both keys on the mediated-call surface. The protected gate already denies a Write at PreToolUse by reading envelope.writes, so the shape is proven — it is the selector that is missing, not the mechanism.

input.call.writes **CHANGED MEANING under this row's feet, and a module written against the old shape is wrong. **CLOUD-1133 (a307094, closed) found Envelope::writes carried the host's file_path VERBATIM, and Claude Code sends it absolute — so every repo-relative glob comparison silently missed. Measured over the shipped binary: the relative spelling of a protected target was refused, the absolute one was allowed with no output, and a live agent Write to .serena/memories/ created the file. It is now normalised once at the boundary — Envelope::relativise_writes (hook.rs:2083-2091), called from lib.rs:3679. So input.call.writes is repo-relative by the time any Rego module sees it; a path outside the root, and an already-relative path, are left verbatim.

What #735 measured, and why this row reopened

5f9f430 set out to answer §2's precondition and found the answer one layer lower and worse than the row expected.

**First, a live defect, now fixed. **policy_rules turned every module violation into a Decision::Deny and never asked the enabling row's severity — the bundle carried none, and blocks, the predicate every typed rule kind consults, was never asked on this path. So severity = "warn" on a scope = "mediated_call" policy row denied exactly as deny did, silently. pinned-toolchain-preset was live in that state: declared warn, with a comment explaining that its first landing must not refuse, and refusing. The bundle now carries its row's severity and its predicate_severity table, resolved exactly as Rule::severity_for resolves it. A non-blocking violation is demoted rather than discardedpolicy_advice renders the same refusal as text and the hook puts it on the advisory channel.

**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 PreToolUse, so a warn there reaches the operator's stderr and never the agent. A registered gate with no reader is the defect that row exists inside."

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.

  • **Source of truth (§1). **batten.toml's rule table plus one new policy/*.rego module. The tree-scoped shell-retirement keeps the verdict and is untouched: this adds a signal, never a second authority, and the two must not be able to disagree about what is governed — the module reads the same globs rather than restating them as a literal.
  • Computable predicate (§2). A mediated call whose input.call.operation == "write" and whose input.call.writes matches a governed path — mise-tasks/*.sh or tests/**/*.bats — produces an advisory naming shell-retirement, the two landable shapes, and R-PORT-AND-RETIRE. Decidable from the envelope alone: no file is opened, nothing is spawned, and the 100 ms mediated budget is untouched.
  • warn, never deny, and that is the design rather than caution (§2). A deny would refuse the deletion every retirement depends on, and the one edit only_drops_a_retired_reference admits (shell-retirement.rego:185-220) — a sibling dropping a declaration row a retirement forced out. The tree gate decides; this only makes the decision arrive before the work instead of after it.
  • The precondition is ANSWERED, and it inverts the deliverable (§2). A warn at PreToolUse does not reach the agent: Claude Code declares no advisory channel at that event, so it lands on the operator's stderr. Measured by 5f9f430. So the predicate is no longer the work — a module emitting an advisory nothing reads is precisely the sensor-with-no-reader this row exists to refuse. What this row now owes is a channel that the agent observably reads, and the predicate lands only behind it. Three candidate readers, and the row picks one with evidence rather than by taste: (a) a PreToolUse deny narrowed to the one shape that is never part of a retirement — a Write/Edit to a governed path that is NOT also in the change's deletion set — since a deny is the one channel the host does surface; (b) the Stop advisory, which does surface, at the cost of arriving after the session's work rather than before the edit; (c) an upstream ask for a PreToolUse advisory channel, which is not in this repository's gift and therefore cannot be this row's acceptance.
  • (a) is the recommended shape and its risk is named (§2). A deny at write time must not refuse a retirement. A retirement DELETES the governed path, and a deletion is not a Write/Edit mediated call at all — it arrives as a Bash git rm or rm, which carries no writes key. So the discriminator is structural rather than a heuristic, and the vacuity case below is what proves it.
  • **Explicitly not the lever (§2). **protected. It is a {mutating verb} × {protected path} intersection with no per-verb exemption (batten.toml:2171-2174, :2191-2200), so adding mise-tasks/** to it would deny the deletions retirement requires. Reaching for it is the obvious move and it inverts the outcome.
  • **Effect (§3). **read. A mediated_call policy row is Authority::Supplied — structurally incapable of a side effect — and adds no verb and no effect-table row.
  • Generated artifacts (§4). A new [[rule]] row and its [[verdict]] token regenerate both committed schemas through mise run fix; schema-check and derived-check gate them byte-for-byte.
  • Output and exit contract (§5). Pointer-only: the path being written and the verdict token, never a byte of the prospective content the envelope carries. The advisory does not move the exit code — warn is 0 absent --fail-on-warning, and promoting it would be the deny this row refuses.
  • **Commit / bump (§6). **feat(policy)patch until 0.1.0, since below that release-plz bumps the patch whatever the type says. Not breaking for the consumer surface: no call that succeeds today starts failing, and no exit code or output shape moves.
  • Test obligation (§7). Over the compiled binary through batten hook, never with input as — a fabricated envelope proves the predicate and not that the engine builds it, which is .claude/rules/policy-modules.md's recorded class. Shown able to fail per CLOUD-418, three cases and the second is the discriminating one: a Write to mise-tasks/ready-lint.sh produces the advisory; a deletion of the same path does not, because a fixer that keys on the path alone passes the first case and breaks every retirement; a write to an ungoverned path is silent. Plus the vacuity case the surface makes easy to get wrong: an event carrying no writes key resolves to silence rather than to a match.
  • Blockers (§8). None. The blockedBy on CLOUD-1133 is REMOVED — it closed as a307094 and a resolved blocker left standing is graph-check's dangling-blocker class. relatedTo CLOUD-758 (which landed the write-shaped mediated surface this consumes), CLOUD-312 (the engine as the pre-tool entry point), CLOUD-924 (no rule kind keys on the tool a mediated call names), CLOUD-418.

Acceptance

  • The signal is OBSERVED reaching the agent — a transcript showing the agent received it, not a test showing it was emitted. This is the clause the first attempt failed, and it is now the first clause rather than the third.
  • An agent writing to a governed shell path is told at the write, naming the two shapes and R-PORT-AND-RETIRE.
  • A deletion of the same path is not impeded — asserted over the actual deletion shape (a Bash git rm), not over a fabricated Write event.
  • An event carrying no writes key resolves to silence rather than to a match.
  • A write to an ungoverned path is silent. Note the governed set is 279 paths, not the 132 shell programs: governed_at_head is a startswith("mise-tasks/") prefix test excluding only .py/.tsvno depth bound and no .sh requirement — plus a second arm is_bats(path) covering all 147 tests/*.bats. A module restating mise-tasks/*.sh as a literal would be narrower than the gate it advertises.
  • shell-retirement's verdict is unchanged: the signal adds nothing to and removes nothing from what lands.
  • severity = "warn" on a mediated row is honoured, which 5f9f430 fixed and crates/batten/tests/policy_severity.rs now holds — this row must not regress it.

Deliberately not in scope, named so it is not absorbed. Whether batten-check should leave the slow profile so pre-commit runs it. That is a different question with a different cost — the slow profile exists to keep the commit hook fast — and it gets its own row if anyone wants it. This row makes the signal earlier than the commit, which subsumes the question rather than answering it.

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

Reopened 2026-08-29 and being fixed on the CLOUD-1131 branch. Cancelled as a duplicate of CLOUD-442 when filed-over-own-diff refused it — correctly, since it names files that branch had open. The decision was then to FIX rather than defer, so the row is live again and its PR closes it. The measurements and the two residues are on CLOUD-442's thread as well.

What the fix is: the enumeration is INVERTED, not extended. [[verb]] keeps naming mutations; a new committed-authority protected_readers names programs that only read. An operand that is a protected path refuses unless the program is known — in [[verb]] at all, or declared a reader. Forgetting a reader is a visible false refusal; forgetting a writer is no longer a silent hole.

Two shapes stay open and are asserted as open, because a suite that looked complete over them would be the CLOUD-418 defect: python3 -c "open('p','w')" puts the path inside one quoted word, and python3 - <<'PY' puts it in a heredoc body the segment projection drops by design (CLOUD-723). The wider word-fragment scan that catches the first was tried and REVERTED — it refused a for loop that merely mentioned the path, and a guard that refuses ordinary mentions is one people switch off. Closing either needs the prospective content as a fact rather than a string to grep, which is its own row.

Why

protected crossed with the [[verb]] table is the gate that replaced memory-guard (CLOUD-442). The verb table is an ENUMERATION of shell write shapes — redirects, tee, mv/cp/rm, an in-place stream edit, a version-control move or remove — so it decides by naming the program. A program it does not name writes the same bytes to the same protected path and is allowed.

Measured 2026-08-29 over the shipped binary against this repository's committed config, one protected path (batten.toml), five spellings of "write to it":

command verdict
echo x >> batten.toml deny (exit 2)
sed -i s/a/b/ batten.toml deny (exit 2)
tee batten.toml deny (exit 2)
python3 -c "open('batten.toml','w')" allowed, exit 0
perl -pi -e s/a/b/ batten.toml allowed, exit 0

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 batten.toml four times with python3 - &lt;&lt;'PY' … PY heredocs and was never refused, in the same session where an Edit tool call to a policy module was correctly denied. The gate was silent for the file it exists to protect while loudly correct about a file one directory away.

Why the deny half looks healthy

Nothing is misconfigured. protected names the three globs it should, and the Edit/Write tool half works — CLOUD-1133 fixed the absolute-path miss that had made it silent, and crates/batten/tests/mediated_verbs.rs covers the corpus. The hole is in the COMMAND half's shape: an allowlist-by-omission, where the safe set is "every program nobody enumerated".

.serena/memories/** and .github/workflows/** are exposed identically. The memory tree is the sharper case, because memory-guard was retired INTO this gate on the claim that the verb table covered its last five write shapes — and it covers them only for the programs it names.

The obvious fix is the wrong one

Adding python, perl, ruby, node, awk and busybox to the table extends the enumeration by six and leaves it an enumeration. The next interpreter is unrefused, and the table now implies a completeness it does not have — worse than the current state, which at least fails visibly the moment somebody probes it.


Refinement — Ready

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • Source of truth (§1). batten.toml's [[verb]] table and the protected set stay the one authority on what is protected and what mutates. What changes is how a call is CLASSIFIED against them, in crates/batten/src/hook.rs, beside the existing segment reading. No second protected list.
  • Computable predicate (§2). A mediated command whose argv names a protected path in a position the boundary cannot prove is a read is refused, rather than allowed because its program is unlisted. The direction is the point: unknown must fail closed on a protected path, where today it fails open. Decidable from input.call.segments and programs, both already projected — no new fact class and no file is opened.
  • What must NOT change (§2). Reads stay allowed. cat, grep, sed -n 1p and every other read of a protected path are the ordinary case, and a gate that refused them would be switched off within a day — which is how this class of guard dies. The narrowing therefore has to distinguish "names the path" from "writes it" for programs whose argv grammar nobody has encoded, and the honest answer for a genuinely unknown program may be to refuse and offer the hatch rather than to guess.
  • Explicitly not the lever (§2). Extending the [[verb]] enumeration with the interpreters measured above. It closes these two instances and leaves the shape, which is what makes the current state a defect rather than a gap.
  • Effect (§3). read. Classification over an argv the engine already parses.
  • Output and exit contract (§5). Unchanged: the existing V-PROTECTED-MUTATION class, its redirect and the 0/1/2/3 table. Pointer-only — the path and the verb, never the content.
  • Commit / bump (§6). fix(hook)patch until 0.1.0. Behaviour-changing in the raise-only direction house style §8 permits: calls that pass today start being refused, and none that fail today start passing.
  • Test obligation (§7). Over the compiled binary through batten hook, shown able to fail per CLOUD-418. The discriminating pair is the measurement above — one protected path, a named verb and an unnamed interpreter, asserted TOGETHER, because either alone passes against a gate that answers the same way for everything. Plus the direction a careless fix breaks: a read of a protected path by any program stays allowed, and an unprotected path stays allowed for every program. crates/batten/tests/mediated_verbs.rs is where the write-shaped corpus already lives.
  • Blockers (§8). None. relatedTo CLOUD-442 (which retired memory-guard into this gate on the coverage claim this refutes), CLOUD-1133 (the tool-write half of the same gate, whose absolute-path miss was the mirror defect), CLOUD-96 (the gate itself).

Acceptance

  • A write to a protected path via an interpreter the verb table does not name is refused, measured over the shipped binary.
  • A read of a protected path is still allowed, for a named program and an unnamed one alike.
  • An unprotected path is untouched for every program.
  • The remedy is not a longer list of program names, and the acceptance says so: adding one more interpreter to the table must not make these tests pass.

Found while building CLOUD-1131, by noticing that four of this session's own batten.toml edits went through a gate that had just correctly refused an Edit to a policy module.


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

protected_tool_write (crates/batten/src/hook.rs) reads envelope.writes and asks policy.protected.contains(normalise(path)). normalise strips a leading ./ and nothing else, and PathSet::contains is a glob match over the string it is handed — so a path that does not begin where the glob begins matches nothing.

Claude Code sends tool_input.file_path as an absolute path. Envelope::writes takes it verbatim (input.pointer("/file_path")). So the comparison is /home/<user>/<repo>/.serena/memories/x.md against .serena/memories/**, which is false, and the gate allows.

Measured 2026-08-29, in this repository, against the committed config. Two runs of the shipped binary, same tool, same target, one difference:

file_path as sent verdict
.serena/memories/probe.md denyV-PROTECTED-MUTATION, exit 0 with the verdict body
/home/user/batten/.serena/memories/probe.md allowed, no output

And end to end rather than only through a hand-built payload: a Write tool call issued by a live agent session to .serena/memories/probe-delete-me.md created the file. memory-guard is retired (CLOUD-442) and the protected gate is its declared successor, so the write shapes that guard denied are, on this host, currently ungated.

Every protected glob is affected, not only the memory tree.github/workflows/** and batten.toml are in the same set, and the same comparison decides all three. The command half (protected_mutation) is unaffected: a shell operand is typed the way the author typed it, which is usually relative.

Found while building CLOUD-1131, whose predicate is over the same field: a mediated_call module keyed on input.call.writes cannot match a real host payload either, for exactly this reason. That row records the finding; this one owns the defect.


Refinement — Ready

Refinement gate: Definition of Ready & Done. This body carries only specializations.

  • **Source of truth (§1). **crates/batten/src/hook.rs — where the write target is resolved onto the envelope, beside the notebook_path fallback that is already there. One place, not two: normalise is the shared reading and every consumer of envelope.writes inherits it, so a fix at the comparison site alone would leave the next reader — a policy module over input.call.writes — with the same defect. PathSet is deliberately NOT the fix site: it decides membership over the string it is handed, and teaching it about roots would make one set answer differently depending on who asked.
  • Computable predicate (§2). A write target inside the repository is judged by its repository-relative path, whatever spelling the host sent. Decidable from the resolved root the boundary already holds (git::repo_root, which every other gate reads) and the path itself; no new fact class.
  • What must NOT change (§2). A path OUTSIDE the repository stays outside: it must not be relativized into an accidental match, and it must not become a refusal either. claim-needs-receipt already draws that line for its own predicate — out-of-repo and git-ignored targets are never judged — and the same reading applies here.
  • **Effect (§3). **read. The root is already resolved at the boundary; nothing gains a spawn, a write, or a network reach, and the mediated budget is untouched.
  • Output and exit contract (§5). Unchanged in shape. The refusal keeps naming the path as the caller typed it — that is the pointer a reader can act on — while the membership question is asked of the relative form.
  • **Commit / bump (§6). **fix(hook)patch until 0.1.0. Behaviour-changing for consumers in the raise-only direction house style §8 permits: calls that pass today start being refused, and none that fail today start passing.
  • Test obligation (§7). Over the compiled binary through batten hook, shown able to fail per CLOUD-418. The discriminating pair is the measurement above — the same protected target, sent relative and sent absolute, must reach the same verdict — plus the two directions a careless fix breaks: a path outside the repository is neither relativized nor refused, and an unprotected path inside it is still allowed. crates/batten/tests/mediated_verbs.rs is where the write-shaped corpus already lives.
  • Blockers (§8). None.

Acceptance

  • An absolute file_path at a protected path is refused exactly as the relative spelling is, on the host that sends absolute paths.
  • A write outside the repository is neither refused nor silently relativized into a match.
  • The refusal still names the path the caller typed.
  • A live Write tool call to .serena/memories/ is denied end to end, which is the observation that opened this row.

Found by probing a mediated write while building CLOUD-1131, rather than by reading the gate.

Review in Linear

@coderabbitai

coderabbitai Bot commented Aug 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 53 minutes.

View limit details

Limit 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.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Free

Run ID: 9b5b818b-3d71-4c2e-9d8f-73e2419a8019

📥 Commits

Reviewing files that changed from the base of the PR and between 9dd6579 and 8e4c4f1.

📒 Files selected for processing (12)
  • .claude/rules/toolchain.md
  • batten.toml
  • crates/batten/src/config.rs
  • crates/batten/src/hook.rs
  • crates/batten/src/resolve.rs
  • crates/batten/src/trust.rs
  • crates/batten/tests/connector_allow_door.rs
  • crates/batten/tests/mediated_verbs.rs
  • crates/batten/tests/run_shape_guard_door.rs
  • crates/batten/tests/shell_write_advisory.rs
  • policy/shell-write-advisory.rego
  • schema/batten.schema.json

Note

🎁 Summarized by CodeRabbit Free

Your 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 @coderabbitai help to get the list of available commands.

@wenzowski
wenzowski force-pushed the claude/retirement-gate-integrity-3mfqc9 branch from 2386021 to b706d53 Compare August 29, 2026 20:02
@wenzowski
wenzowski marked this pull request as ready for review August 29, 2026 20:06
@wenzowski
wenzowski marked this pull request as draft August 29, 2026 20:22
…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
@wenzowski
wenzowski force-pushed the claude/retirement-gate-integrity-3mfqc9 branch from b706d53 to 8e4c4f1 Compare August 29, 2026 20:48
@sonarqubecloud

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@wenzowski
wenzowski marked this pull request as ready for review August 29, 2026 21:48
@wenzowski

Copy link
Copy Markdown
Contributor Author

/fast-forward

@wenzowski
wenzowski merged commit 8e4c4f1 into main Aug 29, 2026
17 of 18 checks passed
@wenzowski
wenzowski deleted the claude/retirement-gate-integrity-3mfqc9 branch August 29, 2026 22:17
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