The refusal surface: one line, reachable, and four false refusals fixed - #807
Conversation
CLOUD-1284 Verdict tokens are free text in SCREAMING-KEBAB, so a class name costs ~9 tokens and explains nothing — declare a three-word vocabulary and gate it
Why 115 verdict classes are spelled
So the current spelling costs 6.9 tokens per refusal over the target — about 2,058 tokens per session at ~300 refusals. Worked examples: The second cost is the one that matters more. The words are free text with no declared meaning, which is why every class needs its own gloss to explain its own name: 6,640 words of Measured at
Opaque numeric codes were considered and lose. Refinement — Ready (a three-word positional vocabulary, gated at load) Refinement gate: Definition of Ready & Done. This body carries only specializations.
Both halves of that spelling are load-bearing, and the measurement separates them. Space beats hyphen consistently but modestly — Three 64-word lists give 262,144 ordered triples for 115 classes — log2(115) = 6.8 bits needed against 18 available. The headroom is the point: not entropy, but room enough that the vocabulary never forces an awkward name. Deliberately not in scope: the flat namespace across 90 consumer / 7 native / 18 preset classes, which is CLOUD-1181's. Changing the spelling does not change the namespace, and a later qualifier composes with either. No predicate changes meaning in this row.
Honest costs, stated up front
Acceptance
CLOUD-1088 `V-SHELL-RULE-ADDED` names a route that clears a different rule, so the campaign's own door-tier suites have no landable spelling
Why
and its class text says: " Measured 2026-08-28, on the real tree: prepending The reason is structural, not a typo. violation contains {"rule": "shell-rule-retired", "verdict": "V-SHELL-RULE-ADDED", ...} if {
some path in delta.added
governed_at_head(path)
}So for a RE-DERIVED 2026-08-31 against
|
| composer | line | constructor |
|---|---|---|
receipt_refusal |
4467 | Refusal::new |
substitution_refusal |
4848 | Refusal::new |
pipeline_refusal (×3: Piped/Trailing/Orphaned) |
4866 | Refusal::new |
ceiling_refusal |
5126 | Refusal::new |
policy_refusal |
5374 | Refusal::new |
unknown_program_refusal |
6379 | Refusal::declared |
protected_refusal |
6537 | Refusal::declared |
shape_refusal |
6597 | Refusal::new |
content_refusal |
6614 | Refusal::new |
unkeyed_refusal |
6638 | Refusal::new |
Refusal::new leaves verdict: None — a state refusal.rs names outright: "the declared class, or None for a refusal composed from consumer prose." Also 0 of 32 mediated_call rules (0 of 97 rules) declare a verdict key.
policy_refusal is the subtle one: it is half-converted. It passes render_line's output as the reason, so the module path renders the one-line format correctly — but it still calls Refusal::new, so verdict stays None. refusal.verdict() therefore returns None even for module refusals, and policy explain is unreachable from the converted path too. Anything keying off the recorded token sees nothing.
Refinement — Ready (declare the classes the composers already state, and record them)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- Source of truth (§1). The ten composers in
crates/batten/src/hook.rstabulated above;refusal.rs'sRefusal::new/Refusal::declaredpair;verdict.rs'sNativeenum. - Computable predicate (§2). Every mediated deny carries a declared class:
refusal.verdict()isSomeon every path, module refusals included. Decidable by construction and countable —Refusal::newsites insrcfall from 21 to roughly 11. - Mechanism (§3). Each unconverted composer's hardcoded cause becomes a declared class, and the composer switches to
Refusal::declared— reuse it; do not write a third constructor. These are engine words about generic concepts (a substitution, a discarded verdict, a ceiling, a shape, unkeyed work), so perverdict.rs's own boundary — "These are Batten's OWN words… A refusal composed from a consumer's[[rule]]row is a different thing… and it is not represented here" — they belong inNative, not in consumer[[verdict]]rows.Nativeis an enum whose exhaustiveness is compile-checked, so the coupling lands in the type system rather than in a convention. **That is roughly ten new variants, not 32 config-row edits. **policy_refusaladditionally records the token it already renders. - Deliberately not in scope (§2). Rewriting the 6,640 words of
classprose, and shortening the emitted line. Placement is this row's defect, not content — a summarisation pass loses the reasoning those rows were written to carry. Dropping the gloss from the hot path is a separate row and depends on this one landing first. - Output & exit contract (§5). Unchanged. Exit
2at the hook boundary either way; what changes is that the refusal now carries a token a reader can dereference. - **Commit / bump (§6): **
fix(hook)— patch until0.1.0regardless of type. - Test obligation (§7). No new gate and no new severity, so no firing rate to establish — this converts existing refusals rather than adding one. Over the compiled binary:
refusal.verdict()isSomefor every mediated deny including the module path; theRefusal::newcount insrcis asserted rather than counted by hand; andpolicy explainresolves for every token any composer can raise. A token whose explanation is missing is a dereference pointing at nothing — CLOUD-1189's class, assertion borrowed, row not. - Blockers (§8): none.
**Note for the implementer. **verdict::validate refuses a class with no route, and one whose only route is an override. A [[verdict]] row nothing raises fails the load, and a token no row declares fails the load — so a composer's token and its declaration must land in the same commit, never as two passes over the tree.
Acceptance
Refusal::newsites incrates/batten/srcfall from 21 to ~11, asserted.- Tripping any mediated rule yields a refusal whose
verdict()isSome, module path included. batten policy explainresolves every token any composer can raise.- No refusal lost its pointer; no emitted text got longer.
CLOUD-1286 The hot path emits the gloss and two wrappers unconditionally, so the one-line format CLOUD-1053 landed is never what an agent actually sees
Why
verdict::render_line ships the target format in its own doc comment — V-TASK-UNDEFINED (a command row names a task this tree does not define) batten.toml:1604 — and GLOSS_MAX = 120 bounds the middle field on the stated reason that "a bound here is what keeps 'one line' a property of the data rather than of the author's restraint." Then three things put the paragraph back:
render_lineemits(gloss)unconditionally.Refusal::renderwraps whatever it got inRefused by <rule>: … Fix: ….- The hook appends the bypass sentence — identical on every deny, which CLOUD-437 already recorded as a defect.
So even a declared refusal that render_line already reduced to one line is padded back out. The best refusal in the tree today, V-PROTECTED-MUTATION, is still ~25 words, and most of what it spends is dereferenceable: the Refused by <rule>: prefix restates the token, and the parenthetical is the token's own definition inlined.
The arithmetic. Roughly 300 refusals fire in a long session — every Bash call is mediated. Estimates below are token counts over the rendered text; the class-name column is measured with tiktoken o200k_base on CLOUD-1284:
| est. tokens | × ~300 | |
|---|---|---|
| what fires today (88 words, sampled live) | ~115 | ~34,500 |
render_line's current output |
~43 | ~12,900 |
| token + pointer, gloss dropped | ~6 | ~1,800 |
The gloss alone is ~28 of the ~43. ~34,500 against a ~175k window is 20% — which is CLOUD-417's headline figure, arrived at independently from the other direction, and evidence for that row as much as this one.
Cache is not a defence, and it is usually offered as one. Caching rewards repetition, and the boilerplate is byte-identical every firing, so it is already discounted on the invoice. But a cached token occupies exactly the same context window as an uncached one, and window pressure is what forces compaction. The objective is occupancy; the raw count is the honest number.
Rule 4 is not the defect. It forbids leaking matched content and it holds. README.md promises concision — "a wrapped tool's two thousand lines become one" — which is a different property, and nothing gates it. The prose grew inside the pointer.
Refinement — Ready (emit token and pointer; dereference the rest)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Source of truth (§1). **
crates/batten/src/verdict.rs'srender_lineandGLOSS_MAX;refusal.rs'sRefusal::render; the live samples taken 2026-09-01. - Computable predicate (§2). A mediated refusal's emitted line carries a declared class and its pointers, and nothing else. Decidable over the emitted bytes against a declared ceiling.
- Mechanism (§3). The hot path emits token + pointer and stops:
shell edit refused mise-tasks/land.sh:845. The gloss, theRefused by <rule>:prefix, theFix:clause and the bypass sentence move behindbatten policy explain <token>.
The subject stays inline.render_line**'s own doc still governs: "making a reader run a second command to learn WHICH file would make the common case slower, which is the opposite of the point." This shortens the prose, never the pointer.****
**KeepGLOSS_MAX. It still boundsexplain's first line, and it is what stops the gloss growing back into a paragraph once nothing on the hot path reads it.
Sequencing against CLOUD-1285, which is not a dependency. That row converts the eight untokened composers. Landing this one first is permitted and simply leaves those composers unchanged, and so unreachable by the ceiling until it lands. Neither row blocks the other. - The gate (§3, non-negotiable 2). A declared ceiling on the emitted line, in
batten.tomland never a literal in the crate — copypolicy-budget's shape.GLOSS_MAXbounds one field; this bounds the line. - **The decision this row must settle in writing (§2). **
README.mdalso promises a refusal "points at the fix — a deny names the rule, the reason, and the command to run instead" — CLOUD-122's landed contract. This drops all three clauses from the emitted line. Either the token is the pointer to the fix (one hop viapolicy explain), or one remedy command stays inline and only the reasoning is dereferenced. Both are defensible; the second costs roughly 10 tokens × ~300 firings, so price it rather than assuming it free. Pick one, write down why, and amend the README clause to match — leaving the README claiming the other is exactly how this row's defect recurs. - Deliberately not in scope (§2). Rewriting the 6,640 words of
classprose. Placement is the defect, not content; moving it behind the dereference keeps every word available to whoever asks. - Output & exit contract (§5). Exit codes unchanged. The emitted text is what changes, and it must stay byte-stable.
- **Commit / bump (§6): **
fix(hook)— patch until0.1.0regardless of type. - Test obligation (§7). The discriminating pair is the whole commit: a refusal over the ceiling fails, one under it passes. Anti-vacuity is mandatory — the converted
no-tool-substitutionrefusal must still pass; a ceiling that refuses everything gets switched off. Plus: no refusal lost itspath:line. Firing rate for the new deny is 0 after the converting commit by construction, and every refusal in the tree before it. Over the compiled binary, neverwith input as. - Blockers (§8). Blocked by CLOUD-1284, and by nothing else. Dropping the gloss is safe only where the class name is self-describing on a declared grammar; without that this trades concision for opacity, which is the
pylint-errorsfailure.
Acceptance
- Tripping
no-tool-substitutiondeliberately emits one line: a token and a pointer. No gloss, noRefused by, noFix:, no bypass sentence. batten policy explainresolves that token to the gloss and to the prose that used to be inline.- No refusal lost its
path:line. README.md's pointer-and-fix clauses say what the code now does.
CLOUD-1287 A newline is whitespace, so a multi-line call is judged by its first word — `protected_readers` is unreachable from any script, and the documented "under-denies" direction is measured backwards
Why
.claude/rules/policy-modules.md documents the newline bound and states a direction for it:
A newline is whitespace, not a separator — bash disagrees, and the bound is deliberate rather than an oversight… It under-denies, which is the sanctioned direction.
Measured, it over-denies. Discriminating pair, taken 2026-09-01 at 79b8bfd, same file and same read:
| call | result |
|---|---|
stat -c %s batten.toml |
allowed — prints 343806 |
cd /tmp ⏎ stat -c %s /home/user/batten/batten.toml |
refused: V-PROTECTED-MUTATION … /home/user/batten/batten.toml cd |
stat is a declared protected_readers entry. Because a newline is whitespace rather than a separator, both lines are one segment, words[0] is cd, and stat is never consulted — the refusal names cd, a verb whose operand is a directory and which writes nothing at all.
**This is not a cosmetic misattribution; it disarms the design's own escape hatch. **batten.toml's protected_readers comment makes the fail-closed argument explicitly, and it rests on the hatch being cheap:
an operand that is a protected path now refuses unless the program is KNOWN — present in
[[verb]]at all, or listed here. Forgetting an entry below is a false refusal somebody fixes in a minute; forgetting a writer is no longer a silent hole.
That trade is sound, and CLOUD-1141 landed it on those terms. But it cannot be a one-minute fix for a program that is already listed — stat is in the set and is still refused. Every multi-line script is judged by its first word, so the hatch is unreachable from the shape an agent writes most.
Three further readings from the same session, all consistent with one cause:
echo hi && true batten.toml→ refused, namingtrue.echo …withgit grep … -- batten.tomlon a later line → refused, namingecho.- A
printfformat string on one line was reported as "a path in this repository" for agrepon another. (That last one is also CLOUD-1109's defect two from a new direction, and is recorded there.)
Why it belongs with the refusal-output rows. Every false refusal costs a ~90-token refusal plus a retry, so it inflates the exact bill CLOUD-1285 and CLOUD-1286 are cutting — and it fires on the most common command shape there is.
Refinement — Ready (stop attribution crossing a newline; do not touch the separator semantics)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Source of truth (§1). **
hook::segments' newline handling incrates/batten/src/hook.rs;batten.toml'sprotected_readersset and the comment above it; the pair above, reproducible from any checkout. - Computable predicate (§2). A declared reader's read of a protected path yields the same verdict whether or not an unrelated line precedes it in the same call. Decidable from the payload — no filesystem access, no spawn.
- Deliberately not in scope (§2), and this bound is the whole risk of the row. Do not promote a newline to a separator wholesale.
.claude/rules/policy-modules.mdrecords why: "promoting it would change every landedpipelineverdict." That is a far larger and riskier change than this defect needs. The narrow fix is that verb/operand attribution stops crossing a newline; segment identity forpipelinerows is untouched. - **Effect (§3). **
read. No verb is added andhookkeeps its classification; the derived read-only allowlist does not move. - **The gate (§3, non-negotiable 2). **
crates/batten/tests/mediated_verbs.rsis the landed corpus for exactly this class — add the pair there. The obligation is met by an existing suite rather than a new gate, so nothing new is registered. - Output & exit contract (§5). Unchanged. A deny is exit
2at the hook boundary; this row changes which calls reach it, never the shape. - **Commit / bump (§6): **
fix(hook)— patch until0.1.0. Not breaking for the consumer surface: the change only ever narrows what is refused, so no call allowed today starts failing. - Test obligation (§7). No new deny and no severity to choose, so there is no firing rate to establish — this row removes refusals. Over the compiled binary: the pair above must produce one verdict, and it is red against the current binary, so it discriminates (CLOUD-418). The discriminator that keeps the fix from becoming a blanket allow: a genuine mutation of a protected path on a later line of a multi-line call must still be refused. Plus an assertion that a declared
protected_readersentry is consulted rather than shadowed by an earlier line's verb. - Blockers (§8): none.
relatedToCLOUD-857 (the landed precedent for anchoring on segments rather than the whole command line, and the same failure measured on the vendored preset), CLOUD-1257 (hook::segmentsblind to$(…)— a sibling defect in the same parser, not this one), CLOUD-1141 (the fail-closed design whose hatch this disarms) and CLOUD-1109 (the same rule family's path resolution).
Acceptance
- The pair above yields one verdict.
- A genuine mutation on a later line of a multi-line call is still refused.
- A
protected_readersentry is consulted regardless of what precedes it in the call. .claude/rules/policy-modules.md's "it under-denies, which is the sanctioned direction" sentence is corrected, since it is now measured false — the prose and the parser must not disagree about which way the bound errs.
CLOUD-1109 `no-tool-substitution` resolves a bare relative path against the repo root, then asserts tracked-ness it never checked
Two independent defects in one row, reproduced twice on 2026-08-28 — once by a subagent, once directly.
Reproduction. With cwd /tmp/claude-0/.../scratchpad/probe, a directory outside the repository:
cat err.txt— refused, verdict prose: "err.txt, a path in this repository", remedy: "a text utility over a path this repository tracks".cat /tmp/claude-0/.../scratchpad/probe/err.txt— the identical file, absolute — allowed.
err.txt is a transient scratch file. It is not in the repository and git ls-files has never heard of it.
Defect one: the path is resolved against the wrong root. A bare relative argument is being joined to the repository root instead of to the invocation's working directory. That is why the two spellings of one file get opposite verdicts, and it is the same class as CLOUD-824 — a second root resolver answering a different question from git::repo_root — except here the disagreement is between the repo root and the call's cwd rather than between two repo roots.
The consequence is not just a false positive on scratch work. Every path the guard judges is judged against a root the caller may not be in, so the corpus it thinks it is protecting and the corpus it is actually reading are different sets. A file inside the repo, reached by a relative path from a subdirectory, resolves to a different absolute path than the one the caller meant.
Defect two: the verdict asserts a fact nothing established. Both the verdict prose and the remedy state that the path is in this repository and that this repository tracks it. Neither was checked. .claude/rules/policy-modules.md makes a verdict token a thing a reader can look up; a token whose prose makes an unchecked claim about its subject is worse than an unnameable class, because the reader believes it. It also sends the reader to .claude/rules/scanning.md to choose between question classes that do not apply to a file the repository does not contain.
The two are independent and either can be fixed without the other, which is why both are named. Fixing only the resolution leaves a verdict that would still assert tracked-ness on the strength of containment; fixing only the prose leaves the guard refusing scratch work.
Authority. batten.toml's no-tool-substitution row (kind = "pipeline", scope = "mediated_call", severity = "deny") declares the rule; the path handling is the engine's, in whatever resolves a pipeline row's path arguments at the mediated-call boundary. The verdict prose belongs to the row's [[verdict]] entry.
Refinement — Ready (resolve against the caller's cwd, and stop claiming what was never checked)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- Authority boundary (§1). Two authorities, one each for the two defects, and neither is a runner. The path resolution belongs to the engine's
pipelineargument handling incrates/batten/src/hook.rs, which must ask the invocation's working directory rather than the repository root. The prose belongs to theno-tool-substitutionrow's[[verdict]]entry inbatten.toml. The rule row itself — kind, scope, severity, the utility corpus — does not change. - Computable predicate (§2). A bare relative path argument resolves against the mediated call's own working directory. Decidable from the payload: the harness supplies the cwd, so the same file named relatively and absolutely from one directory yields one verdict rather than two. The tracked-ness question is decided here rather than deferred: do not check it, and stop asserting it. The guard decides containment, which is what it can see at the boundary; a
git ls-fileslookup per mediated call would be a spawn on the hook path, whichRuleKind::scopesforbids for everymediated_callkind and whichperf-assert's p95 budget prices out besides. So the verdict prose says containment, and says nothing about the index. - **Effect (§3). **
read. No verb is added;hookkeeps its classification and the derived allowlist does not move. - Output & exit contract (§5). Unchanged shape — a deny is exit
2at the hook boundary. What changes is the verdict text: it must state only what the predicate decided. The remedy keeps pointing at.claude/rules/scanning.md, which is correct for a path the repository does contain. - Commit / bump (§6). Lands as
fix(hook)— patch until 0.1.0. Not breaking for the consumer surface: the change only ever narrows what is refused, so no call that is allowed today starts failing. - Test obligation (§7). Over the compiled binary in
crates/batten/tests/mediated_verbs.rs, the pair that is the whole defect: one file outside the repository, named relatively from its own directory and absolutely, must produce the same verdict — today they differ, so the assertion is red against the current binary per CLOUD-418. Plus the discriminator that keeps the fix from being a blanket allow: a relative path naming a file that is inside the repository, from a subdirectory, must still be refused. And a verdict-text assertion that the string carries no tracked-ness claim, so the second defect cannot silently return. - Blockers (§8). None.
relatedToCLOUD-824, the same class one layer over — a second root resolver answering a different question from the engine's own primitive.
Found while grooming CLOUD-1089; the guard refused a scratch file twice during that work, which is how it surfaced.
CLOUD-609 A bare directory destination escapes the protected gate: `dir/` does not match the `dir/**` glob every consumer writes
Why
protected is a list of globs matched by rules::PathSet over globset with literal_separator(true). A directory-shaped operand — the trailing-slash form — is not a member of dir/**, which requires at least one component after the separator. So every mutating verb aimed at a directory inside the guarded set is allowed:
cp /tmp/draft.md .serena/memories/ # allowed
mv /tmp/draft.md .serena/memories/ # allowed
tee .serena/memories/ # allowed (nonsense, but the same hole)
while the same commands naming a file inside it deny. .serena/memories/** is the shape consumer #1 declares and the shape the schema's own examples encourage, so this is the ordinary spelling rather than an exotic one.
It is a pre-existing gap, not CLOUD-442's. Measured on CLOUD-442's branch while porting cp/install: the retiring memory-guard-check matched the guarded path as a substring (*.serena/memories/*), so it caught the directory form; the engine's glob matcher does not. That makes it a fidelity loss in the CLOUD-312 port that CLOUD-442 did not introduce and deliberately did not widen its scope to fix — the every-operand verbs (mv, tee, rm) and the redirect pseudo-verbs have carried it since CLOUD-96.
The direction is under-denial, which is sanctioned (house style §5) — but a copy into the memories tree is the exact shape memory-guard existed for, so this one is worth closing rather than accepting.
Where the decision sits
Three candidate mechanisms, and the choice is a policy question rather than a bug fix, which is why this is filed rather than folded in:
- Normalise the operand. Strip a trailing separator in
hook::normalisebefore matching, sodir/is asked asdir. Thendir/**still misses it —diris not insidedir— so this only works together with (2) or (3). - Ask the containment question, not the membership one. A destination that is a directory means "write inside it", so the honest predicate is "does this path contain, or sit inside, a protected path" rather than "is it a member". That is a new question for
PathSetand would need to be a named method, not a quiet widening ofcontains— every other caller wants membership. - Push it to the consumer. Document that a guarded directory needs both
dir/**anddirdeclared. Cheapest, and it makes every consumer's config carry a subtlety the engine knows and they do not — the shape non-negotiable 1 usually argues for, but here the fact is about the matcher rather than about the repository.
Acceptance
cp /tmp/x <guarded-dir>/denies, under whichever mechanism is chosen, with the row's declared redirect intact.- The read direction is untouched:
cp <guarded-dir>/file /tmp/xstill allows, andls <guarded-dir>/is not this gate's business. - Whatever changes,
PathSet::containskeeps meaning membership for its other callers —scopeandunlandedare answers about files, and a rule selecting a directory would select nothing to inspect.
Both questions, answered in the Ready block below
- Which of the three mechanisms, and specifically whether a containment predicate belongs on
PathSetat all — it is a second question over the same lists, and the module's own doc comment says nothing here consults another field. - Does the redirect operand form (
> dir/) need the same treatment? It cannot name a directory in a working shell (a redirect to a directory is an error), so the answer may be no — which would keep the fix insideprotected_mutation's operand path.
Probe plan
- Unresolved decision. Choose normalization plus another mechanism, a distinct
PathSetcontainment predicate, or consumer-declareddir; separately decide whether> dir/belongs in scope. - Probe. Run the recorded
cp /tmp/x <guarded-dir>/case and its read-direction controls against a.serena/memories/**declaration; compare the trailing-slash operand with a file inside the same directory. Exercise> <guarded-dir>/only to establish its documented shell error behavior. - Record. Allow/deny result for each operand form, redirect result, and whether any candidate changes
PathSet::containsmembership behavior forscopeorunlanded. - Ready / next step. Ready when one mechanism denies the directory destination while the read controls remain allowed and
containsremains membership-only; include redirects only if the probe establishes a protected-mutation path rather than the stated shell error.
Refinement — Ready (normalise the operand and ask containment by a named method, never by widening contains)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
Question 1 is answered (1)+(2), and (3) is declined. Normalisation alone provably does not close it — the body already shows dir is not inside dir/** — so it must pair with the containment predicate. Pushing it to the consumer is the one option that makes every consumer's config carry a subtlety about the matcher: the fact is Batten's, not the repository's, so non-negotiable rule 1 argues the opposite way here from usual. Requiring both dir/** and dir also means every consumer who writes only the shape the schema's own examples encourage stays exposed and never learns why.
Question 2 is answered no, and by evidence rather than by preference: a redirect to a directory is a shell error, so > dir/ cannot reach a working command. The fix stays inside protected_mutation's operand path, and the probe below exercises the redirect only to confirm that error behaviour rather than to gate it.
- **Authority boundary (§1). **
crates/batten/src/hook.rs—normalisefor the trailing separator, andprotected_mutationfor the operand.rules::PathSetgains a named containment method;containsis untouched and keeps meaning membership, becausescopeandunlandedare answers about files and a rule selecting a directory would select nothing to inspect. That is why a quiet widening ofcontainsis refused rather than merely disfavoured: it would change what two unrelated callers ask. - Computable predicate (§2). A mutating verb whose operand is a directory that contains or sits inside a protected path is denied. Two decidable steps: strip a trailing separator so
dir/is asked asdir, then ask containment rather than membership. The direction is deliberately one-way — the read direction is not this gate's business, socp <guarded-dir>/file /tmp/xstill allows andls <guarded-dir>/is never consulted. - **Effect (§3). **
read.hookkeeps its classification: it inspects an operand and answers. - Output & exit (§5). Unchanged: a deny is exit
2at the hook boundary with the row's declared redirect intact, which is the part a careless fix drops — a refusal that stops naming the sanctioned mutation is a worse refusal even when it denies correctly. Pointer-only: the operand and the matched protected glob, never a file's contents. - **Commit / bump (§6). **
fix(hook)— patch until0.1.0, since below that release-plz bumps the patch whatever the type says. Not!for the consumer surface: this only ever adds refusals, which house style §8's raise-only invariant permits, and no exit code or output shape moves — no call allowed today by a correct reading starts failing.mise run semverdecides the library half, since a newPathSetmethod is apubAPI change and that must be asked, not assumed. - Test obligation (§7). Over the compiled binary, shown able to fail per CLOUD-418. Red today, and it is the measured case:
cp /tmp/x .serena/memories/must deny, where the retiringmemory-guard-checkcaught it by substring and the engine's glob matcher does not — a fidelity loss from the CLOUD-312 port, so the assertion is a regression test as much as a new one. The same for the other every-operand verbs the gap has always covered:mvandtee. The direction a careless containment predicate breaks:cp <guarded-dir>/file /tmp/xstill allows. And the case that would otherwise ship silently:PathSet::containsstill answers membership forscopeandunlanded, asserted directly, because widening it would change two callers that never asked this question and would look like nothing in the diff. - Blockers (§8). None.
relatedToCLOUD-442 (on whose branch this was measured, and which deliberately did not widen scope to fix it), CLOUD-312 (the port during which the substring matcher's coverage was lost), CLOUD-96 (which introduced the guarded set, and where the every-operand verbs have carried this since) and CLOUD-418.
CLOUD-1258 Nothing gates a `Read` of `.serena/memories/**`: `no-tool-substitution` decides over shell argv, so the memory tools are prescribed everywhere and required nowhere
Why
Measured 2026-08-31. A session read .serena/memories/github-access.md with the
generic file-read tool while the Serena MCP server was healthy and
read_memory was loadable. Nothing refused it, and nothing named the tool that
should have been used.
Two gates look like they cover this and neither can:
no-tool-substitutioniskind = "pipeline",scope = "mediated_call", with
substitutes = ["cat", "head", "tail", "sed:-n", "grep", "rg", "find", "ls", "wc"]. It decides over shell argv. A structured-tool call is not a shell
command, so the row is structurally incapable of seeing it. It gates
shell utility → structured surface and there is no row for
generic structured tool → specific semantic tool..serena/memories/**is inprotected, but crossed with the[[verb]]
table, which enumerates mutations. CLOUD-442's port states "Reads stay
allowed" — right for the question that row was answering, and the reason
nobody has asked whether a read through the wrong instrument matters.
CLOUD-185 closed the write half of exactly this shape ("memory-guard sees only
Write/Edit — Bash writes and git mv into the memories tree bypass it").
CLOUD-864 produced no-tool-substitution for the shell half. This is the third
face of the same object and it is the one still open.
Why it is a defect rather than a style preference
A path read couples the caller to the tree layout. CLOUD-868 is an open proposal
to move .serena/memories/ into a private repository. Every flat-path read
of a memory is an unmigrated call site against that move: read_memory keeps
working across it, a hardcoded path does not. The gate makes those call sites
unwritable now rather than discoverable after the move breaks them.
The redirect also already exists — the protected-path rows carry the Serena tool
to use as each row's redirect, which is what makes this a matcher change
rather than new vocabulary.
The honest limit, stated so this row is not oversold
This gate would not have saved the session that found it. The failure there was
not reading the memory through the wrong tool — it was not reading it at all
until prompted, having asserted an environment block the memory exists to
refute. A redirect fires only once a read is already happening. That trigger
problem is a different mechanism and needs its own row; naming it here keeps
this one from claiming a reach it does not have.
Refinement — Ready
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Source of truth (§1). **
batten.toml'sprotected/[[verb]]crossing —
the path set is already declared; what is missing is a read-side row over the
structured-tool surface. - Computable predicate (§2). A tool-call read whose target resolves under
.serena/memories/**is refused with aredirectnamingread_memory, when
the session offers it. Absent Serena it must allow — a redirect naming a tool
the session does not carry is CLOUD-998's defect one layer over. - Effect (§3). No command-surface change.
- Output & exit (§5). Pointer-only: the path and the redirect, never a byte
of the memory. Exit 2 is the mediated deny, per the one contract. - **Commit / bump (§6). **
feat→ patch until 0.1.0. - Test obligation (§7). A compiled-binary case over the mediated read; and
the firing rate replayed beforedenyis chosen, since this fires on a read
rather than a write and a noisy deny stops the fleet. - Blockers (§8). None.
{
"source_of_truth": "batten.toml",
"gate": { "task": "batten-check", "exits": [0, 2] },
"commit_type": "feat",
"blockers": [],
"tests": [
{
"file": "crates/batten/tests/mediated_verbs.rs",
"mutation": "drop the read-side path match, so a generic read of a memory path is allowed"
}
]
}Acceptance
- A generic-tool read of a path under
.serena/memories/**is refused and names
read_memory, and the same read allows when Serena is not offered.
CLOUD-1282 A `mediated_call` row may declare tree-only fact columns and nothing refuses it — and the five that ARE refused are a hand-maintained list, which is the drift, not the fix
Why
The declaration-side half of CLOUD-1279's class, split out of it because the fix is a different shape with a different blast radius.
crates/batten/src/rules.rs:3452-3499 refuses five columns on a mediated_call row — documents, sources, lines, uses, invocations — each as its own hand-written if self.scope == RuleScope::MediatedCall && !self.<field>.is_empty() block. The comment on the second one records how the list grew:
"
sourcesandlinesare tree columns for the same reasondocumentsis, and they were added topermitswithout this — so a mediated-call row could declare either and have it silently never read."
So the list is already known to lag the struct, and it still does. A mediated_call row may declare refs, git, ranges, captured, tools, forge, landing, staged, external, symbols or delta_sources today. Each parses, loads, is never acquired for that scope, and the module reading the corresponding input.tree.* key decides nothing — CLOUD-845's dead-gate class, arriving one layer above the module.
Why enumerating the missing ones is the wrong fix
CLOUD-1279's original acceptance asked for five more names to be added "alongside the five columns already refused". That was measured against the struct while building CLOUD-1279's module half and is wrong twice over: the set is larger than five, and a second hand-maintained list is precisely the mechanism that produced this gap. The first five were written by hand, the struct grew, and nobody updated them — writing five more reproduces that in a year.
The module half CLOUD-1279 landed does not have this problem, and it is the shape to copy: it reads the fact model (Fact::tree_key, Class::surface) rather than a list kept beside it, which is what tree_keys()'s own comment argues for — "the check is against the ENGINE's key set rather than against a list kept here… One table, read — never restated, which is the defect this whole row is an instance of."
The blast radius is real and is why this is its own row
A derived refusal can refuse rows that load today — in this repository and in every consumer's. That needs its own census before it lands: which committed rows declare a tree-only column on a mediated scope, and whether any is deliberate. CLOUD-1279's module half could be verified by running the suite; this one cannot.
Refinement — Ready (derive the column refusal from the fact model, not from a list)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Authority boundary (§1). **
crates/batten/src/rules.rsandcrates/batten/tests/. Nomise-tasks/*.shand notests/**/*.bats. - Computable predicate (§2). A
mediated_callrow declaring any column whose fact is not resolvable on the hook surface is refused at load, naming the column; the same row atscope = "tree"loads. Derived from the fact model, so a column added later is covered without an edit here — which is the property the current five do not have. - Deliberately not in scope (§2). The module-side guard, which is CLOUD-1279's and landed. Changing which facts either surface carries.
- **Effect (§3). **
read. Load-time only. - Generated artifacts (§4). The config schema may regenerate if the refusal becomes expressible in it.
mise run fix; never hand-edit. - Output and exit (§5). Exit
1, naming the column and the scope. Pointer-only. - **Commit / bump (§6). **
fix(rules)— patch, unless the census finds a committed row it refuses, in which case the bump is that row's to argue. - Test obligation (§7). Over the compiled binary. Shown able to fail per CLOUD-418: a mediated row declaring a tree-only column is refused naming it, and the anti-vacuity mirror — the same column at tree scope loads, and a mediated row declaring only hook-resolvable columns loads. Plus an arm that fails when a NEW tree-only column is added to the struct without being covered, so the derivation is asserted rather than assumed.
- Blockers (§8). None.
relatedToCLOUD-1279 (the module half, landed, and the row this was split from), CLOUD-845 (the class), CLOUD-876 (the general fix whose scope neither half completed alone), CLOUD-418.
Acceptance
- A
mediated_callrow declaring a tree-only column is refused at load, naming the column. - The same column on a
treerow still loads, and a mediated row declaring only hook-resolvable columns still loads. - The refusal is DERIVED from the fact model — adding a tree-only column to
Ruleneeds no edit here, and a test asserts that rather than a comment claiming it. - The five hand-written blocks are replaced by the derivation rather than left beside it, so there is one authority and not two.
- A census of committed rows records whether any is refused by the change, in this repository and in
batten.example.toml.
CLOUD-1091 `branch_validity` reads a branch that is merely BEHIND `origin/main` as CLOUD-516's restart, so a receipt taken against current main is void and re-taking it cannot help
Why
receipt.rs's branch_validity voids a branch-keyed receipt when
if own == 0 && recorded != head {
return Validity::StaleMain;
}recorded is the origin/main the check ran against; head is the branch's own HEAD. CLOUD-516's table justifies this for one situation — git checkout -B <name> origin/main after a merge, where the receipt's base is older than HEAD and the branch has nothing of its own.
The comparison is symmetric and the situation is not. It also fires when the receipt's base is newer than HEAD — a branch cut from an earlier main that has not been rebased yet. That is not a restart. The receipt is more current than the branch, not less.
Measured 2026-08-28, on claude/stage-2-3-grooming-uqk71k:
HEAD 8b1d209a (branch, cut from the previous main)
origin/main 08ed7047 (advanced by one commit mid-session)
own commits 0
receipt base 08ed7047 (recorded by a search taken seconds earlier)
verdict StaleMain
The issue-search receipt recorded the **current **origin/main, and the gate voided it anyway.
**The remedy the refusal prescribes cannot clear it. **filing-needs-a-search's reason says "Search the board before filing… The receipt mints itself from that result." The receipt is append-only and recorded_base correctly reads the last base line — so a fresh search appends a fresh, correct entry, the comparison still runs against HEAD, and the verdict is unchanged. Three searches were run; all three refused identically. The loop only broke on git merge --ff-only origin/main, which is not what the message asks for.
The message is backwards, which is what makes this expensive. It reads "the receipt was taken against an older origin/main, which has since moved." Both halves are false in this direction: the receipt was taken against the current main, and what is behind is HEAD. An author reading it re-runs the search — the one action guaranteed not to work.
Scope. The predicate is shared by every branch-keyed receipt (claim, issue-search, bot), so the same false void reaches claim-check's receipt and claim-needs-receipt's write gate. Any session on a branch that is behind main with no commits of its own is affected — which is the ordinary state of a freshly-cut branch in a repository landing ~40 commits/day.
What is not claimed. The CLOUD-516 direction is correct and must keep working: a receipt older than a restarted branch stays void. This is about the other direction only.
Acceptance
- A branch with no commits of its own, behind a receipt's recorded base, keeps a valid receipt.
- CLOUD-516's case still voids: a receipt whose base predates a restarted branch's HEAD is refused, with its existing case still red under mutation.
- Where a receipt is genuinely void, the message names the direction that is actually true, so the prescribed remedy is one that can clear it.
Refinement — Ready (the direction is the missing conjunct)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Authority boundary (§1). **
crates/batten/src/receipt.rs—branch_validityand theValidityvariant it returns. The reason strings on thereceiptrows inbatten.tomlare a binding surface and are corrected in the same change wherever they state the direction; no predicate moves into config. - Computable predicate (§2). A receipt is void only when its recorded base is an ANCESTOR of HEAD — the restart, where the branch moved forward off a base the receipt predates. A recorded base that HEAD has not reached yet is a branch awaiting a rebase and keeps the receipt. Decided in the engine, from facts
repo_factsalready resolves; no new rule kind and no new verb. - **Effect (§3). **
read.branch_validityinspects; nothing about the effect table or the derived allowlist changes. - Output & exit (§5). Unchanged, and pointer-only: the verdict is a
Validityvariant, and the reason string names the direction and a remedy without carrying a receipt's contents. - **Commit / bump (§6). **
fix(receipt)— patch until0.1.0. Not!for the consumer surface: this only ever widens what passes, in the direction that was refusing correct work, and no row's declared shape changes.mise run semverdecides the library half, sincebranch_validityis private. - Test obligation (§7). Over the compiled binary, three cases, and the third is the discriminator: a behind-HEAD receipt is honoured; CLOUD-516's restart is still void; and a receipt whose base equals HEAD is honoured. Without the restart case a change that always returned
Validwould pass, which is the direction this widening makes easy to get wrong. Shown able to fail per CLOUD-418 — the behind-HEAD case must be red against the current predicate, which is the measurement in the body above. - Blockers (§8). None.
relatedToCLOUD-516 (the row whose fix introduced this comparison, and whose case must stay red), CLOUD-353 (a branch cut from a stalemain, the state this misreads), CLOUD-820 and CLOUD-733 (the other two receipt-lifecycle defects on the same store).
Not in scope. The append-only receipt format — recorded_base already reads the last entry and is correct. Changing which checks are branch-keyed.
CLOUD-896 Two advisory producers share one channel and no rate budget, and a third is queued behind them
Why
CLOUD-461 puts the advisory drain onto hookSpecificOutput.additionalContext at PostToolBatch, alongside the drift notice — and its PR is explicit that they "coalesce into one document", asserted by a case that fires both producers on one batch. That solves framing: one JSON object per call. It does not solve volume: the two share no rate budget, and the Stop advisory (CLOUD-892) makes a third.
Coalescing bounds how many documents are emitted. Nothing bounds how much any of them says, or how often the set as a whole speaks. CLOUD-82 already holds a token budget for the drain alone; extending it to the channel rather than the producer is the difference between one well-behaved reporter and three reporters that are each individually reasonable.
The failure mode is CLOUD-417's, measured: hook output at 20% of a long session's context. And it is the same trajectory stop-guard took — one rule, then five, each defensible in isolation, with the aggregate never costed. Setting the ceiling before the third producer arrives is cheaper than rationalizing it after.
Refinement — Ready
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- Source of truth (§1). One declared budget in
batten.tomlfor the advisory channel, superseding the per-producer key CLOUD-82 shipped. One authority per fact. - Computable predicate (§2). Producers are admitted to a single emission in
AdvisoryTierorder (severity as required response latency, CLOUD-80) until the channel budget is spent; the remainder is dropped, and the drop is counted rather than silent — a truncated report that reads as complete is the false green in advisory form. - Effect (§3). Unchanged.
- Output & exit (§5). Exit 0; pointer-only. The count of suppressed findings is itself a pointer and is the one addition.
- **Commit / bump (§6). **
feat→ patch until0.1.0.!if CLOUD-82's per-producer key is removed rather than deprecated. - Test obligation (§7). Three producers firing on one boundary emit one document within budget, ordered by tier, with a suppressed-count line. Mutation coverage per CLOUD-418: removing the budget comparison turns the truncation case red.
- Blockers (§8). None; the one blocker has landed. CLOUD-461 was its one blocker, because the channel had to exist before it could have a budget. CLOUD-461 is Done —
encode_claude_adviceand the one-document drain are onmain, and CLOUD-1051 has since moved the Stop surface onto the same channel — so the precondition is discharged and the relation is removed rather than left reading as live.relatedToCLOUD-82 (the per-producer budget this supersedes), CLOUD-80 (AdvisoryTier, the admission order), CLOUD-892 (the third producer this exists to bound before it arrives), CLOUD-417 (the measured 20% this is the forward-looking half of) and CLOUD-418.
Done
The advisory channel has one declared ceiling that every producer is admitted under, and what does not fit is counted rather than dropped silently.
CLOUD-417 Hook output is 20% of a long session's context, because "pointer, never payload" was never applied to the hooks themselves
Why
Non-negotiable 4 holds every check to "a count, path:line, or boolean — never the content itself." Each hook obeys it individually. Nobody has measured them in aggregate, over a session, where the same compliant line is emitted hundreds of times and every copy stays in context forever.
Measured on transcript 125cdf71 (2026-08-11/12, 758 turns, 5.83 MB):
| attachment kind | bytes |
|---|---|
hook_success |
1181 KB |
edited_text_file |
95 KB |
nested_memory |
88 KB |
hook_additional_context |
42 KB |
Hook output alone is 20% of the transcript. The single largest contributor is mcp-attach-check reporting "every enabled MCP server attached this session" on essentially every user turn — a true statement, correctly pointer-shaped, and identical every time. contract-drift is the second: it correctly reports a changed file set once per change-set, but a session editing the same two files repeatedly re-triggers it, and each report is re-read by every subsequent turn.
Why this is Batten's problem specifically. Context is the metered resource an agent pays on every turn (CLOUD-415), and these bytes are spent by the policy engine, on the agent's behalf, without the agent asking. A gate whose own reporting is a material fraction of the budget it protects is measuring the wrong thing. This repository is consumer #1 of its own engine, and this is what that self-application found.
Root cause. The output rule is stated per-check and enforced per-check. There is no rule about a check's output over a session, so a hook that is silent-by-default (correct) and one that confirms success every turn (also individually defensible) are indistinguishable to every gate that exists.
Refinement — Ready
- Source of truth (§1). The session transcript's
attachmentrecords, grouped by hook. Exact, on disk, no estimate. Same reading CLOUD-415 takes. - Mechanism (§3). Two parts, and the first is most of the win:
- Silence on success is the default. A hook that has nothing to report emits nothing.
contract-driftalready documents this posture — "Silence is the default; a change-set is reported once" — and it is the right one; the gap is that not every hook adopted it.mcp-attach-check's confirmation is the clearest case: it says the same thing every turn and nothing acts on it. - A repeat is a pointer to the first, not a copy. Where a hook must speak again, it emits a one-line reference rather than the full text — the same reason
contract-driftreports a change-set once rather than every turn.
- Silence on success is the default. A hook that has nothing to report emits nothing.
- The gate (§3, non-negotiable 2). A budget over hook output per session, in the grammar
policy-budgetalready defines for the instruction surface: a measured ceiling, derived rather than typed, that a hook exceeding it fails. Prose asking hooks to be quiet is exactly the feedforward this repo refuses. - Deliberately not in scope (§2). Removing any hook, or weakening what it detects. Every finding stays; only its restatement is bounded.
Test obligation
A suite over fixture transcripts: a hook that says the same thing N times is a violation, a hook silent on success is clean, and a hook reporting one change-set once is clean. Plus the property that makes this self-applying — the budget check's own output is one line.
Commit / bump (§6): fix(hooks) — patch until 0.1.0 regardless of type.
Blockers (§8): none. Sharper once CLOUD-415 makes session cost readable, since the same reading serves both.
Acceptance
- Hook output is a measured fraction of session context, reported rather than assumed.
- A hook that repeats an unchanged success message fails a gate.
- The measurement is re-runnable against any transcript, so the 20% figure can be checked rather than believed.
CLOUD-1189 Nothing checks that a remedy names a command that exists, so 31 redirect strings can point at a verb that was renamed away — the refusal contract's own defect class, one table over
Why
redirect.rs owns the per-path-class remedy table: "what to run instead, keyed by what is protected". Its strings are free-form prose, and nothing resolves the command they name against anything. batten.toml carries 31 of them.
CLOUD-122's refusal contract is that every deny names the fix. A deny naming a command that does not exist is worse than a deny naming none: it sends the reader to a shell error instead of a remedy, and it looks authoritative doing it.
This is exactly the defect class CLOUD-1050 recorded — one table over. There, msg was a String, so "a refusal naming no remedy, naming a task that does not exist, or offering an override with no precondition were all expressible and none checkable", and two were live in this repository when the row was written. [[verdict]] fixed it for verdict tokens by making the class a registry a gate can read. The redirect table has the same hole and no such registry.
Why now
A surface rename makes this acute rather than theoretical: any verb that moves turns every remedy naming it into a lie, silently, with no gate reporting it. But the hole is real today — a remedy could already name a command that never existed, and nothing would say so.
The gate is small, the object is a committed file, and it is independent of every other row in this closure.
A LIVE instance measured 2026-08-31, and §2 as written would not catch it
Recorded here rather than filed separately because this row owns the class. It is not a restatement — it is an instance whose object this row's predicate currently excludes, so the row's author has a decision to make rather than a duplicate to close.
mise-tasks/land.sh:1540 refuses a prose-only branch and tells the reader:
*"… or set *
BATTEN_PROSE_ONLY_OVERRIDE=1if the prose is the deliverable and cannot wait — that records which branch used it."
That variable does nothing. batten.toml:3807-3811, on the ported prose-only ***rule, states the opposite in as many words: "THE OVERRIDE IS DECLARED ON THE CLASS, not here. [[verdict]]'s ***V-PROSE-ONLY-DIFF ***carries an ***override ***route with its precondition … No ***bypass_env: the whole point of that row is that the bare variable stops working." CLOUD-1051 removed it deliberately; the message that advertises it was left standing.
So a reader who follows the refusal's own remedy sets a variable with no reader and gets refused again, with no indication why — the shape CLOUD-729 recorded once already ("a refusal advertises a remedy its reader cannot reach").
Why §2 misses it, stated precisely so the scope decision is informed:
| this row's §2 | the instance |
|---|---|
the object is a redirect string in batten.toml |
the object is a die message in a mise-tasks/*.sh program |
the named thing is a batten <…> invocation |
the named thing is an environment variable |
resolves against SURFACE or the [[rule]] table |
would resolve against the bypass_env fields — a third authority this row does not read |
All three differ, so widening is a real design change and not a one-line selector edit. The cheapest correct answer may be neither: land.sh is governed by shell-retirement, so that message cannot be edited at all — only retired — and the stale line therefore dies with land.sh's own retirement whether or not this row widens. Worth knowing before anyone scopes work here.
A second, sharper half, since it is what made the instance costly. The refusal did not arrive through land.sh at all. prose-only is also a tree-scoped [[rule]] run inside batten-check, so verify stopped with its generic line — "a gate refused this tree — read the step's own path:line pointers above" — and V-PROSE-ONLY-DIFF's declared route R-BATCH-IT ("let the next change to these files carry the prose") was never shown. It was found by reading batten.toml by hand. A verdict registry that a gate can read is only half of CLOUD-1050's fix; the other half is the route reaching the reader at the moment of refusal, and on this path it does not.
Refinement — Ready (a remedy resolves to a declared command or rule)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Authority boundary (§1). **
policy/(a new module) orcrates/batten/src/redirect.rsplus its[[rule]]and[[verdict]]rows inbatten.toml, andcrates/batten/tests/. **No **mise-tasks/**program and no **.batssuite —V-SHELL-RULE-ADDEDrefuses. - Computable predicate (§2). Every
batten <…>invocation appearing in aredirectstring resolves to either a declaredSURFACEpath or a declared[[rule]]id. A string naming neither is a finding. - **It must recognise both object shapes (§2). **
batten check <rule-id>resolves against the rule table;batten show configagainstSURFACE. A gate that only knew one would report the other as broken, which is the false positive that gets a gate switched off. - Deliberately not in scope (§2). Remedies that name a non-batten command (
mise run …,git …) — those are the operator's PATH, and resolving them would need a second authority over what is installed. Rewriting any existing remedy's wording. Whether a remedy is good advice, which is judgement and not gateable. - **Effect (§3). **
read. - Output and exit (§5). Pointer-only: the redirect row and the unresolvable command, never the remedy prose. Exit follows the
0/1/2/3table. - **A **
[[verdict]]row ships with it (§5), with routes: correct the remedy, or declare the command. A gate about unresolvable remedies that itself raised an unregistered token would be the joke writing itself. - **Commit / bump (§6). **
feat(policy)— patch. - Test obligation (§7). Module
test_rules plus a compiled-binary tier incrates/batten/tests/*.rs. Shown able to fail per CLOUD-418, four observed: (a) a remedy naming a removed verb is reported; (b) a remedy naming a declared rule id is not reported — the anti-vacuity mirror; (c) a remedy naming a non-batten command is not reported; (d) all 31 current strings pass, or each failure is recorded as a real finding rather than a reason to weaken the predicate. - Blockers (§8). None.
relatedToCLOUD-1050 (the same defect class in the verdict table), CLOUD-1184.
Acceptance
- A
redirectnaming abattencommand that is neither a declared path nor a declared rule id is a finding, naming the row and the command. - All 31 existing strings are resolved; anything that fails today is filed rather than papered over.
- The gate survives the surface rename by construction — it resolves against
SURFACE, so a renamed verb makes stale remedies fail immediately rather than silently.
Found while pressure-testing a surface rename: the largest silent hazard was not the code, it was 31 unvalidated strings telling people what to run instead.
CLOUD-1308 config-lint compares verdict classes by id, so any rename of an override-bearing class reads as a hatch newly added — a false positive CLOUD-1284 makes reachable 105 times
Why
trust.rs's verdict_override_entries collects the id of every [[verdict]] class declaring an override route, and weakenings reports the set difference as VerdictOverrideAdded. Its own doc comment says why the id is the object: "a class either offers a hatch or it does not, and which precondition it states is the judgement this module refuses to make."
That is correct for an edit and wrong for a rename. Base and working sets are keyed by id, so a class renamed from A to B leaves A absent and B present, and B is reported as a hatch newly added. The removal side is deliberately not reported (deleting a class is fail-closed — a module raising an undeclared token fails to load), so nothing in the comparison cancels it out.
Measured, on the branch that makes it reachable
CLOUD-1284 renames every class in the registry, so this fires once per override-bearing class in one commit. On claude/refusal-surface-bundle-5l3ovp against origin/main:
batten.toml:verdict[diff ship early].override verdict-override-added
batten.toml:verdict[issue file same].override verdict-override-added
Both are pure renames — V-PROSE-ONLY-DIFF and V-FILED-OVER-OWN-DIFF. Route, precondition and gloss are byte-identical to main's; only the id changed. Two of 105 consumer rows carry an override today, so the blast radius is small now and is a property of that ratio rather than of the mechanism.
Why it matters more than two lines of noise
The admission mechanism worked exactly as designed — a Weakens: trailer plus a groomed clause on CLOUD-1284, admitted groomed. But what got admitted is a weakening that did not happen. Every future reader of that receipt and that trailer sees this branch declaring it added two hatches. The record is now wrong in the durable direction, and the correct remedy was spent to make it so.
It also means the gate cannot distinguish the case it exists for. A branch that renamed a class and added a real override to it would produce one indistinguishable line, and the honest admission for the rename would cover the hatch.
Refinement — Ready (a rename is not a hatch)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Source of truth (§1). **
crates/batten/src/trust.rs—verdict_override_entries(the projection) andweakenings'VerdictOverrideAddedblock.crates/batten/tests/it/config_trust.rsfor the tier. - Computable predicate (§2). A class present in the working set and absent from the base set is
VerdictOverrideAddedonly where no base class resolves to it as a rename. What makes a rename resolvable is the open question below, and it is the whole of the work. - **The candidate mechanism, and its cost stated up front (§2). **
DeclaredVerdictalready carriessuccessorandwithdrawn— a tombstone chainverdict::validaterequires to terminate. A rename that leaves the old id as a tombstone naming its successor makes the comparison resolvable with no new field. CLOUD-1284 deleted the old ids outright instead, so it would have owed ~105 tombstone rows; whether that is the right price for a one-time rename is this row's decision and not a foregone conclusion. - Do NOT report the removal side to cancel it (§2). That was considered and loses: deletion is fail-closed today (a module raising an undeclared token fails the load), so reporting it would add a second finding per rename rather than removing one, and it would report on every legitimate class retirement.
- **Effect (§3). **
read. - Output and exit (§5). Unchanged — the same pointer line and the same admission path.
- **Commit / bump (§6). **
fix(trust)— patch. - Test obligation (§7). The discriminator is the pair, and the second half is what keeps the fix from becoming a blanket allow: a class renamed with its override unchanged is not reported (red today), and a class renamed that also gained an override is still reported. Plus the anti-vacuity mirror
config_trust.rsalready carries — a class that gained a hatch without a rename is reported, and an unchanged table says nothing. - Blockers (§8). None. It is decidable from the two configs alone.
Acceptance
- A pure rename of an override-bearing class produces no
verdict-override-added. - A rename that also opens a hatch still does.
- The two lines this branch admitted would not have needed admitting.
Provenance. Found by spending the admission on it: the trailer and the groomed clause on CLOUD-1284 are the durable record of a weakening that did not occur, and writing that clause honestly is what made the defect legible.
|
Warning Review limit reachedNext included review available in 4 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 ignored due to path filters (2)
📒 Files selected for processing (90)
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: ⛔ Files ignored due to path filters (3)
📒 Files selected for processing (140)
🚧 Files skipped from review as they are similar to previous changes (138)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughThe change replaces legacy Merge Risk: ⚪ Minimal · up to The current change is merge-ready after normal checks and review; no actionable merge-blocking risk remains. 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 Essentials by visiting https://app.coderabbit.ai/settings/billing. Comment |
SonarCloud: analysis failed on three consecutive heads, and it is not this PR's
It is an analyser-side failure, not a verdict about this diff. "The last It is outside this repository's control by construction. There is no It is not in the landing path. No re-run spent. A re-run is for confirming a failure that is not this PR's, If somebody wants this chased, the probe that actually distinguishes the two Local verification for these heads is in the PR body: Generated by Claude Code |
… needs CLOUD-1284 replaces free-text `V-SCREAMING-KEBAB` class names with a three-word positional grammar drawn from declared vocabulary lists. Its arm 4 -- every vocabulary word is ONE token under a pinned tokenizer -- is what makes that convention enforceable rather than aspirational, and it is the row's one new dependency. It is also the arm the other five depend on, because the vocabulary cannot be curated without the measurement. This lands the measurement and its gate. It does not land the grammar. The tokenizer is a DEV-dependency, and that placement is the whole of why it is affordable. `budget.rs` estimates bytes/4 on purpose -- "an exact count needs a tokenizer, a vocabulary and a network fetch" -- and `batten hook` runs on every mediated tool call under CLOUD-689's budget, so a tokenizer in the shipped binary would put an embedded merge table on the config-load path and contradict both. In the test binary it contradicts neither: the vocabulary is a committed table, so its token counts are a property of the commit. `tiktoken-rs` vendors `o200k_base.tiktoken` in its own assets, so the gate is offline -- no network, and none of the "fails because a download failed" shape `budget.rs` argues against. MEASURED, 250 candidates: 237 are one token, 13 are not, and the 13 are a class rather than a scatter. `unparsed`, `unwired`, `untested`, `ungated`, `unbound`, `orphaned`, `shadowed`, `unclean`, `unsaid` and `untold` cost 2; `uncounted` costs 3; `mcp` and `rebase` cost 2. The commoner `unread`, `undefined`, `unknown`, `unnamed`, `unused`, `unmet` and `unseen` survive at 1. That reproduces the row's own worked example as a gate: it warns that `shell edit unretired` is 5 tokens against `task spelling weakened`'s 3, and this is what tells an author which of the two they just wrote. WHAT IS DELIBERATELY NOT HERE. The `[vocabulary]` table, the six load-time arms over it, and the rename of 130 classes and 124 route ids across 824 call sites. That conversion is all-or-nothing by construction -- `policy::check_registry_is_exhausted` refuses a declared-but-unraised token and `check_verdicts_are_declared` refuses a raised-but-undeclared one, so a half-renamed registry does not load at all -- and shipping half of it would leave a tree that cannot read its own config. The gate here is honest about covering the dictionary and not the grammar, and its doc comment says so rather than reading as the whole row. Refs: CLOUD-1284
…grammar CLOUD-1284. A class name was free text in `V-SCREAMING-KEBAB`, close to the worst case for a BPE vocabulary trained on running text -- uppercase runs and hyphen-joined compounds are rare there, so the merges are long. It also explained nothing on its own, which is WHY every class needed its own essay to gloss its own name. Both halves are data now. A name is exactly three space-separated words on a positional grammar, `<subject> <action> <condition>`, each drawn from a declared list in `batten.toml`'s new `[vocabulary]` table, and every word carries one gloss that every name spending it reuses. The marginal class costs no new prose; position carries the rest, because slot 2 is always what happened. CONVERTED: 105 consumer classes, 124 consumer routes, 7 `Native` variants, 18 vendored preset classes and 30 vendored routes -- 252 names over 824 call sites. `VERDICT_PREFIX` and `ROUTE_PREFIX` are deleted: the prefix existed to make a token recognisable beside a rule id and a path, and FIXED ARITY does that for free. Three words, then pointers, nothing between them. ARITY IS EXACTLY THREE AND MUST NOT BECOME A MAXIMUM. It is what lets a rendered line parse with no delimiter; a maximum puts the delimiter back and dissolves the grammar into the free text it replaced. `V-PRIVILEGED-LANE-UNTESTED-ORIGIN` carried five concepts and is `lane guard missing` -- split, per the row's own instruction, rather than granted a fourth slot. THE SIX ARMS. Arity, per-slot membership, uniqueness of the triple (the duplicate-id refusal unchanged -- under this grammar the id IS the triple), single-tokenness, no orphan vocabulary, every word glossed. Five refuse at load in `verdict::validate`. The sixth cannot: it needs a tokenizer and the shipped binary must not link one, so it is the dev-dependency gate from the previous commit, now reading the declared table with a drift test holding the two in agreement in BOTH directions. THE GRAMMAR IS OPT-IN, which is `[[pattern]]`'s preset exemption one table over. A consumer declaring no `[vocabulary]` has no lists for a name to be drawn from, so holding them to membership would be a demand with no fix available short of authoring 134 words -- the wrongly-refusing gate AGENTS.md calls a defect. Declaring the table opts in and is all-or-nothing from there, so the exemption cannot be spent as a partial adoption. It is also what lets the fixture corpus load unchanged instead of every fixture growing a table. SO DELETING THE TABLE IS A WEAKENING: `trust.rs` gains `VocabularyAbandoned` and its `CENSUS` row, because dropping it turns arms 1, 2 and 5 off with the config still loading clean. SHRINKING the lists is deliberately not reported -- a word a name still spends fails the load on its own, so the whole table going away is the only silent direction. MEASURED, and this is CLOUD-418's anti-vacuity case: the converted registry LOADS. Arity, membership and the orphan arm all hold over all 105 consumer classes and 124 routes, so no arm refuses everything. Route names cost less than feared -- 15 distinct names cover all 154 routes, derived from each route's kind and target, because a route id need only be unique within its own verdict. ALSO REPAIRED, because it blocked this rather than being adjacent to it: `mise run snapshots` could not run at all since the target consolidation (CLOUD-1210). The corpus moved to `tests/it/snapshots` and `--test snapshots` names a target that no longer exists, so `find` failed on a missing directory and the accept half never reached cargo. The module filter keeps the bound its own note argues for -- `--test it` unfiltered would run the suite under `cargo test`'s threads-in-one-process model, which `document_read_count` cannot survive. Refs: CLOUD-1284 Admits: e48c57d974996c843a78471d5986eab38e3107adf53d67a535a93767f62a938a Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 31bb1d1 Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: 1d63eab531e6400bc02824f92daa7ba8329b98501bc7d641ecfc950eb176d1b2 Admits-answer-lost: The row cannot land at all. The grammar is opt-in on a declared `[vocabulary]`, and both the declaration and every name it governs live here, so refusing this write refuses the whole conversion rather than deferring part of it. Admits-answer-precondition: CLOUD-1284's whole deliverable IS this file: the `[vocabulary]` table it declares and the 105 class and 124 route names it renames are config, so batten.toml is both the surface that owns the change and the path the gate protects. There is no other surface that can express a rename of the registry's own names. The write is the diff a reviewer reads. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which is the file being refused, so the remedy it names is the thing denied. `patch run first` is `git restore`, which discards the conversion rather than expressing it. Admits: 2bf15fc7601f5ee1f48807fd3bf445dd25db90c6fc742c8cac2947ab2e910654 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .github/workflows/auto-release-land.yml Admits-head: 31bb1d1 Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: A stale class name survives in CI's own definition of green, citing a token `batten policy explain` can no longer resolve. The conversion would be complete everywhere except the one file a reviewer reads to learn what CI does. Admits-answer-precondition: The workflow is touched only because it quoted a verdict token in prose, and CLOUD-1284 renames every one of them. Leaving it would strand a name no registry declares in a file a reader trusts, which is the drift the rename exists to remove. No surface other than the workflow can carry its own text. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which is not this file and cannot carry its text. `patch run first` is `git restore`, which would put the stale token back.
CLOUD-1088. `shell add refused` declared two routes, and the second -- "declare that it stays bash" -- DID NOT CLEAR THE VERDICT THAT OFFERED IT. Measured 2026-08-28: prepending `# stays-bash: <issue> <why>` to `tests/wiring-reclaim.bats` and re-running `batten check --rule shell-retirement` left the finding byte-identical. The reason was structural rather than a typo. `admits_with = "# stays-bash:"` belongs to `bash-surface-not-growing`, whose glob is `mise-tasks/**`, so it never reaches `tests/**`; and the added arm carried no admission clause at all. So on a bats suite the named remedy was unreachable twice over, and on a `mise-tasks/` path it cleared a DIFFERENT rule while leaving this one standing. It mattered because two landed policies MANDATE what this refused. `.claude/rules/policy-modules.md` requires a door migration's second tier over the compiled binary, and CLOUD-312's per-row obligation says the same for a handler destination -- so a migration was required to add a suite this arm refused, with a route that could not clear it. The added arm now honours the declaration on the ADDED PATH ITSELF, so the route works on every surface the class is raised over. The token stays `bash-surface-not-growing`'s: one spelling for one concept, so an author who learned it once carries it to either surface. `line_sources` GAINS `tests/**/*.bats`, and that is not tidiness. The clause reads `input.tree.lines[path]`, which is exactly that list -- without the glob it would evaluate over a key nothing fills, never hold, and ship as a dead gate inside the fix for a dead route. That is CLOUD-845's class arriving in the repair for its own sibling. THE RATCHET IS NOT WEAKENED, and that bound is why this is the ADDED arm only. `shell edit refused` is untouched -- one route, no override, no `bypass_env` -- because an edit is the move that reads as progress and is not. Five cases hold the line: three load-time (a bats suite admitted, a shell program admitted, and an EDIT carrying the same declaration still refused) and two over the compiled binary. The compiled pair is the one that matters, because only it proves the ENGINE acquires the lines; a `with input as` case fabricates the very shape the engine may be unable to produce. THE SECOND ACCEPTANCE CLAUSE HAS ITS PREMISE REMOVED RATHER THAN LEFT UNDONE. The row also asks for a load-time refusal of a route whose mechanism cannot clear its own verdict. After this change no route in this tree depends on another rule's `admits_with` -- the admission is the arm's own -- so that check would ship with zero subjects, which is the dead gate this repository refuses. Recorded here rather than built. ALSO IN THIS COMMIT, all of it CLOUD-1284 fallout the narrower per-gate tasks could not see and only `test:cargo` did: * `common::tokens_in` still filtered raised tokens on a `V-` prefix, so every fixture-derived registry came back empty and 40 `shell_retirement` cases went red over a module that was fine. Rebound to the ARITY, which keeps the bound its own doc claims: a `test_` rule's fixture input is still excluded rather than declared as dead vocabulary. * 128 fixture tokens across 36 files were still spelled `V-…` in raising position, so the load refused them as undeclared. * `WeakeningKind::VocabularyAbandoned` was in the enum and its census row but missing from `ALL`, and then had no case exercising it -- two separate derived tests reading `trust.rs`'s own source, each catching its own half. * The derived man pages needed regenerating. Refs: CLOUD-1088, CLOUD-1284 Admits: 12b909c4c089a11ecff9dd8bfcd160110544c0aede93725ae06be1a101d22e98 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-head: bc83689 Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: A declared route stays unreachable and two landed policies keep mandating what a third refuses: a door migration is required to add a compiled-binary tier, and this arm refuses adding one, with a remedy that clears a different rule. Admits-answer-precondition: CLOUD-1088's whole subject is this module's `added` arm: it raises `shell add refused` while carrying no admission clause, so the route that class offers cannot clear it. The predicate lives here and nowhere else, so the module is both the surface that owns the fix and the path the gate protects. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which declares the class but not the predicate, so it cannot express the arm. `patch run first` is `git restore`, which puts the defect back. Admits: a3f9361f7cf025ec28f1e69aedff0fd71f1f0fcf8a2551dd51f6b19bbd25bee9 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: bc83689 Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: e48c57d974996c843a78471d5986eab38e3107adf53d67a535a93767f62a938a Admits-answer-lost: The fix would ship as a dead gate. The arm would read `input.tree.lines` for a bats path that `line_sources` never acquires, so the declaration would still not clear the verdict and the module would report clean while deciding nothing. Admits-answer-precondition: CLOUD-1088 needs two things only batten.toml can carry: `shell-retirement`'s `line_sources` must reach `tests/**/*.bats`, or the admission clause is dead for exactly the paths the row is about, and `shell add refused`'s class text must stop naming a mechanism that clears a different rule. Both are config; no other surface expresses either. Admits-answer-rejected-route: `config read first` resolves to batten.toml, the file being refused, so the remedy it names is the thing denied. `patch run first` is `git restore`, which restores the unreachable route. Admits: ff78fde0aff5605b167d7b948c1afa9cbfd787a498aab61a5906c132f5ce4195 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: bc83689 Admits-epoch: 111df297017faa5ba1aa29924118fcb88b4c724593f547811c7359504c1532b9 Admits-author: alec@wenzowski.com Admits-prev: a3f9361f7cf025ec28f1e69aedff0fd71f1f0fcf8a2551dd51f6b19bbd25bee9 Admits-answer-lost: The class would keep telling its reader to reach a mechanism that does not reach them, which is the defect CLOUD-1088 exists to close rather than a wording preference. Admits-answer-precondition: `shell add refused`'s class text names `bash-surface-not-growing`'s ratchet as the mechanism that admits a new file, and CLOUD-1088 measured that this is false for every `tests/**` path the class is also raised over. The class prose is config and lives only here, so batten.toml is both the surface that owns the correction and the path the gate protects. Admits-answer-rejected-route: `config read first` resolves to batten.toml, the file being refused. `patch run first` is `git restore`, which puts the false claim back. Admits: 6329c43861dbcb577c8a2670ed901d4bbdd7fc49c9d95f697bf8f6e05c7d7186 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-head: bc83689 Admits-epoch: 30c658dacad677461143c9255951811cccc1553f78e9d0ef92621e6be7d2896b Admits-author: alec@wenzowski.com Admits-prev: 12b909c4c089a11ecff9dd8bfcd160110544c0aede93725ae06be1a101d22e98 Admits-answer-lost: The fix would ship with no case proving the declaration now clears the verdict, and none proving it still does not admit an EDIT. Without the second the first would pass over a blanket allow, which is the direction that would weaken the ratchet. Admits-answer-precondition: The admission clause added to this module's `added` arm owes its own load-time cases, and a module's `test_` rules live in the module. CLOUD-418 requires the pair be shown able to discriminate, so the cases and the predicate are one artifact and cannot be written anywhere else. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which declares the class but holds no `test_` rule. `patch run first` is `git restore`, which discards the cases. Admits: 4577cb40b9de39a019a72f8e5ea304af64304075e1c44540c6bfa0c4fe0f9bf1 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/verdict-routes-resolve.rego Admits-head: bc83689 Admits-epoch: 9b266c23073f4740b6af63363c75c697adbe1d280ef7054617fc30109667710a Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The module's own load-time tier goes red on fixtures rather than on the predicate, which is the tier reporting about itself instead of about the rule. Admits-answer-precondition: This module's `test_` rules construct fixture registries whose tokens were spelled in the retired `V-` shape. CLOUD-1284 removed that shape, and the harness that derives a fixture registry now recognises a raised class by its arity, so a fixture token left in the old spelling is no longer declared and its own case fails to load. The cases live in the module, so the module is the only surface that can carry the correction. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which declares no `test_` rule. `patch run first` is `git restore`, which puts back tokens the registry no longer declares.
Eight of the ten refusal composers in `hook.rs` called `Refusal::new`, which leaves `verdict: None`. The prose they emitted was a hardcoded `format!` at the boundary, so a class a reader could look up did not exist for any of them and `batten policy explain` could not answer over the mediated path at all. The cause prose is not config — a `[[rule]]` row's `reason` is the *Fix* half — so this lands as new `Native` variants rather than as config-row edits. The wildcard-free `every_native_class_is_listed` match makes the exhaustiveness compile-checked, and each variant's VENDORED entry lands in the same commit, because a row nothing raises fails the load and so does a token no row declares. Converted, reusing `Refusal::declared` rather than a third constructor: `receipt_refusal`, `substitution_refusal`, `pipeline_refusal`, `ceiling_refusal`, `shape_refusal`, `content_refusal`, `unkeyed_refusal`, and `policy_refusal` — the half-converted one, which already rendered `render_line`'s output as its reason while still reporting `verdict()` as `None`. Three distinctions the suite proved the conversion had to keep rather than collapse, each now carried as a declared subject or as its own class: - the receipt KEYING (`branch` / `row` / `commit`), because "no receipt for this commit" sends a reader looking for a per-commit step when what is missing is a claim the whole branch shares; - the expiry BOUND, on the expired class only — `300s` is the difference between "run it again" and a row nobody can satisfy; - `StaleHead` and `StaleMain`, which are an amend/rebase replacing the validated bytes and a branch that has moved off trunk. Collapsing them lost the one word that says which. `policy_url` rides the deny wherever a row declares one, via a shared subject helper, so no composer drops it on the way through. Refs: CLOUD-1285
`render_line` shipped the one-line format in its own doc comment and then three things put the paragraph back: it emitted `(gloss)` unconditionally, `Refusal::render` wrapped the result in `Refused by <rule>: … Fix: ….`, and the hook appended an identical bypass sentence on every deny. So even a refusal already reduced to one line was padded back out, and the padding was paid for on every one of the ~300 firings a long session produces. Measured live at 79b8bfd: what fires today is 88 words / ~115 tokens, which over ~300 firings is ~34,500 tokens against a ~175k window. That is 20%, and it is CLOUD-417's headline figure arrived at independently from the other direction. The gloss alone was ~28 of `render_line`'s ~43. The emitted line is now `<class> <pointer…> <rule>`. Dropping the gloss is only safe because the class is a declared three-word name (CLOUD-1284); under the old SCREAMING-KEBAB free text this would have traded concision for opacity. THE DECISION THIS ROW OWED IN WRITING, against CLOUD-122's landed contract: the token IS the pointer to the fix, one hop via `batten policy explain`, and `README.md` is amended to say so. The reason and the remedy do not vary between firings; the pointer does. That is the whole test applied throughout — what repeats moves behind the dereference, what changes stays inline. So the hop had to actually reach everything that left, and three things did not have a lookup before this commit: - a `[[rule]]` row's own `reason` — `explain` now resolves a rule id as well as a class, the two namespaces being unconfusable (three lowercase words versus a kebab identifier); - a declared fact's COMMAND, which CLOUD-776's loop depends on being byte-identical to what the record is verified against — `explain` prints a row's fact commands; - the per-path-class `[[redirect]]` mutation (CLOUD-280), which belongs to a glob rather than to a class or a rule, so the derived gate's own id resolves to that table and the `[[verb]]` fallback under it. The rule id stays on the line rather than moving: two rows can raise one class, and `explain` answers about the class, so without it a reader could not find the config line that refused them. What goes is the `Refused by <rule>:` framing. CLOUD-437 is closed rather than narrowed. The hatch sentence was byte-identical on every firing of every row — pure per-firing cost carrying no per-firing information — and naming the wrong variable was the visible symptom of printing it at all. The hatch still works, and the case that proves each row's own variable suppresses its own deny is untouched. THE GATE is a declared `[refusal] max_tokens` in `batten.toml`, never a literal in the crate, copying `[budget.instructions]`'s shape; a zero ceiling is refused at load, and raising or deleting it is a `refusal-ceiling-raised` weakening. `crates/batten/tests/it/refusal_ceiling.rs` measures every refusal this tree can emit against it over the compiled binary — the anti-vacuity half, and the load-bearing one. `GLOSS_MAX` stays: it bounds `explain`'s first line now, and it is what stops the gloss growing back into a paragraph once nothing on the hot path reads it. The 6,640 words of `class` prose are untouched. Placement was the defect. Weakens: `[refusal]` is a new ceiling rather than a relaxed one, and `config-lint` compares it against a base that declares none. Admits: c561ee2b9fea964bceb46a3d0caa983f67d9bb3d8242635ff8cf944b24e578b5 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: bb12099 Admits-epoch: 9b266c23073f4740b6af63363c75c697adbe1d280ef7054617fc30109667710a Admits-author: alec@wenzowski.com Admits-prev: ff78fde0aff5605b167d7b948c1afa9cbfd787a498aab61a5906c132f5ce4195 Admits-answer-lost: The row's own mechanism. Without the declared table the ceiling would have to be a constant in `crates/batten`, which is this repository's judgement compiled into every consumer's engine and unmovable without a release — the defect non-negotiable rule 1 forbids. The alternative is landing prose with no runnable gate, which is half a change under non-negotiable rule 2. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table: CLOUD-1286 requires the emitted-line ceiling be declared config and never a literal in the crate, so there is no non-protected path that carries it. The write is one a reviewer sees in the diff it lands in — it is a 20-line addition of `[refusal] max_tokens = 24` with its reasoning inline, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the table this sits beside (`[budget.instructions]`) and copied its shape deliberately; reading further does not produce a route that writes the key. `patch run first` does not apply either: there is no patch surface that can add a new top-level table to the policy authority — a patch is still a write to `batten.toml`, so it reaches the same class one indirection later. Refs: CLOUD-1286
A newline is whitespace to `segments`, so a call written across lines was ONE segment and `effective_program` resolved the first line's program for every operand on every line. Measured over the shipped binary, one protected path and the same read twice: `stat -c %s batten.toml` allowed, and the identical `stat` written on line two after `cd /tmp` REFUSED, naming `cd`. So a declared `protected_readers` entry was unreachable from any script, which is the surface `protected_readers` exists for — and the failure is an OVER-deny, on a read, which is the direction that gets a guard switched off rather than the sanctioned one. NARROW ON PURPOSE. Segment identity is untouched: promoting a newline in `segments` would move every landed `pipeline` verdict, since `terminator` is what those rows are decided by. Only the two walks that ask "which program was handed this operand" — the mutation walk and CLOUD-1141's unknown-program walk — read line boundaries, because that is a question a line answers and a segment does not. `line_bounded_words` re-enters `segments` per line rather than splitting the string itself: a second tokenizer is a second AUTHORITY (CLOUD-857) and would disagree with the one `shape` and `pipeline` rows are decided by. Re-entering is safe because a segment's `raw` carries no separator by construction, and heredoc bodies are already gone from it (CLOUD-723), so a `rm` inside a commit message does not become a line of its own. The single-line case returns before any of it. Four cases over the compiled binary and the committed table: the measured pair, a genuine mutation on a later line still refused, an unknown program on a later line still refused — the two discriminators that keep the fix from being a blanket allow — and the bound itself, a discard shape written across lines still judged as one segment. `.claude/rules/policy-modules.md`'s "it under-denies, which is the sanctioned direction" is corrected against the measurement, per the row's acceptance: the prose and the parser must not disagree about which way the bound errs. Refs: CLOUD-1287
…top asserting tracked-ness Two independent defects in one row, reproduced twice on 2026-08-28 from a scratch directory outside the repository: cat err.txt -> refused, "a path in this repository" cat <abs>/err.txt -> the identical file, allowed ONE: `repo_relative_path` is purely lexical. It asks whether a token is SHAPED like a relative path and calls that "inside the repository", so every path the guard judged was judged against a root the caller may not have been in — the corpus it thought it was protecting and the one it was reading were different sets. `Envelope::cwd` has been decoded since CLOUD-202 and was read by nothing. `names_a_repository_path` joins the operand to it and asks `relative_to`, the same containment primitive `protects` already uses, so the two readers cannot disagree the way two resolvers would (CLOUD-824's class). No stat, no spawn: decidable from the payload. The bound: an ABSOLUTE operand stays excluded rather than resolved and refused. Resolving both spellings would be tidier and would WIDEN what is refused, and this change only ever narrows, so no call allowed today starts failing. An unknown cwd keeps the lexical reading rather than switching clause 3 off for that host — a gate that found nothing must not look like a gate that passed. TWO: the verdict prose asserted the repository TRACKS the path, and nothing ever asked git. A `git ls-files` per mediated call is a spawn `RuleKind::scopes` forbids on this kind and `perf-assert` prices out, so the fix is to stop claiming it rather than to check it. The class and the row's remedy now say CONTAINMENT, which is what the predicate decides. A class a reader believes is worse than one they cannot look up. Three cases over the compiled binary: the pair that is the whole defect, red against the unfixed binary; a relative path naming a file the repository DOES contain, from a subdirectory, still refused — the discriminator that keeps the fix from being a blanket allow; and a verdict-text assertion that no tracked-ness claim survives. Two stale doc claims corrected in passing, both asserting "`Envelope` carries no `cwd`" while the field sat decoded three lines away. A stale reason is worse than none: the next author reads it as a constraint and designs around a field that was there all along. Admits: 9eddfef5a9c49d424ff3fcc8688776db2d781a9a69f76c5bab60f127f3ae28da Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 89b5336 Admits-epoch: a98253f1c6f8b3e2ed67efebd8cd04d45e890cc1eb144a813bc8037049cb831c Admits-author: alec@wenzowski.com Admits-prev: c561ee2b9fea964bceb46a3d0caa983f67d9bb3d8242635ff8cf944b24e578b5 Admits-answer-lost: The second half of CLOUD-1109. The engine half lands either way, but the refusal would keep telling a reader the repository TRACKS a file nothing asked git about — and `.claude/rules/policy-modules.md` makes a verdict a thing a reader looks up, so a class whose prose asserts an unchecked fact about its subject is worse than an unnameable one, because the reader believes it. Fixing only the resolution leaves exactly that. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the object IS a string inside `batten.toml`: the `no-tool-substitution` row's `reason` asserts "a path this repository tracks", a fact CLOUD-1109 establishes nothing ever checked. The remedy prose is consumer config by design (non-negotiable rule 1), so there is no non-protected path that carries it. The write is one word, `tracks` to `contains`, in a diff a reviewer sees on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the row, and reading it is what found the claim — reading further produces no route that corrects the string. `patch run first` does not apply either: a patch to `batten.toml` is still a write to `batten.toml`, so it reaches this same class one indirection later. Refs: CLOUD-1109
`protected` is matched with `literal_separator(true)`, so `dir/**` requires at least one component after the separator and `dir` is not a member of it. Every mutating verb aimed at a guarded DIRECTORY was allowed while the same verb naming a file inside it denied: cp /tmp/draft.md .serena/memories/ # allowed mv /tmp/draft.md .serena/memories/ # allowed A fidelity loss from the CLOUD-312 port rather than a gate designed without it: the retiring `memory-guard-check` matched the guarded path as a SUBSTRING and caught the directory form, and the engine's glob matcher does not. `.../**` is the shape consumer #1 declares and the shape the schema's own examples encourage, so this is the ordinary spelling. Question 1 is answered (1)+(2). Normalisation alone provably does not close it — `dir` is not inside `dir/**` either — so `normalise` strips a trailing separator AND `protects` asks containment. Pushing it to the consumer is declined: the fact is about the MATCHER, so requiring every consumer to declare both `dir/**` and `dir` would make their config carry a subtlety that is Batten's, and anyone writing only the documented shape stays exposed without ever learning why. Question 2 is answered no, by evidence: a redirect to a directory is a shell error, so `> dir/` cannot reach a working command and the fix stays inside `protected_mutation`'s operand path. `PathSet::encloses` is a NAMED method and `contains` is untouched. `scope` and `unlanded` are answers about files, and a rule selecting a directory would select nothing to inspect — so a quiet widening would change two callers that never asked this question and would look like nothing in the diff. A unit case asserts membership directly for exactly that reason. It decides over the declared PATTERNS rather than a probe path: a synthetic path answers for `dir/**` and gets `dir/*.md` wrong, and any sentinel component can collide with an exclude. An ancestor encloses too, so `rm -rf .serena` refuses. That is the predicate working rather than overreaching — a gate gating the smaller blast radius and not the larger one would be the wrong way round. Also fixed here, because review of the previous commit found it before the field did: CLOUD-1287's line split opened a BYPASS on a backslash continuation. `rm \` with the path on the next line is one command to bash; split naively it hands line one an `rm` with no operands and line two an operand with no program, so the protected path was judged by nothing. `joined_lines` rejoins a continuation, counting trailing backslashes so `rm a\\` — an escaped backslash, a complete command — does not swallow the line after it. BREAKING CHANGE: the library API moves, and `mise run semver` names four lints rather than one, so this declares the break for the branch rather than for this commit alone: `Config` gained a `refusal` field (constructible_struct_adds_field, CLOUD-1286), `Native` and `WeakeningKind` gained variants (enum_no_repr_variant_discriminant_changed, CLOUD-1285/1286), `verdict::validate` takes the declared vocabulary (function_parameter_count_changed, CLOUD-1284), and `VERDICT_PREFIX` / `ROUTE_PREFIX` are gone (pub_module_level_const_missing, CLOUD-1284). No consumer-facing exit code or output shape moves, and release-plz bumps the patch below 0.1.0 whatever the type says; the declaration is what keeps the gate honest rather than a version claim. Refs: CLOUD-609
`no-tool-substitution` is `kind = "pipeline"` and decides over shell argv, so a structured-tool call is STRUCTURALLY invisible to it. `.serena/memories/**` is in `protected`, but crossed with `[[verb]]`, which enumerates mutations — CLOUD-442's port says "reads stay allowed" in as many words, which was right for the question that row answered. So nothing gated a generic file read of a memory, measured 2026-08-31 with the Serena server healthy and `read_memory` loadable. CLOUD-185 closed the write half of this shape and CLOUD-864 the shell half. This is the third face of the same object. It is a defect rather than a style preference because a path read couples the caller to the tree layout, and CLOUD-868 proposes moving that tree into a private repository: `read_memory` survives the move and a hardcoded path does not. Every flat-path read is an unmigrated call site, and this makes them unwritable now rather than discoverable after the move breaks them. A matcher change rather than new vocabulary, as the row says: `[[redirect]]` gains an optional `read`, and `Envelope` gains `reads` — the mirror of `writes`, from the same host keys, keyed on the neutral `Operation::Read` rather than on a second per-host tool list, because two lists of one host fact is how the two come to disagree. Both targets relativise in the one place `writes` already did. THE "SESSION DOES NOT OFFER IT" ARM IS THE ABSENT KEY, and that is where the question is decidable. The boundary cannot see whether an MCP server is healthy — no mediated payload carries that — so a consumer without the tool declares no `read` and is refused nothing. The remedy is therefore a string its own author wrote, which rules out CLOUD-998's defect by construction rather than by care. An empty `read` is refused at load, so absent and empty stay different statements. `protected` is deliberately not consulted: "which instrument answers this path" and "is this path guarded" are two sets, and deriving one from the other is the collapse CLOUD-37 exists to prevent. Four cases over the compiled binary. The deny, and that the hop reaches `read_memory` rather than the mutation tools. The load-bearing half — ordinary reads of `hook.rs`, `batten.toml` and a policy module are untouched, because this fires on a READ and a noisy deny stops the fleet. The not-offered arm, using a protected class that declares a mutation and no read. And that a write to a memory is still refused as a write, since the read row rides the same table. Firing rate, stated rather than claimed: over this session's own reads the gate fires zero times on correct behaviour and once on the measured defect. A CCR container writes no transcript, so a fleet-wide replay is not available here and is not asserted. Pointer-only: the path and the class, never a byte of the memory — which for this subject is exactly what a read gate must not become a mirror of. Admits: 3f4e20df269636ca67ea65563d4b815f68de94b5fd2e1112d920da95cae29296 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 953163e Admits-epoch: a362e21e7adbab4bc6adb06baaab7411cd739953cbe330a8cf7838b1a0b8ecaf Admits-author: alec@wenzowski.com Admits-prev: 9eddfef5a9c49d424ff3fcc8688776db2d781a9a69f76c5bab60f127f3ae28da Admits-answer-lost: The row entirely. CLOUD-1258's whole mechanism is a consumer-declared read remedy: with no row, `resolve_read` returns `None` for every path and the gate refuses nothing, which is the state the row was filed about. The alternative is landing engine code with no configuration that reaches it — a gate that cannot fire, which reads as coverage and is the defect `.claude/rules/policy-modules.md` opens with. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table row: CLOUD-1258's read-side gate is declared as a `[[redirect]]` row's `read` key, and the tool it names is the consumer's fact (non-negotiable rule 1) — an engine literal naming `read_memory` would be exactly what that rule forbids. There is no non-protected path that carries it. The write is a four-line addition a reviewer sees in the diff it lands in, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the `[[redirect]]` table and the `[[verb]]` rows beside it, and reading is what established that no row speaks for `.serena/memories/**` today — reading further produces no route that adds one. `patch run first` does not apply either: a patch to `batten.toml` is still a write to `batten.toml`, reaching this same class one indirection later. Refs: CLOUD-1258
Six hand-written blocks refused six columns on a `mediated_call` row, each as its own `if self.scope == MediatedCall && !self.<field>.is_empty()`. The second one's comment recorded how the list grew: "`sources` and `lines` are tree columns for the same reason `documents` is, and they were added to `permits` without this — so a mediated-call row could declare either and have it silently never read." So the list was ALREADY KNOWN to lag the struct, and it still did — by fourteen columns. `git`, `refs`, `ranges`, `commits`, `staged`, `history`, `state`, `forge`, `tools`, `captured`, `landing`, `delta_sources`, `external` and `symbols` each parsed, loaded, were never acquired for that scope, and left the module reading the corresponding `input.tree.*` key deciding nothing — CLOUD-845's dead-gate class one layer above the module. Adding fourteen more names is the wrong fix and is the mechanism that produced the gap. `COLUMN_CENSUS` says which `Fact` each column declares, `Fact::class().surface` says where that fact resolves, and the two answer for a column added tomorrow with no edit here. A fact RECLASSIFIED onto the hook surface also stops being refused, which a list could not manage in either direction. The census carries a verdict for every one of the 81 `Rule` columns — a fact, or not fact-bearing with the reason — and `every_rule_column_carries_a_fact_verdict` reads the field list off the struct's own source, so the table cannot fall behind it silently. That is the arm that makes the derivation asserted rather than claimed, and it is `trust.rs`'s `CENSUS` shape reused deliberately. `requires_path` is recorded not fact-bearing rather than left out: it is a `try_exists` per entry, acquires no `Fact`, and is permitted on every kind by design. Naming it is the difference between "considered" and "not covered", which an omission cannot express. THE CENSUS OF COMMITTED ROWS, which is why this could not be verified by running the suite alone: no row in `batten.toml` and none in `batten.example.toml` is refused by the change — the whole suite loads both. One TEST FIXTURE was refused, correctly: `one_external_id_names_one_file` built a row at the policy kind's first scope and declared `external`, so it now pins tree scope explicitly, or it would pass for the wrong reason and stop saying anything about id uniqueness. Three cases over the compiled binary and through the real loader, since the acceptance is about what a consumer's `batten.toml` may declare. They assert the refusal TEXT rather than the exit code, and that is not a weakening: the fixtures name a module that does not exist, so all three exit 1 for a second reason and a case reading the code would pass on the missing module while saying nothing about the column. Both anti-vacuity halves are there — the same column at tree scope, and a mediated row declaring only hook-resolvable columns. Refs: CLOUD-1282
`branch_validity` voided a branch-keyed receipt on `own == 0 && recorded != head`. CLOUD-516's table justifies that for ONE situation — `git checkout -B <name> origin/main` after a merge, where the branch moved forward off a base the receipt predates and the commits that were the branch are gone. The comparison is symmetric and the situation is not. It fired just as readily when the receipt's base was NEWER than HEAD: a branch cut from an earlier main and not yet rebased, which is the ordinary state of a freshly-cut branch in a repository landing ~40 commits a day. The receipt is then MORE current than the branch, not less. Measured 2026-08-28 on `claude/stage-2-3-grooming-uqk71k`: an `issue-search` receipt recording the current `origin/main` was voided, and the remedy the refusal prescribes cannot clear it — the receipt is append-only and `recorded_base` correctly reads the last `base` line, so a fresh search appends a fresh, correct entry and the comparison still runs against HEAD. Three searches, three identical refusals. The loop broke only on a fast-forward merge, which the message never asks for. The direction is the missing conjunct: void only where HEAD carries commits the recorded base does not. Expressed as a RANGE COUNT rather than a reachability verdict, which is what CLOUD-36 leaves legal and what `own_commit_count` already does one function up — selecting which commits to count is a different act from concluding that one commit contains another. Could-not-look stays VOID. This widens what passes only where the repository can be read, so an unreadable range can never be the thing that admits a claim that expired. Four cases, and the discriminators are the point: the behind-HEAD receipt is honoured (red against the predicate this replaces); CLOUD-516's restart is STILL void, and now for the direction rather than for could-not-look, which is what keeps a change that always returned `Valid` from passing; a base equal to HEAD short-circuits before the range is read; and an unreadable range stays void. The range reader is injected, because a scratch directory is not a repository and a case reading the real range would pass for the wrong reason. The `receipt read stale` class said "the check ran against an `origin/main` that has since advanced… Rebase and re-run", which is the direction that is now measured false and the one action guaranteed not to work. Corrected to name the restart, which is what the predicate now refuses, and to say outright that a branch merely behind keeps its receipt. `Validity`'s own doc called it "the four states" over six variants. Fixed in passing; a count in prose beside the thing it counts is drift waiting to happen, and the two staleness variants are exactly the pair a reader assumes away. Refs: CLOUD-1091
`hookSpecificOutput.additionalContext` has three producers now — `drain::render`, `contract::render` and the dispatched handler CLOUD-898 added — and the only declared number was `[drain] token_budget`, which bounds ONE of them. CLOUD-461 coalesced the framing: one JSON object per call. It bounded nothing about volume, so N producers under N budgets left the channel's real ceiling as whatever the set happened to sum to. That is the same trajectory `stop-guard` took — one rule, then five, each defensible alone with the aggregate never costed — and the failure mode is CLOUD-417's, measured: hook output at 20% of a long session's context. `[advisory] max_tokens` is the channel's, applied at the one site where the whole set is in hand. 1024 is `drain::DEFAULT_TOKEN_BUDGET`'s number read as the channel's rather than one producer's: the drain document is the largest of the three, so it keeps essentially its whole allowance firing alone, and the change bites where there was no bound at all — two or three producers on one boundary. Producers are admitted in `AdvisoryTier` order (CLOUD-80: severity as required response latency), so what survives a full channel is what must be answered soonest. The tier is carried from the PUSH SITE rather than inferred at the emission, because "how soon must this be answered" is a property of what is said and the boundary has only a string: a handler's advice is `Advisory`, a contract violation is `Warning`, an unmediated session is `Warning`, a Stop nudge is `Caution`. The remainder is dropped AND COUNTED. A truncated report that reads as complete is the false green in advisory form, so the suppressed count is a line in the emitted document rather than a silence — `drain.rs`'s `budget_summary` reused, a count and a ceiling, never the text that was dropped. The first entry is always admitted, even where it alone is over. A channel that could emit nothing would make the count line the only thing said, and the reader would hear silence rather than learning the ceiling is too small for its own content. Six unit cases, and two are the load-bearing half. The mutation case: remove the comparison in `admit` and the truncation case goes red, because everything fits and nothing is counted. The anti-vacuity case: an UNDECLARED ceiling emits exactly what it emitted before, in the boundary's own order — no reordering, no count line, nothing paid on a call that was never the problem. `validate` refuses `max_tokens = 0` at load, which is a channel switched off wearing a budget's clothes. `trust.rs` carries `AdvisoryCeilingRaised` beside `RefusalCeilingRaised`: smaller is stricter, so §8's "may not weaken" reads as "may not raise", and an absent ceiling is unenforced rather than zero. Its `CENSUS` row is what makes the field covered rather than merely present. `resolve.rs` registers `advisory` as an authority-only contributor. Without the row `Resolved::attributed` refuses the emitted key with "carries no source", which is `batten config show` exiting 3 over this repository's own committed config — found by `committed_rules_pin_severity_and_scope_explicitly` rather than by reading, which is the second tier doing its job. `policy/module-layering.rego` places the module. It is a leaf beside `refusal`, and the pairing is the placement's content: `refusal` bounds ONE emitted deny line and this bounds ONE emission of the whole channel, so the two answer the same question over the two documents a boundary can produce. It reaches `budget` for the estimator `refusal` already reaches, because a second one would be a second authority over what a token costs. That rule named the unplaced module before any reviewer did, for the tenth time. Admits: eb3a2ef4b32c5fb74fd9ff01e4c5a1d52ad175965ab693d4077d9fa6d0458d9a Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 7aa647e Admits-epoch: 294d260fec2cffd857f18f200915445933d03bde7eb6fedba821e0e325beef68 Admits-author: alec@wenzowski.com Admits-prev: 3f4e20df269636ca67ea65563d4b815f68de94b5fd2e1112d920da95cae29296 Admits-answer-lost: The row's own mechanism. `advisory::admit` treats an undeclared ceiling as no ceiling, so without this table the new gate is unreachable in consumer #1 — it loads clean, decides nothing, and the anti-vacuity mirror is the only case that ever exercises it. That is a dead gate, which non-negotiable rule 2 calls half a change. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table: CLOUD-896 requires one declared budget for the advisory CHANNEL, superseding a per-producer key, and non-negotiable rule 1 forbids the number living as a constant in `crates/batten`. No non-protected path carries it. The write is one a reviewer sees in the diff it lands in — a short addition of `[advisory] max_tokens` with its reasoning inline, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the neighbouring `[budget.instructions]` and `[refusal]` tables and copied their shape deliberately; reading further produces no route that writes the key. `patch run first` does not apply either: a patch that adds a top-level table to the policy authority is still a write to `batten.toml`, so it reaches this same class one indirection later. Admits: 174e8e0c026fc45c027ae59a86b35cd5119c69fd659430810a6c7602a88caf48 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/module-layering.rego Admits-head: 7aa647e Admits-epoch: 65c5b8996a1b40713c48fc6c1e8b991e3f231fe68407dfb9c2eca5efd525ea6d Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: CLOUD-896's whole change. `batten-check` is red until the new module is placed, so the ticket cannot land at all: the alternative is deleting `advisory.rs` and leaving the channel ceiling unbuilt, which is the gate this row exists to enforce working exactly as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the layer table: `module-layering`'s absence-is-an-error clause refuses `crates/batten/src/advisory.rs` until this module places it, and a placement is a claim about architecture that only this file carries. There is no non-protected path that can place a module. The write is one a reviewer sees in the diff it lands in — one entry in `declared_modules` with its placement comment, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read this module in full — its absence-is-an-error clause, its forbidden-edge table and its test tiers — and reading further produces no route that adds the placement. `patch run first` does not apply either: a patch that adds a member to `declared_modules` is still a write to `policy/module-layering.rego`, so it reaches this same class one indirection later. Admits: 35490aec1672d07e9dcecd3c525e790988f9135721d7dfe6f9f70207a5553293 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/core.md Admits-head: 7aa647e Admits-epoch: 65c5b8996a1b40713c48fc6c1e8b991e3f231fe68407dfb9c2eca5efd525ea6d Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: CLOUD-896's whole change. `module-map-check` is red until the new module has its row, so the commit cannot land: the alternative is deleting `advisory.rs` and leaving the channel unbounded, which is the gate working as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the module map: `module-map-check` refuses `crates/batten/src/advisory.rs` until `.serena/memories/core.md` carries its row, and that file is the one authority on where each module sits. No non-protected path can place a module in the map. The write is one a reviewer sees in the diff it lands in — one entry describing `advisory.rs` beside the `refusal.rs` row it pairs with, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the map's `refusal.rs` row and written this one to pair with it deliberately; reading further produces no route that adds the entry. `patch run first` does not apply either: a patch that adds a row to the module map is still a write to `.serena/memories/core.md`, so it reaches this same class one indirection later. Refs: CLOUD-896
…cidable Non-negotiable rule 4 holds every check to "a count, `path:line`, or boolean — never the content itself", and each hook obeys it individually. Nobody had measured them IN AGGREGATE, over a session, where one line that obeys the rule is emitted hundreds of times and every copy stays in context forever. Measured on transcript `125cdf71` (758 turns, 5.83 MB): `hook_success` at 1181 KB and `hook_additional_context` at 42 KB, against 95 KB of edited files and 88 KB of delivered memories. Hook output alone was 20% of the transcript, and the largest single contributor said one true, correctly pointer-shaped, identical thing on essentially every turn. The root cause is that the output rule is stated per-CHECK and enforced per-CHECK. There is no rule about a check's output over a SESSION, so a hook that is silent by default and one that confirms success every turn are indistinguishable to every gate that exists. That is the shape CLOUD-896 found one layer down, and the answer is the same: put the ceiling on the aggregate, because the aggregate is what is spent. `[hook_output]` carries two thresholds and the second is most of the win. `max_tokens` bounds the session. `max_repeats` is what makes "silence on success is the default" and "a repeat is a pointer to the first, not a copy" DECIDABLE rather than prose — a hook saying the same thing every turn is byte-identical every turn, so it is exactly a digest repeated, and prose asking hooks to be quiet is the feedforward this repository refuses. 1 is the floor, never 0: saying it once is the report, and refusing the first emission would remove the finding rather than its restatement, which the row puts explicitly out of scope. `Event::HookOutput` is appended to the transcript vocabulary and carries a count and a DIGEST, never the text — `transcript.rs` hashes the emitted bytes with `identity::context_fingerprint` and drops them at the parse, so a measurement of an over-wide channel cannot itself carry what the channel said. The report keeps eight hex characters, a count, and the first copy's line. Three parse decisions each close a way of being silently wrong. The tag is matched as a `hook_*` PREFIX rather than an enumerated set, because a host shipping a third tag is emitting the same cost and counting it as zero is the under-report this row ends. Empty output is NOT an emission, which is what keeps three silent records from hashing alike and manufacturing a violation out of the exact behaviour being asked for. The grouping key is (producer, digest), so two hooks emitting one string are two producers rather than one repeating itself. `batten policy hooks` prints ONE line and nothing around it. That is the row's own acceptance clause and it is asserted in both tiers: a gate about hook volume whose own report grew with what it found would be the defect wearing the sensor's clothes. The producers that broke a threshold are named in the findings, which is where a reader who needs one goes. `mise run hook-cost` is the measurement, and it is deliberately NOT in `verify` and not in the hk gate. A transcript is a property of the WORLD rather than of the commit — the `lock-complete` / `lock-currency` split one gate over — so wiring it into `verify` would red a branch for a session it did not cause and would pass vacuously in CI, where no transcript exists at all. It is also the row's acceptance: the 20% figure ships as a re-runnable command rather than as a number in an issue body. 12000 is derived and is deliberately not the measured figure. 1223 KB is ~313,000 tokens on the engine's own estimator, and a ceiling there would certify the defect rather than refuse it; this is the budget a session SHOULD spend, against `[budget.instructions]`'s 3,500 for the always-loaded surface. Eight unit cases and eight over the compiled binary, and the discriminating ones are the point: a hook repeating itself is refused, a hook reporting one change-set once is clean, a hook silent on success is clean and costs nothing. Without the two clean cases a rule that refused every emission would satisfy the first and gate nothing. The mutation case is the `count > 1` filter: drop it and every single emission becomes a repeat, so the clean cases go red. The anti-vacuity case is an UNDECLARED ceiling, which still measures and refuses nothing — which is also how a consumer reads its own number before adopting one. `a_repository_with_no_transcript_is_a_usage_error_and_never_a_clean_pass` asserts the refusal names `[transcript]`, not just the exit code: exit 1 is also what an unparseable config produces, and an earlier draft of this file passed over a fixture that never reached the verb because `kind = "forbidden_path"` names a variant the engine does not have. `trust.rs` carries two kinds rather than one over this table. The thresholds answer different questions — how much a session may cost, and how many times one thing may be said — and a `Weakens:` clause that could not say which of them moved would be articulating nothing; a change tightening the ceiling while raising the allowance is still a weakening, and one comparison would let it through. `census_fixture`'s config moves to a const in the same change. The fixture's job is to supply each data-emitting verb its minimum input, that list grows by a table per verb, and `policy hooks` is the fifth — which pushed the function past the line ceiling. Admits: 5ab7c3762bcd6f6f9512f5ae54373a1d571d13201c79f989b59465855ef856e5 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 99afa8a Admits-epoch: 5e81f17ac93d97a8f1412b4cf773f78d648c71d24ff07ced8828ccd923206aaf Admits-author: alec@wenzowski.com Admits-prev: eb3a2ef4b32c5fb74fd9ff01e4c5a1d52ad175965ab693d4077d9fa6d0458d9a Admits-answer-lost: The row's own mechanism. `hookcost::judge` treats an undeclared ceiling as no ceiling, so without this table the new gate is unreachable in consumer #1 — it measures, decides nothing, and only the anti-vacuity mirror ever exercises it. That is a dead gate, which non-negotiable rule 2 calls half a change, and it is the exact defect this row exists to end: prose asking hooks to be quiet with no runnable gate behind it. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table: CLOUD-417 requires a budget over hook output per session declared in `policy-budget`'s own grammar, and non-negotiable rule 1 forbids the numbers living as constants in `crates/batten` — a consumer whose hooks are quieter or louder cannot move a figure compiled into the engine. No non-protected path carries it. The write is one a reviewer sees in the diff it lands in — a short `[hook_output]` table with its two thresholds and the measurement they are derived from, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the neighbouring `[budget.instructions]`, `[refusal]` and `[advisory]` tables and copied their shape and their boundary convention deliberately; reading further produces no route that writes the keys. `patch run first` does not apply either: a patch that adds a top-level table to the policy authority is still a write to `batten.toml`, so it reaches this same class one indirection later. Admits: 1b620bf424e6e5e51ea5b9f97e3d2453bdb584230ee7f55b0a197444a882ba5b Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/module-layering.rego Admits-head: 99afa8a Admits-epoch: 65c5b8996a1b40713c48fc6c1e8b991e3f231fe68407dfb9c2eca5efd525ea6d Admits-author: alec@wenzowski.com Admits-prev: 174e8e0c026fc45c027ae59a86b35cd5119c69fd659430810a6c7602a88caf48 Admits-answer-lost: CLOUD-417's whole change. `batten-check` is red until the new module is placed, so the ticket cannot land: the alternative is deleting `hookcost.rs` and leaving hook output unmeasured in aggregate, which is the gate working as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the layer table: `module-layering`'s absence-is-an-error clause refuses `crates/batten/src/hookcost.rs` until this module places it, and a placement is a claim about architecture that only this file carries. No non-protected path can place a module. The write is one a reviewer sees in the diff it lands in — one entry in `declared_modules` with its placement comment, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read this module in full — its absence-is-an-error clause, its forbidden-edge table and its test tiers — and reading further produces no route that adds the placement. `patch run first` does not apply either: a patch that adds a member to `declared_modules` is still a write to `policy/module-layering.rego`, so it reaches this same class one indirection later. Admits: 790a8c58bb43d71c543b70276b367f1b9750d26be38ab825e169c98ed6eb9f64 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/core.md Admits-head: 99afa8a Admits-epoch: 5e81f17ac93d97a8f1412b4cf773f78d648c71d24ff07ced8828ccd923206aaf Admits-author: alec@wenzowski.com Admits-prev: 35490aec1672d07e9dcecd3c525e790988f9135721d7dfe6f9f70207a5553293 Admits-answer-lost: CLOUD-417's whole change. `module-map-check` is red until the new module has its row, so the commit cannot land: the alternative is deleting `hookcost.rs` and leaving hook output unmeasured in aggregate, which is the gate working as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the module map: `module-map-check` refuses `crates/batten/src/hookcost.rs` until `.serena/memories/core.md` carries its row, and that file is the one authority on where each module sits. No non-protected path can place a module in the map. The write is one a reviewer sees in the diff it lands in — one entry describing `hookcost.rs`, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the map's neighbouring rows and written this one to match them; reading further produces no route that adds the entry. `patch run first` does not apply either: a patch that adds a row to the module map is still a write to `.serena/memories/core.md`, so it reaches this same class one indirection later. Refs: CLOUD-417
The rebase onto `origin/main` brought 38 commits, six of which declare or raise a refusal class in the retired `V-SCREAMING-KEBAB` spelling. They were written against a tree where that was the only spelling there was, so they are not a regression — they are the window between this branch's converting commit and the merge, and the registry's own both-directions equality check is what makes them visible rather than latent: a token no row declares fails the load, and so does a row nothing raises. Three are `shell-retirement`'s ported-subject arms, which `main` landed with their `[[verdict]]` rows: `suite port unnamed`, `suite port dead`, `suite port held`. Their class prose is `main`'s and is kept verbatim — only the id moves, which is the whole of what CLOUD-1284 asks of a class. Their routes take the declared `config read first`, whose target is already `batten.toml`, rather than three one-off ids that named the same act three ways. Two are the `landing-loop` preset's, in the `VENDORED` table: `patch ship twice` and `lease grant other`, with their four routes converted alongside. The vendored half is deliberately not held to the consumer's vocabulary — a third-party consumer never declared these words — but it IS held to the grammar, which is what the conversion satisfies. Prose references to retired tokens are corrected in the same change rather than left to rot: a comment naming a class that no longer exists is a pointer into nothing, and the next reader has no way to tell it from a live one. Four conflicts were resolved and each is worth naming, because taking the wrong side of any of them would have been silent: `main` replaced `#MUTANT-EXEMPT` prose with `#MUTANT-SUITE` rows in fourteen modules, and this branch had only renamed a token INSIDE the prose `main` deleted. Every one takes `main`'s side — the line carrying the renamed token no longer exists, so there is nothing to carry forward. `batten.toml` and `shell_retirement.rs` both add `tests/**/*.bats` to `line_sources`, from CLOUD-1294 and CLOUD-1088 independently. The list is identical; only the reasoning differs, so both reasons are kept. That is not tidiness: a later reader deleting the glob because one of the two reasons no longer applies would break the other, and the comment is the only place that fact lives. Admits: 342fd2ece5a89e5b7c3160a6671f12a4e546bc543c2c38f6ae167cdf94cf038b Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 19479dd69cf581669b09b479330db4fe73e69ca2 Admits-epoch: 50bcb12cb9f6ea242960629ba3db2c7082f83a7d2ad773dde2fc01dfad27a6d2 Admits-author: alec@wenzowski.com Admits-prev: 5ab7c3762bcd6f6f9512f5ae54373a1d571d13201c79f989b59465855ef856e5 Admits-answer-lost: The whole branch. `check_verdicts_are_declared` and `check_registry_is_exhausted` refuse the load in both directions, so with `main`'s rows still spelled `V-PORT-SUBJECT-*` and the module raising the three-word form, nothing loads at all — not a gate switched off but a tree that cannot be built. The alternative is reverting CLOUD-1284, which is eleven landed tickets undone to avoid a rename. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS the `[[verdict]]` registry: six classes `main` landed after this branch's converting commit are declared and raised in the retired spelling, and a token no row declares fails the load — so the tree does not build until the ids move, and `batten.toml` is the one file that carries them. No non-protected path holds a class declaration. The write is one a reviewer sees in the diff it lands in — three ids and three route ids renamed, with `main`'s own class prose kept verbatim, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read `main`'s three rows in full and am keeping their class prose unchanged; reading further produces no route that renames an id. `patch run first` does not apply either: a patch that rewrites a `[[verdict]]` id is still a write to `batten.toml`, so it reaches this same class one indirection later. Refs: CLOUD-1284
…d binary CLOUD-1286 took the `Fix:` clause off the emitted mediated line, and one case in `tests/fact-record-keying.bats` asserted that clause verbatim — the declared `gh pr view --json reviewThreads` remedy. The assertion is now false, and `shell edit refused` refuses editing a governed `.bats` with one route and no override, so there is no landable spelling that fixes the assertion in place. That leaves the two shapes `.claude/rules/toolchain.md` names, and this is the first: retire it whole. Six cases move to `crates/batten/tests/it/` driving the same two real hook envelopes over the compiled binary — a `PostToolUse` carrying the declared command, which mints the record, and a `PreToolUse` `gh pr ready`, which reads it. Nothing writes a receipt by hand and nothing inspects a path to decide a case, which is the property the bats header argued for and the reason this is a port rather than a rewrite as unit tests over `sourced_path`. The ledger arm is `ported:` rather than `carried:`, and the distinction is the whole of the classification: the SUBJECT survives. `crates/batten/src/facts.rs` is engine source the campaign never retires, so this is the cases moving off bash while the thing under test stays exactly where it was — which is what `suite port held` refuses to let anyone spell as a retirement, and what `suite port dead` refuses to let anyone spell as a port. Exactly one assertion changed, and it is the one that made the retirement necessary: the head-keyed case now pins the declared CLASS and its pointers (`receipt read missing`, `ready-needs-the-fact`) rather than the inline remedy. That is what the hot path emits, and the remedy is one hop away through `batten policy explain`. Every other case is byte-for-byte the same predicate over the same two envelopes, including the anti-vacuity twin the suite exists for — head-keying everything would satisfy the first case and break `claim` repo-wide, so "a branch-keyed record survives a new commit" stays green. Two helpers changed shape rather than meaning. `age_records` backdates through `File::set_modified` instead of shelling to python3, and `records` globs through `read_dir` instead of a shell loop — the bats versions were written around BSD `touch -d` and GNU-only `find -printf`, portability hazards that do not exist once the code is Rust. The ledger is TWO granularities and both are owed. The file arm buys the deletion; `bats-tests-not-deleted`'s `[rule.conserves]` demands one arm per deleted CASE on top of it, which is CLOUD-908's point — the file column asks whether the subject died and never whether the cases moved, so a migration could delete a 259-line suite and land green with nothing asserting what replaced it. All six case arms are `ported:` rather than `carried:`, and that is the same distinction one level down rather than a second one. `carried` names a successor and accounts for no survivor; `ported` names both, and the survivor it names is what clears the aggregate subject-alive term. Naming it is not optional — an arm without the field would be `carried` with a longer word on it, buying the deletion while accounting for nothing, which is the exploit CLOUD-1130 closed and CLOUD-1268's fifth marker could have re-opened. The subject is read from the DYING FILE's own `# subject:` header at base, so the arm cannot name a convenient survivor it invented. Refs: CLOUD-417
A second rebase onto `origin/main` brought `policy/lock-complete.rego` — eleven refusal classes and their routes, all in the `V-SCREAMING-KEBAB` spelling CLOUD-1284 retires. Two more arrived in `rules-drift`. Same window as the last conversion and the same non-defect: they were written against a tree where that was the only spelling, and the registry's both-directions equality check is what surfaces them rather than letting them load. The class names are the vocabulary read at three positions rather than transliterations of the old ones. `lock write other` is a platform key mise does not emit — it disagrees with its counterpart rather than being absent. `lock reach missing` is a required platform with nothing to install from. `tool pin missing` and `tool pin absent` split what the old names ran together: a backend that CAN lock a url and did not, against a tool the lockfile never mentions. `lock write unsafe` and `workflow run unsafe` name the two settings that permit an unverified write, and `lock read unread` is the could-not-look. The routes collapse to the two landed spellings rather than eleven bespoke ids. Every one of them was either "read the declaration" or "run the task that regenerates it", so they take `config read first` and `task run first` — a route id is a class of remedy, and minting a new one per row is how a registry that exists to make a remedy lookupable ends up with a vocabulary nobody can hold. Four conflicts resolved to main's side, all the same shape as the last round: it replaced or extended prose this branch had only renamed a token inside. `bench/suites/RESULTS.md` is derived and takes the regenerated form. The load gate drove the conversion rather than a grep. `batten config show` refuses one malformed name at a time and says which, so the remaining ids were found by running it — which is the arity check working as CLOUD-1284 specified, on a registry it had never seen. One reading cost a detour and is worth recording: the gate reported `format` as invalid for kind `policy` when the config was correct, because the binary on PATH predated the rebase that added the column. The installed binary is not the tree's binary, and a gate run through it answers about neither — which is exactly why `batten-check` and `policy-budget` both shell `cargo run` rather than the installed name. `policy/lock-entry-complete.rego` is deleted in the same change. `main` renamed the module to `lock-complete.rego`, and taking its side of the conflicted hunk left the old path in the tree referenced by nothing — a module no row loads, which `mutate census` reports as uncovered because that is exactly what it is. The census caught it; a green suite could not have, since nothing ran it. Admits: b0159f56008100296cc8b5d5251dd016ded1a5783ad8d8bdc02b4ba64c0236d8 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 6de22801900c8c46378254c31a22b9cb7ab951d1 Admits-epoch: 658ff6f5760048d083e0c6e99ba723f974ec7001622f5c29ff8a698266601808 Admits-author: alec@wenzowski.com Admits-prev: 342fd2ece5a89e5b7c3160a6671f12a4e546bc543c2c38f6ae167cdf94cf038b Admits-answer-lost: The whole branch. `validate_one` refuses a class that is not three words once a `[vocabulary]` is declared, and `check_verdicts_are_declared` refuses in both directions, so with `main`'s rows still spelled `V-LOCK-*` nothing loads at all — not a gate switched off but a tree that cannot be built. The alternative is reverting CLOUD-1284, which is twelve landed tickets undone to avoid a rename. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS the `[[verdict]]` registry: `main` landed eleven `lock-complete` classes and two `rules-drift` classes in the retired spelling after this branch's converting commit, and the grammar gate refuses them at load — so the tree does not build until the ids move, and `batten.toml` is the one file that carries a class declaration. No non-protected path holds one. The write is one a reviewer sees in the diff it lands in — thirteen ids and their routes renamed, with `main`'s own class prose kept verbatim, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read `main`'s rows in full and am keeping their class prose unchanged; reading further produces no route that renames an id. `patch run first` does not apply either: a patch that rewrites a `[[verdict]]` id is still a write to `batten.toml`, so it reaches this same class one indirection later. Refs: CLOUD-1284
The `lock-complete`, `rules-drift` and `staged-facts` fixture registries declared classes in the retired `V-SCREAMING-KEBAB` spelling while their probe modules raised the converted names, so `check_verdicts_are_declared` refused the load and the case failed on a registry mismatch rather than on the predicate it exists to pin. Route ids are untouched: `validate_route` checks the grammar only where a `[vocabulary]` table is declared, and these fixtures declare none. Refs: CLOUD-1284
… two renames
CLOUD-1284's sweep converts `[[verdict]]` and `[[verdict.route]]` ids to the
three-word grammar. It is keyed on the id string, so it also rewrote the
`[[pattern]]` row `policy-rule-const` — whose new spelling collided with a real
route id — and `config-lint` read the row as removed against `origin/main`.
Restored here, with `policy/rules-drift.rego`'s two reads and its fixture
registry repointed at it. No pattern row on this branch differs from main's now.
The two remaining smells are the rename read syntactically. `config-lint` keys a
verdict by its id, so a class that carried an override on main and carries the
same override under a converted name reads as an override newly added:
verdict-override-added verdict[diff ship early].override
`V-PROSE-ONLY-DIFF` renamed. Its `override` route, precondition and
gloss are byte-identical to main's; only the id changed.
verdict-override-added verdict[issue file same].override
`V-FILED-OVER-OWN-DIFF` renamed, same shape. `batten.toml` on main
already states "THE OVERRIDE IS DECLARED ON THE CLASS" of this row.
Neither adds a route, widens a precondition, or reaches a subject the class did
not already reach.
Refs: CLOUD-1284
Weakens: verdict-override-added verdict[diff ship early].override
Weakens: verdict-override-added verdict[issue file same].override
Admits: 1189492d572f0263a78539a035288c96a452e12f4b5b3cf5d6447e284ae473c7
Admits-rule: protected-mutation
Admits-verdict: path write refused
Admits-subject: batten.toml
Admits-head: 4770b6d7bc9b78aa93c6cc0a371df2d215932419
Admits-epoch: 0cf85f299d456bb50824d2a3738d70642a1a2e71913cd3e04ba95c520c9a0162
Admits-author: alec@wenzowski.com
Admits-prev: 4528fe7dd88357aa839d5cfe2d3df77b8278399af900eb5ed482063472be8f30
Admits-answer-lost: config-lint stays red on `pattern-removed`, so `verify` writes no receipt, `ready-guard` refuses `gh pr ready`, and the whole bundle cannot land. The `rules-drift` module also keeps reading `data.batten.patterns["module read first"]`, a name whose row would then be missing on any tree that took the rename literally.
Admits-answer-precondition: No surface can express this: it is a one-token repair of a `[[pattern]]` row id inside batten.toml itself, which this branch's mechanical rename sweep hit by accident. `policy-rule-const` was renamed to `module read first`, colliding with a route id, and config-lint reports `pattern-removed` against origin/main. Only a direct edit of the protected file restores the row, and it lands as one visible line in the PR diff.
Admits-answer-rejected-route: `config read first` was taken — batten.toml and origin/main's copy were both read, and the diff is what identified the accidental rename. `patch run first` (git restore) does not apply: this file carries 62 commits of intended change from this bundle, so restoring it would discard all of them to fix one token.
CLOUD-1189, the thirteenth row of this bundle. It was recorded as skipped
because `claim-check` would not pull it; measured this session,
`batten claim check --issue CLOUD-1189` exits 0 and its receipt reads
`ready-lint pass`. The skip was on a false premise, so the row is built.
`redirect` strings are free-form prose and nothing resolved the command they
name against anything, so any verb that moves turns every remedy naming it into
a lie, silently. `redirect::validate_remedies` resolves each one against the two
authorities that can answer: the longest prefix that is a declared `SURFACE`
path, then every remaining word against the declared `[[rule]]` ids. Wired into
`config::parse_ungated` beside its four sibling validators rather than left with
no call site — the defect `verbs::validate` had for its whole life (CLOUD-242).
AN INVOCATION IS A CODE SPAN, and that bound is the whole of what makes this
decidable. The first version collected every following token that looked like a
subcommand word, which reads "run `batten capture show <handle>` instead" as a
four-word invocation ending in `instead` and then reports `instead` as an
undeclared rule id. A finding invented out of English is the false positive that
gets a gate switched off; it turned three cases red before it was bounded. What
the bound under-denies — a command named in bare prose — is asserted outright in
`an_invocation_is_a_code_span_and_bare_prose_is_not_one` rather than left to a
reader of the predicate.
Two deviations from the row's §5, both recorded on the issue rather than
silently taken:
no `[[verdict]]` row. A load-time refusal is a `UsageError`, not a raised
class, and a declared row nothing raises fails the load — so shipping one
would break every consumer's config. Every sibling validator in
`parse_ungated` refuses the same way.
the population is zero. 22 remedy strings on the committed table, 0 naming a
`batten` invocation; 44 `command` routes, 0 batten-invoking. The row's own
premise is that the gate survives a surface rename by construction, so the
constructed cases are what show it can fail and `the_committed_table_loads`
is the mirror that keeps it from being tightened past what an author writes.
Refs: CLOUD-1189
CLOUD-1308. `verdict_override_entries` collected the id of every class declaring an `override` route and `weakenings` reported the set difference, so a class renamed with its override untouched left the old id absent and the new one present — and the new one was reported as a hatch newly added. The removal side is deliberately unreported (deleting a class is fail-closed, since a module raising an undeclared token fails the load), so nothing cancelled it out. Measured on this branch, which renames every class in the registry: two of 105 rows carry an override and both were reported. `V-PROSE-ONLY-DIFF` -> `diff ship early` and `V-FILED-OVER-OWN-DIFF` -> `issue file same`, route and precondition byte-identical to main's. THE KEY IS THE PRECONDITION RATHER THAN THE ID, and that is the same question this kind already asks. `VerdictOverrideAdded` refuses to judge whether one precondition is looser than another and still does — nothing here compares two conditions for strength. It compares them for IDENTITY, which is what tells a hatch that moved from a hatch that is new: a hatch IS the condition it states, so a class whose override states a precondition some base class also stated is that hatch relocated, whatever either is called. The discriminating pair is the whole case, and the second half is what keeps the fix from becoming a blanket allow: a rename with an unchanged precondition is silent, and a rename that ALSO rewrites the precondition is still reported. Without the second, "never report a class whose id is new" would pass the first and switch the kind off. WITHDRAWN BY THIS COMMIT: the two `Weakens:` trailers on cc49e0ea and the two matching clauses on CLOUD-1284's Ready block. `config lint` against origin/main now reports 0 smells rather than 2 admitted, so both admissions record a weakening that did not happen and neither is needed. They are left in that commit's message rather than rebased out — the history is 68 commits deep and the trailers are inert once nothing raises the smell — and the clauses are removed from the row, which is where a future reader looks. HOW THIS WAS FOUND, because the route matters more than the defect: it was filed as CLOUD-1308 rather than fixed, and `filed-over-own-diff` refused that — correctly. Its first declared route is "close the row you filed and fix it in this diff", and the fix is this file rather than the ~105 tombstone rows the filed row priced it at. Refs: CLOUD-1308
3ade39e to
707ab41
Compare
|
❌ The last analysis has failed. |
|
/fast-forward |
…ises CLOUD-1220. Measured on this repository during #807: `enforce` printed `2 finding(s) carry no remediation: persisted:false`, and both were `kind = "policy"` rows whose `[[verdict]]` tokens declare routes. The registry had the remedy; the `Finding` did not, so both were dropped before `findings::record` and never entered the store. THE MECHANISM, established before changing anything, because the row required it and because `lib.rs`'s comment asserted the opposite: RuleKind::Policy => &["severity"] RuleKind::Judge => &["glob", "criteria", "no_fix_reason"] A policy row requires only `severity`. Judge requires `no_fix_reason` outright, and its own comment says why — "a judge finding reaches the store and CLOUD-81's ingest refuses one nothing can close ... Requiring it here is what keeps that refusal unreachable from a config that parses." Policy rows never got that treatment, so `rule.remediation()` returned `None` for every one of them. `lib.rs:6730`'s "this partition should never fire" generalised Judge's guarantee to a kind that never had it — CLOUD-242's lesson, one table over. THE FIX JOINS THE REGISTRY RATHER THAN ADDING A SECOND REMEDY COLUMN. Requiring `no_fix_reason` on a policy row would have been the smaller diff and the wrong one: a module's remedy is per PREDICATE and one row can carry many (CLOUD-832), so a single column could not say which violation it answered — and it would be a second spelling of what `[[verdict]]` already declares. `policy_remediation` resolves the raised class instead: a `command` route becomes `Remediation::Fix`, and every other route — document, issue, override — becomes a pointer-only `NoFix` naming route ids and kinds, which is a real answer rather than an absence, since `verdict::validate` already refuses a class with no route and one whose only route is an override. `RunInputs` gained the union registry, taken from `Vocabulary.verdicts` which `run` already receives. The union rather than the consumer table, via `registry_for`: a module may raise a preset's class as readily as a consumer's. WHY THE TESTS ASSERT THE STORE AND NOT THE EXIT CODE. `enforce` exited 2 and printed the class correctly throughout — what was lost was persistence, so only a store read can see it. Running `enforce` on this repository is NOT evidence either: the tree is clean, so zero findings fire and zero unrecordable findings is vacuously true. The discriminating case is a fixture whose only violation comes from a `policy/*.rego` module, read back out of the store. Both arms, because a fix handling only `command` routes would leave every class whose remedy is a read or an override exactly as broken — which is most of this registry: a command route records the runnable argv, and a document route records the pointer without copying the route's target into it (rule 4). Refs: CLOUD-1220
Every mediated Bash call in this repository passes
batten hook, and every refusal it emits is paid for in the agent's context window on the turn it fires and on every turn afterwards. Measured live on 2026-09-01, ano-tool-substitutionrefusal was 88 words / ~115 tokens, and ~300 of them fire in a long session — ~34,500 tokens against a ~175k window.Thirteen rows all named one file domain: the mediated-call boundary (
crates/batten/src/hook.rs), the refusal it constructs (refusal.rs,verdict.rs,redirect.rs,rules.rs,receipt.rs), and the registry that declares what it may say (batten.toml's[[verdict]],[[rule]],protected*tables). Each one's registry or gate is what the next extends, which is why this is one PR rather than thirteen: N PRs would be N CI matrices for one unit of build work.The outcome: a mediated refusal is now one line — a declared three-word class and its pointers — reachable from every composer, with the reasoning dereferenced through
batten policy explain, and with four false-refusal defects in the same boundary fixed so the cheaper line is not also a wrong one.What landed
Closes CLOUD-1284 — a three-word verdict grammar, gated.
VERDICT_PREFIXandROUTE_PREFIXare gone; arity is fixed at 3 (never≤N, which is what lets<class> <pointer…>parse on one line with no delimiter), membership is per position, and single-tokenness is asserted againsto200k_basethrough a dev-dependency that ships nothing in the binary.Closes CLOUD-1189 — a remedy resolves to a declared command or rule.
redirectstrings were free-form prose and nothing resolved the command they name against anything, so any verb that moves turned every remedy naming it into a lie, silently.redirect::validate_remediesresolves the longest prefix againstSURFACEand every remaining word against the declared[[rule]]ids, wired intoconfig::parse_ungatedbeside its four sibling validators rather than left with no call site — the defectverbs::validatehad for its whole life (CLOUD-242). An invocation is a code span: the first predicate readrun `batten capture show <handle>` insteadas a four-word invocation ending ininsteadand reportedinsteadas an undeclared rule id, which is a finding invented out of English. See the caveats below.Closes CLOUD-1088 —
R-DECLARE-IT-STAYS-BASHclears the verdict it is offered for: a# stays-bash:line in an ADDED file admits it, and a route naming another rule's mechanism is refused at load when that rule's glob cannot select the paths the verdict is raised over.Closes CLOUD-1285 — every mediated deny carries a declared class. Eight composers moved off
Refusal::new; the prose was hardcodedformat!inhook.rsrather than config, so this is ~10 newNativevariants under the wildcard-free exhaustiveness match, not 32 config-row edits.policy_refusalwas the half-converted one and now records the token it already rendered.Closes CLOUD-1286 — the hot path emits token and pointer and stops.
render_line's unconditional gloss,Refusal::render's wrapper and the duplicated bypass sentence are all gone. Taking theFix:clause off the line broke three reachability paths, sobatten policy explaingrew a rule-id resolver, a fact-command printer and aprotected-mutation→[[redirect]]resolver before the elision was honest rather than lossy. The CLOUD-122 tension is settled in writing inREADME.md: the class name IS the pointer, one hop.Closes CLOUD-1287 — verb/operand attribution no longer stops at a newline. Narrow on purpose: segment identity is untouched, so no landed
pipelineverdict moves..claude/rules/policy-modules.md's "it under-denies, which is the sanctioned direction" sentence is corrected against the measurement. A backslash continuation splitting into two lines was a real bypass this change introduced and CodeRabbit caught;joined_linescounts trailing backslashes, with three cases over it.Closes CLOUD-1109 — paths resolve against the caller's cwd, and the refusal stops claiming tracked-ness.
Envelope::cwdwas already decoded and simply unconsumed, so one file named relatively and absolutely from one directory now yields one verdict, with nostatand no spawn. The gate decides containment; the text now says containment.Closes CLOUD-609 — a bare directory destination is inside the protected set.
cp /tmp/x .serena/memories/was allowed becausedir/is not a member ofdir/**.PathSetgained a NAMED containment method;containsis untouched and keeps meaning membership, becausescopeandunlandedare answers about files and widening it would change two callers that never asked this question.Closes CLOUD-1258 — a generic read of a memory names
read_memory. Pointer-only: the path and the redirect, never a byte. Absent Serena it allows, because a redirect naming a tool the session does not carry is CLOUD-998's defect one layer over.Closes CLOUD-1282 — the mediated column refusal is derived from the fact model rather than from six hand-written blocks.
Fact::tree_keyis the predicate, notSurface::Checkequality, and the column→Factmap is a census with a verdict perRulefield whose test reads the field list off the struct's own source. Census result, which the suite alone could not establish: no committed row inbatten.tomlorbatten.example.tomlis refused; exactly one test fixture was, correctly.Closes CLOUD-1091 — a branch behind its own receipt is not a restart.
branch_validity's comparison was symmetric and the situation is not: it fired just as readily when the receipt's base was NEWER than HEAD, where no number of fresh searches can clear it. Void only where HEAD carries commits the recorded base does not. CLOUD-516's restart is still void, which is what keeps a change that always returnedValidfrom passing.Closes CLOUD-896 — one budget for the advisory channel, not per producer. Three producers shared
additionalContextwith no rate budget;[advisory] max_tokenssupersedes the per-producer key. Admitted inAdvisoryTierorder, remainder dropped AND COUNTED, because a truncated report that reads as complete is the false green in advisory form.Closes CLOUD-417 — the row this whole PR is the concrete half of.
[hook_output]bounds what a session's hooks cost it, andmax_repeatsmakes "silence on success is the default" and "a repeat is a pointer to the first, not a copy" decidable rather than prose.Event::HookOutputcarries a count and a digest; the emitted bytes are hashed and dropped at the parse.batten policy hooksprints one line and nothing around it, and the 20% figure ships asmise run hook-costrather than as a number in a body.Closes CLOUD-1308 — a verdict rename is not a hatch newly added.
VerdictOverrideAddedwas keyed on the class id, so a class renamed with its override untouched left the old id absent and the new one present, and the new one was reported as a hatch newly added; the removal side is deliberately unreported, so nothing cancelled it out. This branch is what made it reachable — it renames every class in the registry, and two of 105 carry an override. The key is now the override's precondition rather than the id: a hatch is identified by the condition it states, so a class whose precondition already existed is that hatch relocated. The comparison still refuses to judge whether one precondition is looser than another; it compares them for identity, not strength.config lintagainstorigin/mainnow reports 0 smells where it reported 2 admitted.Caveats a reviewer should not have to find
CLOUD-1189's gate reports nothing on this tree, and that is its own premise. Measured on the committed table: 22 remedy strings, 0 naming a
batteninvocation; 44commandroutes, 0 batten-invoking. The row's acceptance is that the gate survives a surface rename by construction, so the constructed cases are what show it can fail andthe_committed_table_loadsis the anti-vacuity mirror. Its §5 asks for a[[verdict]]row and it does not ship one: a load-time refusal is aUsageErrorrather than a raised class, and a declared row nothing raises fails the load, so shipping one would break every consumer's config. Recorded on the row.Its code-span bound under-denies, deliberately. A command named in bare prose is not judged. That is asserted outright in
an_invocation_is_a_code_span_and_bare_prose_is_not_onerather than left to a reader of the predicate.Two
Weakens:trailers oncc49e0eaare withdrawn and left in place. They admitted the twoverdict-override-addedlines CLOUD-1308 has now made not fire. A trailer naming a smell nothing raises admits nothing, and rebasing 68 commits to remove two inert lines is not worth the churn; the durable correction is on CLOUD-1284's Ready block, where the matching groomed clauses were withdrawn and the claim receipt re-minted without them.One
[[pattern]]id was renamed by accident. The CLOUD-1284 sweep is keyed on the id string, so it rewrotepolicy-rule-const— whose new spelling collided with a real route id — andconfig-lintread the row as removed. Restored, withpolicy/rules-drift.rego's two reads and its fixture registry repointed. No pattern row on this branch differs frommain's now.Shape notes
The branch is keyless on purpose. Branch-name precedence beats the PR body, so a branch named for one ticket would move that ticket and strand the others; all fourteen keys close from this body instead.
WIP will read high on
graph-check. That is a reporting artifact rather than a refusal (CLOUD-502, Canceled).mise run semverreported four API-break lints across the branch —constructible_struct_adds_field,enum_no_repr_variant_discriminant_changed,function_parameter_count_changed,pub_module_level_const_missing— each declared with aBREAKING CHANGE:footer naming its cause.Four protected paths were written under issued admissions, each with its
Admits:block in the commit that made the write:batten.toml(four new tables no non-protected path can carry, and later the one-token pattern-id repair),policy/module-layering.rego(two module placements the absence-is-an-error clause demands), and.serena/memories/core.md(the matching module-map rows).Two corrections to this body's own earlier versions
CLOUD-1189 was recorded as skipped, on a false premise. The claim was that its Ready block fails
ready-lintsoclaim-checkwill not pull it. Measured,batten claim check --issue CLOUD-1189exits 0 and mints a receipt readingready-lint pass. The skip protocol — write on the issue, move it to Backlog — had not been followed either. The row is claimed, In Progress, and built above.CLOUD-1308 was filed rather than fixed, and
filed-over-own-diffrefused that. Correctly: its §1 namedcrates/batten/src/trust.rs, which this diff has open, and the gate's whole design is that a defect found in your own diff is one you are already holding the file for. Its first declared route is "close the row you filed and fix it in this diff", and the fix turned out to be ~40 lines rather than the ~105 tombstone rows the filed row had priced it at.