CLOUD-312 row 10 + CLOUD-1294: session-start.sh retires, and the gate that could not admit it - #804
Conversation
CLOUD-1085 Deleting `.claude/container-setup.sh` left no bootstrap at all: `batten` is absent at SessionStart, so the engine mediates nothing for the whole session
Why
Measured on the first session after that deletion, 2026-08-28, this container:
So for the first ~2.5 minutes of every session on a container of this class, and for the whole of any session where The failure is not loud. No host error surfaced in that session; the only hook message was Why it is more than one container's setupThe repo now carries no bootstrap. It also blocks CLOUD-312 row 10 on its merits. #714 retires Not in scope: restoring the wrapper. The design is one install script and no harness-specific bootstrap; re-adding Refinement — Ready Refinement gate: Definition of Ready & Done. This body carries only specializations.
Acceptance
Found while landing #714, by asking whether row 10's stated premise — "a container puts CLOUD-1268 Port-without-retirement: the ledger has no spelling for a suite whose subject survives — ~82s, 15 suites, and `session-start` is NOT one of them (its 135.9s is `mise install`, not the harness)
|
| filed | corrected | |
|---|---|---|
| members | 16 | 15 |
| class total | 217.9s | ~82.0s |
| "90% in three suites" | 195.3s | 59.4s in two — helpers 36.1 + pre-commit-staging 23.3 |
The remaining 13 members total ~22.6s, so this row is now a mechanism row with a modest**
**yield, not a 218-second recovery. That does not make it not worth doing — the ledger
spelling is the deliverable CLOUD-1156 deferred and nothing else can land
the class — but it must be scheduled against ~82s, and the makespan-floor argument below is
withdrawn: session-start leaves this row still standing at 135.9s, so this row does not
move the floor after Bundle A. Only its own remedy does.
And .claude/hooks/session-start.sh was never governed either
The premise that put it in this class — "the subject cannot die" — is true but irrelevant
for it. governed_at_head selects mise-tasks/ paths carrying a shebang or #MISE description, plus any .bats under tests/. A .claude/hooks/ program is in neither set,
so it is ungoverned for edits and for deletion. Filing it under "immortal subject" sent
every pass at it toward a retirement it never needed.
**Action: drop **session-start from §1's scope and from Acceptance, and file its
[tasks."test:bats"] warm-install remedy as its own row.
Why
SubjectFacts::died is .all() — every declared subject must be absent before a suite's
deletion is admitted. 16 suites declare a # subject: that is not a mise-tasks/ program:
mise.toml, hk.pkl, batten.toml, clippy.toml, install.sh, a .claude/hooks/ program,
or Rust source. Those subjects are not what the campaign retires, so .all() can never hold,
so the suites are undeletable by construction.
Measured at origin/main against bench/suites/RESULTS.md (138 suites, 1097.1s):
| suite | s | # subject: |
|---|---|---|
session-start |
135.9 | .claude/hooks/session-start.sh |
helpers |
36.1 | tests/helpers.bash |
pre-commit-staging |
23.3 | hk.pkl |
lint-deno |
5.6 | mise.toml |
hk-selection |
5.0 | hk.pkl |
spawn-census |
2.9 | clippy.toml |
lint-rego |
2.6 | mise.toml |
install |
2.2 | install.sh |
verify |
1.5 | mise.toml |
fact-record-keying |
1.0 | crates/batten/src/facts.rs |
commit-attribution |
0.6 | hk.pkl mise.toml |
commit-convention |
0.4 | batten.toml mise.toml |
task-fail-closed |
0.4 | mise.toml |
git-hook |
0.3 | .claude/hooks/git-hook.sh |
cross-check |
0.1 | mise.toml |
zizmor-split |
0.0 | mise.toml |
| 217.9 | 19.9% |
Three suites are 195.3s — 90% of the class.
The row that owns this is Done, and it deferred the recovery to a row nobody filed
CLOUD-1156 closed 2026-08-31 04:36:52, inside the same ~4-second bulk cluster CLOUD-1253
documents. PR #777 landed policy/suite-subject-retirable.rego — the detector, which
refuses a new instance arriving unnoticed. That is a real deliverable and it is not this row's
complaint.
What it deferred is stated in the module's own header, policy/suite-subject-retirable.rego:47-51:
THE THIRD ROUTE IS STILL OPEN AND IS NOT THIS MODULE'S. A suite over
batten.tomlhas
cases worth porting intocrates/batten/tests/*.rseven though nothing dies — a port
WITHOUT a retirement, which the ledger has no spelling for. Each such port is its own row.
This module only refuses a NINETEENTH instance arriving unnoticed, which is the acceptance
CLOUD-1156 owes.
No such row exists. CLOUD-1156's own Acceptance says "All 19 suites are classified:
exempted, re-subjected, or ported-without-retirement" — they are exempted, which is one of
the three, and the 217.9s is untouched. This is CLOUD-1253's class stated precisely: the row**
**closed when its analysis was finished, not when its condition was gone.
Why the two obvious routes are refused, and why the third is landable
The module's header already establishes the first two, and they are not re-litigated here:
- RE-SUBJECT — rewriting a
# subject:line is an edit to a governedtests/**/*.bats.
governed_at_headselects every bats suite, so it isV-SHELL-RULE-EDITED: one route, no
bypass_env. The one admitted edit needs every removed line to name a path the same delta
deletes, and a re-subjected header names paths that are staying. Refused. - AN IN-FILE MARKER — an added line that is neither a truncation nor a repointing. Refused
by the same arm.
The third route is landable, and that is the finding. A ledger spelling for
port-without-retirement lives in policy/shell-retirement.rego and crates/batten/src/rules.rs
— neither is governed by shell-retirement (governed_at_head selects mise-tasks/ paths
and .bats suites and nothing else). So unlike mutant.sh (CLOUD-1267) this class is not
trapped behind an unlandable edit. Nobody has tried.
And the ledger genuinely has no spelling today — checked rather than inherited.
shell-retirement.rego:62 declares
#MUTANT shell-subject-alive-unchecked|…|CLOUD-1130 — a row naming a subject this delta does NOT retire is refused, whichever of the four markers it carries, and named_and_alive
(:683-688) raises on exactly that. So CLOUD-1130 tightened this deliberately, and the new
spelling must be additive rather than a loosening of that arm — a suite deletion whose ledger
carries a complete port arm for every case, with the surviving subject named and its ongoing
coverage asserted elsewhere. .claude/rules/toolchain.md's "the ledger must name it too"
states the obligation; what is missing is a marker under which naming it is admitted.
It moves the makespan floor a second time, which is the reason to do it after Bundle A
test:bats runs --no-parallelize-within-files, so the lane's makespan cannot fall below the
longest single suite.
| longest suite | corpus | |
|---|---|---|
| today | land-lock 195.1 |
1097.1 |
| after Bundle A (CLOUD-1170) | session-start 135.9 |
866.2 |
| after this row | land 96.2 (CLOUD-1148's) |
~648 |
Bundle A hands the floor to this class. It is the only other bundle that moves the floor
rather than the total, and it becomes the binding constraint the moment Bundle A lands.
Co-dispatchable remainder — 43.7s, and it shares NO mechanism with the above
Named here so it stops being unscheduled, not because it belongs to the same unit. Each is an
independent delta and any of them can be dropped without touching the spine:
| what | s | state |
|---|---|---|
CLOUD-1163 units 6, 7, 8, 10 — token-bench(+check), suite-bench-check(+suite-bench), gh-guard(+check), perf-gate(+perf-compare) |
19.1 | groomed 2026-08-31 as landing today; dispatched nowhere |
hook-latency-drift |
24.6 | a member of CLOUD-1170's own table, "costed separately"; excluded from Bundle A by the dispatching session and in no bundle since |
Units 6, 7, 8 each declare two subjects in one suite (tests/token-bench.bats:2,
tests/suite-bench-check.bats:2, tests/gh-guard.bats:2), so both members of each pair must
die in one delta — SubjectFacts::died is .all().
Refinement — Ready (give the ledger a port-without-retirement spelling, then spend it)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Authority boundary (§1). **
policy/shell-retirement.regoandcrates/batten/src/rules.rs
for the spelling; then the suites themselves are deleted, oneconservesarm per path.
**No **mise-tasks/**program and no **tests/**/*.batsis edited or added — every change to a
suite here is a deletion, which is the only shape available. - Computable predicate (§2). A
.batsdeletion whose declared subject survives is admitted
iff the ledger carries a complete port arm naming where every case went, and refused
otherwise.named_and_alive's existing refusal stands for every other marker; this adds one
admitted shape rather than weakening an arm. - The discriminating pair (§2), and it is the whole row. A deletion with a complete port
arm passes; the same deletion with one case unported still refuses. Without the second,
the spelling is a hole and CLOUD-908's finding — "retires_withconserves files, not logic…*
*six cases have no successor anything in the tree can name" — recurs by design. - Coverage must not fall (§2). The subject is still alive and still needs testing. Each
ported case names itscrates/batten/tests/*.rshome, and the surviving subject's coverage
is asserted there. A port that deletes 36 seconds ofhelpers.batsand lands nothing is the
defect, not the deliverable. - Deliberately not in scope (§2). Changing
SubjectFacts::diedtoany— that readmits
the partial-retirement defect its own comment names, and CLOUD-1156 already rejected it.
Re-subjecting any suite. Retiringrelease-tracking-check.shorboard-payloads.sh, the two
CLOUD-1156 records as stranded by a governed co-subject — each needs this spelling first and
then its own row. - **Weakens:
rule-predicate-changedatrule[bats-tests-not-deleted].conserves
Adding a fifth[rule.conserves]arm changes that rule's predicate, whichconfig-lintraises as
a smell judged againstorigin/main. This clause was written on 2026-09-01, AFTER the work
**started, and is recorded that way deliberately. §1 mandated the[rule.conserves]change from
the beginning and never groomed the admission that makes one landable, so this is a repair to the
refinement rather than a groomed decision — and the claim receipt, minted before it, carries no
weakensline. What actually admitted the smell on PR feat(policy): a fifth conserves arm for a port without a retirement #794 was the commit trailer ALONE, which is
CLOUD-841's open hole (a receipt naming no weakening reads as "no receipt") rather than
this clause working. Stated here so the next reader of this row does not mistake the sequence. - **Effect (§3). **
readfor the policy half. - Output and exit (§5). Pointer-only: the suite path and the unported case's name. Never a
case body. - **Commit / bump (§6). **
feat(policy)for the spelling,refactor(ci)!per port — patch (0.0.x, so afeatis a patch bump and a!on a test-lane refactor carries no public surface with it). - Test obligation (§7). Over the compiled binary in
crates/batten/tests/;suite_subjects.rs
is the existing tier. Shown able to fail per CLOUD-418, plus the anti-vacuity mirror: a
conforming whole-file retirement still passes and an unexempted immortal subject is still
refused, so this does not weakensuite-subject-retirable. - Blockers (§8). None. Sequenced **after **CLOUD-1170's Bundle A, since that is what makes
session-startthe floor and this row the binding constraint.relatedToCLOUD-1156 (Done,
detector landed, this is the recovery it deferred), CLOUD-1130 (whose arm this must extend
rather than loosen), CLOUD-908 (the conserve-logic-not-files bar), CLOUD-1253 (the
closed-while-the-condition-persists class this is an instance of), CLOUD-1163 and CLOUD-1170
(the co-dispatchable remainder), CLOUD-1140 (suite cost).
Acceptance
- The ledger admits a port-without-retirement, and refuses the same deletion with one case
unported. session-start,helpersandpre-commit-stagingare ported and deleted — 59.4s,
with every case's new home named and the surviving subject's coverage asserted.
session-startis struck: its 135.9s ismise installin two cases, not the harness,
so a port pays the same seconds. See the correction at the top of this row.bench/suites/RESULTS.mdregenerates withhelpersandpre-commit-stagingabsent and the
serial total down by the measured amount. ~~If ~~~~session-start~~~~still heads that table after~~~~
and will still head the table; the makespan floor is its own remedy's to move.
* The remaining 13 members each carry a disposition: ported, or exempted with a reason that is
not "the subject cannot die" — that one is now answered.
* `suite-subject-retirable` still refuses an unexempted new instance, asserted.
```Bundle
and will still head the table; the makespan floor is its own remedy's to move.
* The remaining 13 members each carry a disposition: ported, or exempted with a reason that is
not "the subject cannot die" — that one is now answered.
* `suite-subject-retirable` still refuses an unexempted new instance, asserted.
Found by subtracting four dispatched bundles from the corpus and asking what was left: this
class was in none of them, and the session that built those bundles had recorded
`session-start.bats` as *"the largest number in the tree with no owning row"* — which was
wrong twice over, since s owns it and had already closed.
```
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-1160/retire-hooks-wiring-check-514s-47percent-the-largest-single-program">CLOUD-1160 Retire `hooks-wiring-check` — 51.4s (4.7%), the largest single-program retirement available, and UNBLOCKED: `input.tree.external` landed</a></summary>
<p>
> ## UNBLOCKED, AND THE TITLE NOW SAYS SO — 2026-08-31
>
> The title read *"… blocked only on a fact that projects a path outside the repo root"*
> while the body, two paragraphs down, already recorded that [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) is **Done** and
> `input.tree.external` is on the surface. **A correction block does not correct a title**,
> and the title is what a queue listing and every inline reference render — so this row
> read as blocked to everyone who did not open it. The `blockedBy` relation on
> [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) has been removed for the same reason.
>
> ### The seconds are also wrong, and lower than this row predicted
>
> Every figure in this body — 84.4s, then 135.1s, then "~112s is this row's alone" —
> predates [CLOUD-1198](https://linear.app/buttoninc/issue/CLOUD-1198/testbats-never-builds-the-binary-so-every-gate-pays-cargo-run-startup) landing. Measured at `origin/main` `5b98174` against
> `bench/suites/RESULTS.md` (**138 suites, 1097.1s**):
>
> `tests/hooks-wiring-check.bats` **= 51.4s, 4.7%.**
>
> The prediction was ~112s. It is 51.4s, because [CLOUD-1198](https://linear.app/buttoninc/issue/CLOUD-1198/testbats-never-builds-the-binary-so-every-gate-pays-cargo-run-startup) pre-builds and exports
> `$BATTEN_BIN`, so `setup()`'s 180 `cargo run` dispatches became a no-op rebuild check
> rather than a compile — it took far more than the ~17% gate-side share this row
> allowed for. **Report the delta against** `RESULTS.md` **at landing time, never against****
****any figure in this body.**
>
> It is still **the largest single-program retirement available**: `land-lock` (195.1s)
> and `land` (96.2s) are [CLOUD-1170](https://linear.app/buttoninc/issue/CLOUD-1170/decided-the-engine-gets-a-supplied-instant-and-a-liveness-record-never)'s and [CLOUD-1148](https://linear.app/buttoninc/issue/CLOUD-1148/landing-policy-is-2250-lines-of-button-specific-bash-a-consumer-cannot)'s, and `session-start.bats`
> (135.9s) subjects a hook program, not a `mise-tasks/` one.
>
> ### One real precondition remains, and it is not a fact
>
> [CLOUD-1193](https://linear.app/buttoninc/issue/CLOUD-1193/retire-attribution-check-config-lint-policy-budget-policy-test-and) has not named a destination for `doctor hooks`, this row's successor — see
> the spelling section below. That is a NAME, not a capability: land the predicate under
> whatever spelling exists at landing time. It does not block the work.
>
> Nothing else in the body below is changed by this note.
---
> ## REOPENED 2026-08-31 — THIS ROW WAS MARKED DONE WHILE ITS SUBJECT IS STILL IN THE TREE
> ### THE DECISIVE EVIDENCE, ADDED 2026-08-31: `MERGED` IS A FOUR-ROW CONSTANT, SO THE READ IS **DECLARED**
>
> The blocks above say [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) is Done and assert this row is therefore unblocked. **That****
****assertion was one step short**, because `[[rule.external]]` takes exactly ONE path under
> ONE named root variable per declared row — so "reads a path under `$HOME`" is NOT
> sufficient. A read whose path set is DISCOVERED at runtime is unspellable no matter how
> Done [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) is, and that is [CLOUD-1251](https://linear.app/buttoninc/issue/CLOUD-1251/ruleexternal-declares-one-path-under-one-root-so-a-set-of-out-of-root), still open.
>
> **Checked rather than inferred. **`mise-tasks/hooks-wiring-check.sh:125-128`:
>
> ```sh
> MERGED="${HOOKS_WIRING_MERGED-claude-code .claude/settings.json
> claude-code .claude/settings.local.json
> claude-code .claude/launcher-settings.json
> gemini-cli .gemini/settings.json}"
> ```
>
> Four `(harness, relative path)` pairs, written as a literal, each resolved against
> `$HOME` at `:366`. **That is exactly **`[[rule.external]]`**'s shape: four declared rows,****
****nothing discovered, nothing globbed. **[CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) answers this row completely.
>
> **The contrast is the whole reason to write this down. **`mcp-allow-check.sh:327` is
> `for candidate in /tmp/mcp-config-cse_*.json; do` — a set discovered at runtime under a
> suffix minted per session. By category the two are identical: *"reads a path outside the**
**tree."* In fact they are opposite, and only opening the program tells them apart. The
> distinction is **declared-vs-discovered, not in-root-vs-out-of-root** — and
> [CLOUD-1199](<https://linear.app/buttoninc/issue/CLOUD-1199>)'s filled second-input table records both cells side by side.
>
> ### One thing the port must carry that the seconds do not show
>
> `merged_read` is COUNTED at `:362`, deliberately, and its own comment says why: *"the**
**stale rule below needs to tell 'this row names a merged command nobody registers any**
**more' from 'no merged surface was READ AT ALL'. Zero here is could-not-look."* That is
> this row's §2 could-not-look clause already implemented in the bash, and it is the arm a
> careless port drops — losing it ships the gate silently off on every CI runner, which is
> the permanent state of one.
> Set Done at `04:36:54.979` **directly from Todo** — the state history records no In
> Progress and no In Review, and no PR is attached. It was closed inside a ~4-second
> bulk cluster (`04:36:52`–`04:36:55`) with roughly twenty other rows, so Done was not
> set per-row from evidence.
>
> Checkable rather than asserted, against `origin/main` `0683ce53`:
>
> ```
> git cat-file -e origin/main:mise-tasks/hooks-wiring-check.sh # exists
> git cat-file -e origin/main:tests/hooks-wiring-check.bats # exists
> ```
>
> **Both paths this row exists to delete are still tracked.** AGENTS.md is explicit
> that Done means *released* and is never the merge's to set; nothing was released here
> because nothing landed. `done-check` did not catch it, which is the class defect
> rather than this row's — see the row filed for it.
>
> **AND IT IS NOW GENUINELY UNBLOCKED, which is why this matters beyond bookkeeping.**
> Its one blocker, [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed), is Done and `input.tree.external` is on the surface
> — a declared path beneath a named root variable, which is exactly what
> `hooks-wiring-check.sh:366`'s `$HOME/$merged_rel` needs. At **135.1s** this is the
> **second-largest suite in the tree and the largest single-program retirement****
> ****available**, and closing it as Done took it off the queue rather than landing it.
>
> Nothing else in the body below is changed by this note.
---
> ## RE-MEASURED 2026-08-30 against `origin/main` @ `2fe242e` — and ~83% of the cost is this row's alone
>
> **This suite is 135.1s / 11.1%, not 84.4s / 6.8%.** It grew **+51 seconds in 33 commits** and is now the **second-largest suite in the tree**, behind `derived-check` (289.8s). The corpus is 145 suites / 1219.4s, not 147 / 1244.6s. Every figure below that says 84.4s understates it, and the ranking sentences built on it are off by a place.
>
> ### ~83% of this suite's cost is reclaimable ONLY by this retirement — pressure-tested, and it corrects a claim first written here
>
> This block first said the cost was reachable ahead of the blocker via the injectable seam at `:219`. Reading `tests/hooks-wiring-check.bats` **refutes most of that**, and the refutation raises this row's priority rather than lowering it.
>
> `setup()` **runs before every case and dispatches **`cargo run` **five times** — `:67` loops `emit_wiring` over five harnesses and `:85` is `derived=$(cd "$REPO" && cargo run --quiet -p batten -- generate hooks --harness "$1")`. At **36 cases** that is **180 dispatches**, against roughly **36** gate-side.
>
> | dispatches | share | reachable without retiring? |
> | -- | -- | -- |
> | **180** — suite `setup()` (`:85`) | **~83%** (~112s) | **NO.** Inline in a governed `.bats`; there is no seam, and `V-SHELL-RULE-EDITED` refuses adding one |
> | ~36 — gate (`:219`) | ~17% (~23s) | yes, via `HOOKS_WIRING_DIAGNOSIS` — [CLOUD-1198](https://linear.app/buttoninc/issue/CLOUD-1198/testbats-never-builds-the-binary-so-every-gate-pays-cargo-run-startup) |
>
> **So this row owns ~112s that nothing else can reach**, and the seam row owns ~23s. That makes [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) — the blocker — the gate on the larger half, not a formality to route around. **The earlier "the cost is reachable BEFORE this row lands" framing was wrong and is withdrawn.**
>
> `:219`'s seam:
>
> ```sh
> diagnose="${HOOKS_WIRING_DIAGNOSIS-cargo run --quiet --manifest-path $crate_root/Cargo.toml -p batten -- doctor hooks -J}"
> ```
>
> What [CLOUD-1198](https://linear.app/buttoninc/issue/CLOUD-1198/testbats-never-builds-the-binary-so-every-gate-pays-cargo-run-startup) **costs this row, recorded here because it asks:** once the seam is exported suite-wide, **no case exercises that default line** — today ~34 do, so a typo in it would be untested. It cannot be bought back there (a new case is a governed `.bats` edit, refused at `deny`). **This row deletes the line, so the gap closes here** — which is why it is acceptable to open at all.
**Why**
Unit 12 of the 83-unit partition. **1 program, 1 suite, 84.4s — 6.8% of the 1244.6s serial suite.** The **largest single-program retirement in the tree**, and the third-largest unit of any kind.
461 lines, `tests/hooks-wiring-check.bats` 84.4s, in `$MUTANT_GATES`, 1 `#MUTANT` row.
**Nothing references it from any governed file.** Two deleted paths, two ledger arms, zero drag-in. On landability alone it is among the cheapest retirements available.
## The one thing that blocks it, and it is not a reference
`mise-tasks/hooks-wiring-check.sh:366` — `merged_file="$HOME/$merged_rel"`.
The engine's observable surface has **no fact that projects a path outside the repository root**. `input.tree.*` is repo-rooted by construction; the 26 `Fact` variants at `crates/batten/src/facts.rs:404-480` carry no out-of-root reader. So a successor cannot see the file this gate's whole purpose is to compare against.
That fact family is now its own row: [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed), which this row is `blockedBy`. When filed, this row carried the precondition itself — wrongly, because eight other programs need the same fact and none of them should wait on a retirement ticket to land it. The fact lands there; this row consumes it.
**This is the successor-feasibility class, not the landability class**, and the distinction is why this row exists: a reference census classed this program FREE and it is not retirable. `mcp-attach-check.sh:135` (`${HOME}/.cache/claude-cli-nodejs/...`), `transcript-corpus-check.sh:97` (`$HOME/.claude/projects`), `sbom.sh:321` (`${CARGO_HOME:-$HOME/.cargo}`), `doctor.sh:73` and `doctor-check.sh:52` are the same class. **This row is where that fact family gets decided**, because it is the largest instance.
`:219` also runs `cargo run … doctor hooks -J` — but that is `batten` **itself**, so folding it in is the port rather than a blocker.
## HOME (2026-08-30): `batten doctor hooks`, an existing §2 verb — and the scope objection this answers
The scope reminder says Batten is *"**not** a hook runner"*, which reads at first as refusing this predicate outright. It does not, and the distinction matters: **running** hooks is out of scope; **diagnosing that Batten's own registration is correct** is `doctor`'s literal job — §2 lists `doctor <SUB> diagnose environment; nests focused sub-diagnostics (read)`.
And the sub-verb already exists: this program calls `cargo run … doctor hooks -J` at `:219`. So the successor is not a new verb and not a new module — it is the `doctor hooks` **sub-diagnostic this program already delegates to, doing the whole job instead of half of it.** §2's surface does not move ([CLOUD-1176](<https://linear.app/buttoninc/issue/CLOUD-1176/the-retirement-campaign-has-one-disposition-port-it-into-the-core-so>)).
That also settles why [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed)'s out-of-root fact is legitimate rather than core-widening: `doctor` diagnoses the **environment**, and a harness's wiring files live under `$HOME` by that harness's design. A declared, bounded projection of paths a consumer's own config names is a fact like any other — the paths come from config, so rule 1 holds.
### The successor's SPELLING is not stable, and this row must not hard-code it (2026-08-30)
`doctor hooks` (`crates/batten/src/surface.rs:1972`) is a noun-headed path, and the imperative `VERB OBJECT` grammar [CLOUD-1184](https://linear.app/buttoninc/issue/CLOUD-1184/the-command-surface-has-no-grammar-so-38-verbs-spell-50-leaves-and-two) declares asserts that **every path's first token is a registry verb**. `doctor` is not one. [CLOUD-1193](<https://linear.app/buttoninc/issue/CLOUD-1193/retire-attribution-check-config-lint-policy-budget-policy-test-and>) retires bare `doctor` as a **withdrawal** — and its table does not list `doctor hooks` at all.
**So this row currently names a successor that is scheduled for deletion and has no recorded replacement.** The predicate is unaffected — diagnosing Batten's own registration is still the right job in the right place, and [CLOUD-1176](https://linear.app/buttoninc/issue/CLOUD-1176/the-retirement-campaign-has-one-disposition-port-it-into-the-core-so)'s EXISTING-VERB home still holds. What is unsettled is the four characters it is invoked by.
Do not resolve that here. [CLOUD-1193](<https://linear.app/buttoninc/issue/CLOUD-1193/retire-attribution-check-config-lint-policy-budget-policy-test-and>) owns naming the destination for every leaf under the nouns it dissolves; `check wiring` is the natural rule id under the grammar, but that is [CLOUD-1193](https://linear.app/buttoninc/issue/CLOUD-1193/retire-attribution-check-config-lint-policy-budget-policy-test-and)'s call to record. This row lands the predicate under whatever spelling [CLOUD-1193](https://linear.app/buttoninc/issue/CLOUD-1193/retire-attribution-check-config-lint-policy-budget-policy-test-and) **names**, and if this row lands first it lands under `doctor hooks` and moves with the wave like every other call site.
## What the gate does, so the successor is not guessed
[CLOUD-777](https://linear.app/buttoninc/issue/CLOUD-777/batten-is-registered-exactly-once-on-every-hook-surface-of-every)'s decision in one sentence: *"batten is hooked exactly once on every available hook surface in every harness we support, and nothing else registers a hook."* It compares **selected entries inside a shared file**, canonicalized — not a whole artifact against a rendered one, which is why it is a separate program from `derived-check` and stays separate through the port.
`WiringFile` records which hosts define a hooks-only file and which merge into a shared one (`.claude/settings.json`, Gemini). The `DECLARED` table at `:178-181` is the row-per-line shape `truncates_a_retired_reference` was built for, so dropping a row is already an admitted edit — but that is a maintenance affordance, not a retirement route.
---
**Refinement — Ready (largest single retirement; blocked on the out-of-root fact)**
*Refinement gate: [Definition of Ready & Done](<https://linear.app/buttoninc/document/definition-of-ready-and-done-e4e8defb6774>). This body carries only specializations.*
* **Authority boundary (§1). **`mise-tasks/hooks-wiring-check.sh` and `tests/hooks-wiring-check.bats` are **deleted** — two paths, two `// carried:` arms. The successor is the existing `doctor hooks` sub-diagnostic (§2), widened to carry the whole predicate, plus a `crates/batten/tests/*.rs` tier, reading [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed)'s out-of-root fact. **No new verb; §2's surface does not move.** The `$MUTANT_GATES` entry and the one `#MUTANT` row move with it. `hk.pkl:317` calls it by task name and is ungoverned.
* **The precondition, owned elsewhere (§2).** A fact projecting a **declared, bounded** set of paths outside the repository root — the harness wiring files a consumer's `[host]` config names, never an arbitrary `$HOME` read. That is [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) **and it does not land here.** This row consumes it and asserts the wiring predicate over it.
* **Computable predicate (§2), unchanged in substance.** Batten is registered exactly once on every hook surface every supported harness offers, matching what `generate hooks` derives, and nothing else registers a hook. A derived event with no registration is `wiring-event-unregistered`.
* **Could-not-look is distinct from clean (§2).** A wiring file the engine cannot read — absent `$HOME`, no permission, a host that ships none — is `Look::CouldNotLook` and must be assertable apart from "registered correctly". Rego reads undefined as *does not hold*, so collapsing them ships a gate that is silently off.
* **Deliberately not in scope (§2).** Changing [CLOUD-777](https://linear.app/buttoninc/issue/CLOUD-777/batten-is-registered-exactly-once-on-every-hook-surface-of-every)'s decision or which harnesses are supported. Retiring `mcp-attach-check`, `transcript-corpus-check`, `sbom` or `doctor` — each needs the same fact and each is its own row. Reading a consumer's file layout from `crates/batten`, which non-negotiable rule 1 forbids: the paths come from config.
* **Effect (§3). **`read`. It reads wiring files and compares; it writes nothing.
* **Output and exit (§5).** Pointer-only: the harness, the event, and the file it was read from — **never the file's contents**, which carry a consumer's permissions and MCP config. Exit follows the `0/1/2/3` table; could-not-look is `3`.
* **Commit / bump (§6). **`refactor(ci)` — **no bump**. Below `0.1.0` every *release-worthy* type collapses to a patch, but `refactor` is not one: it releases nothing at any version. [CLOUD-595](https://linear.app/buttoninc/issue/CLOUD-595/cloud-228-fixed-the-6-gate-and-left-the-corpus-behind-so-refined-phase)'s correction. The fact-family half is [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed)'s `feat(facts)` and is not this PR's bump.
* **Test obligation (§7).** Over the compiled binary in `crates/batten/tests/`; **no **`.bats` **file is added or edited**. **Two **`// carried:` **arms**, one per deleted path, each naming where its cases went ([CLOUD-908](https://linear.app/buttoninc/issue/CLOUD-908/retires-with-conserves-files-not-logic-the-one-completed-port-deleted)). **Shown able to fail** per [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate): a harness missing a registration is reported; a duplicate registration is reported; **an unreadable wiring file is could-not-look and NOT reported as clean** — the third is the one a careless port drops, and it is the whole risk of moving an out-of-root read into the engine. Anti-vacuity: a correctly wired tree passes. **Mutated:** the one `#MUTANT` row re-homed, `mutant-census` green.
* **Blockers (§8). **`blockedBy` [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) — the out-of-root fact family, which this row originally carried as its own precondition and no longer does. `relatedTo` [CLOUD-1151](https://linear.app/buttoninc/issue/CLOUD-1151/waves-2-and-3-have-never-had-an-owner-cloud-843-warned-of-it-cloud-910) (the wave owner), [CLOUD-1159](https://linear.app/buttoninc/issue/CLOUD-1159/retire-the-sbomrelease-artifact-unit-9-programs-598s-glued-by-six) (`sbom`, same fact needed), [CLOUD-1140](https://linear.app/buttoninc/issue/CLOUD-1140/cloud-398s-slice-1-never-landed-the-shell-suite-is-still-83percent-of) (suite cost), [CLOUD-908](https://linear.app/buttoninc/issue/CLOUD-908/retires-with-conserves-files-not-logic-the-one-completed-port-deleted), [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate).
**Acceptance**
* Both paths deleted with one ledger arm each; no other governed file edited.
* [CLOUD-1167](https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed) has landed and this row's successor reads its projection rather than re-deriving a path expansion.
* An unreadable wiring file exits `3` and is distinguishable from a clean one, asserted.
* No consumer file contents appear in any output, asserted.
* The predicate lands under the existing sub-diagnostic — `doctor hooks` today, or whatever spelling [CLOUD-1193](<https://linear.app/buttoninc/issue/CLOUD-1193/retire-attribution-check-config-lint-policy-budget-policy-test-and>) has named by then; `batten spec --format json` emits exactly the committed row set, with **no new verb** either way.
* `bench/suites/RESULTS.md` regenerates with `tests/hooks-wiring-check.bats` absent and the serial total down by the **measured** amount — ~135.1s if this row lands first, ~112s if [CLOUD-1198](https://linear.app/buttoninc/issue/CLOUD-1198/testbats-never-builds-the-binary-so-every-gate-pays-cargo-run-startup) **has already taken the gate-side ~17%.** Report which, rather than restating a figure taken before either. **The ~83% is this row's either way**, which is the corrected reading above.
Unit 12 of 83. Previously mis-classified FREE by a reference-only census; the blocker is expressibility, not landability.
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-312/the-engine-is-the-pre-tool-entry-point-the-shell-guards-retire-behind">CLOUD-312 The engine is the pre-tool entry point; the shell guards retire behind it</a></summary>
<p>
## Why
The pre-commit layer and CI are already adjudicated by the engine reading the committed authority. The agent tool-call layer is not: `.claude/settings.json` wires seven `PreToolUse` entries — `gh-guard`, `ready-guard`, `issue-guard`, `run-shape-guard`, `memory-guard` (twice, once per matcher), `claim-guard` — every one a `mise run` of a shell task carrying its own decision table, and `batten hook` appears in that file **zero** times.
Two implementations of one policy is two authorities for one fact, and the divergence is silent. A rule added to `batten.toml` does not reach the tool call, and a guard's table cannot be read from the config a reviewer reviews. `crates/batten/src/hook.rs` is the port of those guards and its own header describes the compatibility path as lasting "while they exist" — this issue is what ends that period.
It also makes the README's three-layer claim true. Today one third of it describes the design rather than the state.
**The counts in this section are the pre-wiring state and are kept as the historical baseline, not as current fact.** Re-counted 2026-08-20: `.claude/settings.json` carries **thirteen** registrations across six events, of which **one** reaches `batten hook` — the single `PreToolUse` entry. The remainder are `SessionStart` ×2, `UserPromptSubmit` ×2, `Stop` ×1, six further `PreToolUse` shell entries across five matchers, and `PostToolUse` ×1. [CLOUD-713](https://linear.app/buttoninc/issue/CLOUD-713/nothing-counts-the-pretooluse-commands-that-are-not-battens-so-six) owns the census that keeps that number honest; [CLOUD-777](https://linear.app/buttoninc/issue/CLOUD-777/batten-is-registered-exactly-once-on-every-hook-surface-of-every) owns getting the engine onto every surface exactly once.
## Mechanism
* Each `PreToolUse` entry invokes the engine with the harness adapter for the host. The decision comes from the `mediated_call`-scoped rows of the resolved config and from nowhere else.
* Every refusal a retiring guard renders is expressed as a config row with a required `reason` **before** that guard is removed. A guard is deleted only once its refusals are reproduced from config.
* Fail-open posture is preserved end to end: unreadable stdin, an unparseable payload, or a missing binary all resolve to allow, and the existing bypass variables keep working.
## Ready
* **Source of truth (§1).** The committed `batten.toml` is the only table a mediated call is judged against. No decision table remains in `mise-tasks/`.
* **Mechanism as a predicate (§2).** Two gates, both exiting `0`:
1. a differential suite replays every payload fixture in the existing guard `.bats` suites through the engine and asserts the same decision and the same reason text;
2. a source-level assertion fails if any `PreToolUse` entry in the settings file invokes a task that carries a decision table.
* **Effect (§3).** No new command surface: `hook` already exists and is already classified. What changes is who invokes it.
* **Output & exit (§5).** Every retiring guard's refusal keeps its reason text, which is what the differential suite asserts; the deny channel per host is the one `Capabilities` declares. Fail-open is preserved end to end — unreadable stdin, an unparseable payload, or a missing binary all resolve to allow — so no failure code Batten can produce is one a host reads as a deny.
* Commit / bump (§6). `feat` → patch until `0.1.0`: below `0.1.0` release-plz bumps the patch whatever the type says.
* **Test obligation (§7).** The differential suite in §2, plus the settings-file assertion, both under `mise run verify` and CI. A guard is deleted only once its fixtures pass through the engine, so coverage never drops below what the retiring guard had.
* **Blockers (§8). Superseded — see "Blockers, re-verified" below**, which is the live list. Two rows were named here when this was written; both are resolved and their relations removed, and they are named there with their evidence. Repeating them here would be a blocker citation with no relation behind it, which `ready-lint` reports as `blocker-cited-without-relation` — measured on this row 2026-08-22, two violations, caused by removing the relations without editing this sentence. The live `blockedBy` relations are [CLOUD-924](https://linear.app/buttoninc/issue/CLOUD-924/no-rule-kind-keys-on-the-tool-a-mediated-call-names-so-the-two) and [CLOUD-925](https://linear.app/buttoninc/issue/CLOUD-925/budget-counts-a-file-set-so-a-per-call-ceiling-is-inexpressible-and), per row rather than campaign-wide.
## The gap is measured, not asserted
Counted against `main`: seven `PreToolUse` entries, zero invocations of the engine. The port itself is not the missing piece — `hook.rs` carries six harness adapters over a harness-blind core, its `mediated_call` matcher, and a per-host capability table — so what remains is the wiring and the config rows that make each retiring guard's refusal reproducible.
One clarification for whoever picks this up, because the neighbouring language invites the wrong move: the table `hook` must read is the `mediated_call`-scoped rows of `batten.toml`, not `crates/batten/src/effect.rs`. That module classifies Batten's *own* command surface for the §5 read-only allowlist, and its consumer is `spec.rs`. Two declared tables, two different objects; importing one into the other would put a classification of Batten's verbs in the path that judges a consumer's shell commands.
## Done
`main` carries the engine as the pre-tool entry point with the differential suite green, no guard-local decision table remains, and CI is green on the merge commit landed by fast-forward.
---
# The remaining inventory, re-counted 2026-08-22 against `main` (`170c7c4`)
This section is the campaign's operative content. Everything above it is history: the counts in **Why** are the pre-wiring baseline, and the `## Ready` block's §8 is superseded by **Blockers, re-verified** below.
## What has already changed under this row
* Registration is finished. `CLAUDE_EVENTS` carries **eight** events (`hook.rs:1013`, `UserPromptSubmit` added by [CLOUD-777](https://linear.app/buttoninc/issue/CLOUD-777/batten-is-registered-exactly-once-on-every-hook-surface-of-every)) and `.claude/settings.json` registers `batten hook --harness claude-code` **matcherless on all eight**. There is nothing left to register, and no new registration is planned by any row in this bundle. A row proposing one is proposing a second narrowing.
* The census moved in-process. `batten doctor hooks` (`doctor.rs:225-344`) computes the diagnosis from `WiringFile` and `Wiring::registrations`, reporting `registrations` / `siblings` / `merged` / `merged_surfaces_read` and eight stable reason ids — including `hook-wiring-merged-registration`, so a registration on a `$HOME` surface the repository does not own is **visible**. `hooks-wiring-check.sh` is now the thin caller holding this consumer's `DECLARED` table (`:168-180`).
* The door exists and has a worked example. `[[hook.handler]]` landed ([CLOUD-898](https://linear.app/buttoninc/issue/CLOUD-898/hookhandler-one-door-to-the-hooking-surface-so-the-contract-is)) and `batten.toml:1912` dispatches `mcp-attach-check` through it. That guard is therefore **already retired from this table** — it is dispatched by `batten hook`, not registered beside it.
## The thirteen remaining rows
One row per entry in `hooks-wiring-check.sh`'s `DECLARED` table. **Destination is the load-bearing column**: durable policy goes to core/config, and a `[[hook.handler]]` is used only where an external program intentionally remains. **One of thirteen qualifies** — row 8, landed in #707; assuming every script becomes a handler would move twelve decision tables out of the committed authority and behind a dispatch.
**The table below is a snapshot and** `main` **has moved past it.** Re-counted 2026-08-27 against `origin/main`: `hooks-wiring-check.sh`'s `DECLARED` table carries **four** rows — `.claude/hooks/session-start.sh` ([CLOUD-312](https://linear.app/buttoninc/issue/CLOUD-312/the-engine-is-the-pre-tool-entry-point-the-shell-guards-retire-behind), row 10), `mise-tasks/run-shape-guard.sh` ([CLOUD-821](https://linear.app/buttoninc/issue/CLOUD-821/run-shape-guard-has-never-been-registered-so-agentsmds-gated-by-run), row 11), and the two out-of-repo merged `$HOME` rows `stop-hook-git-check.sh` and `session-start-git-identity.sh` ([CLOUD-605](https://linear.app/buttoninc/issue/CLOUD-605/a-user-level-stop-hook-instructs-the-exact-commit-identity-battentoml), rows 12 and 13). Rows 1-9 have retired. **Row 10's destination is contested and open**: PR #714 retires `session-start.sh` behind `[[hook.handler]]` with a measured 5s bound and drops its `DECLARED` row, and there is an unresolved bootstrap-circularity objection to that on the PR itself. Its landing is what settles the row; nothing here pre-empts it.
| # | Event / matcher | Command (lines) | Owner | Destination | Blocker & ordering |
| -- | -- | -- | -- | -- | -- |
| 1 | PreTool `.*save_issue` | `mise-tasks/issue-search-guard.sh` (93) | **312** | config — a `receipt` row over the search receipt | none; first in the board family |
| 2 | PreTool `.*save_issue` | `mise-tasks/issue-read-guard.sh` (117) | **312** | config — a `receipt` row with the recency bound `facts::Sourced` borrowed from it | none; after 1 (shares the matcher and the receipt store) |
| 3 | PreTool `.*save_issue` | `mise-tasks/board-move-guard.sh` (158) | **312** | config — a `receipt` row keyed on the issue key | none; after 2 |
| 4 | PreTool `.*(subscribe_pr_activity\|send_later\|create_trigger)` | `mise-tasks/connector-verb-guard.sh` (174) | **312** | config — but the predicate is a **tool-name suffix**, and no rule kind selects on one today; `[[verb]]` names a shell program | **blocked** on [CLOUD-924](https://linear.app/buttoninc/issue/CLOUD-924/no-rule-kind-keys-on-the-tool-a-mediated-call-names-so-the-two) — no rule kind keys on the tool a call names, and this guard matches by SUFFIX deliberately |
| 5 | PreTool `^mcp__` | `mise-tasks/connector-allow-guard.sh` (88) | **312** | config — needs a connector-grant table in `batten.toml`; the grants live in `.claude/settings.json` today | **blocked** on [CLOUD-924](https://linear.app/buttoninc/issue/CLOUD-924/no-rule-kind-keys-on-the-tool-a-mediated-call-names-so-the-two) (the selector), plus that grant table |
| 6 | PreTool `Task` | `mise-tasks/fanout-guard.sh` (158) | **312** | config — `Field::Prompt` exists, but `[budget.<name>]` is a **file-set** budget over globs, not a per-call ceiling | **blocked** on [CLOUD-925](https://linear.app/buttoninc/issue/CLOUD-925/budget-counts-a-file-set-so-a-per-call-ceiling-is-inexpressible-and) — `[budget]` counts a file set, so a per-call ceiling is inexpressible |
| 7 | PostTool `.*save_issue\|.*save_comment` | `mise-tasks/board-write-record.sh` (329) | **312** | **core** — it derives a record from a tool response, which is exactly the capture bundle's first consumer | **unblocked. **[CLOUD-919](https://linear.app/buttoninc/issue/CLOUD-919/persist-every-posttooluse-response-as-a-local-capture) is **Done** (verified 2026-08-27), so the ordering constraint that held this row is discharged and it is actionable work |
| 8 | UserPromptSubmit | `mise-tasks/mcp-allow-check.sh --session` (415) | **312** | **handler** — reads settings files and MCP client logs, not the envelope; its sibling `mcp-attach-check` already went this way | none; the door is landed |
| 9 | Stop | `mise-tasks/stop-guard.sh` (318) + five gates (1,412) | **892** | config / core | [CLOUD-892](https://linear.app/buttoninc/issue/CLOUD-892/the-stop-surface-retires-into-batten-hook-1412-lines-of-bash) owns it end to end |
| 10 | SessionStart | `.claude/hooks/session-start.sh` (295) | **312** | **handler** — it provisions a toolchain and preflights the container. There is no decision table in it to move; it is deliberately synchronous and deliberately loud on failure | none, but see the bound below |
| 11 | PreTool `Bash` | `mise-tasks/run-shape-guard.sh` (647) | **821** | config, partially — `Field::RunInBackground` landed, so the exemption predicate is expressible | [CLOUD-613](https://linear.app/buttoninc/issue/CLOUD-613/run-shape-guards-last-two-families-need-facts-the-mediated-envelope) for the heredoc-binding family; [CLOUD-821](https://linear.app/buttoninc/issue/CLOUD-821/run-shape-guard-has-never-been-registered-so-agentsmds-gated-by-run) owns the row |
| 12 | Stop, merged `$HOME` | `stop-hook-git-check.sh` | 605 / **893** | out of repo — not ours to port | [CLOUD-893](https://linear.app/buttoninc/issue/CLOUD-893/three-hooks-own-the-stop-boundary-one-is-invisible-to-this-repos-own) owns visibility, [CLOUD-605](https://linear.app/buttoninc/issue/CLOUD-605/a-user-level-stop-hook-instructs-the-exact-commit-identity-battentoml) the identity conflict |
| 13 | SessionStart, merged `$HOME` | `session-start-git-identity.sh` | 605 / **893** | out of repo — same | as 12 |
### Row 10 carries a bound the door does not give for free
`[[hook.handler]]` imposes a `timeout_ms`, and this script's whole reason for existing is that a cold `mise install` inside the MCP client's startup window took 24s. A bound tighter than the cold path turns a fail-open handler into the absence the hook was built to close. So its handler row declares a measured bound, and the migration records the cold measurement beside it — the same standard `mcp-attach-check`'s `timeout_ms = 2000` was held to.
## Per row, the two obligations this issue has always carried
Unchanged in substance from **Mechanism** above, restated because the table needs them per row:
* **Differential test.** Every refusal the retiring script renders is reproduced from the committed authority *before* the script is deleted, proved by replaying that script's own `.bats` fixtures through the engine and asserting the same decision and the same reason text. A handler destination has the same obligation with the door in the path: the fixture goes through `batten hook`, and the reply is byte-compared.
* **Exact deletion condition.** The script, its `DECLARED` row, and its bats suite go in one change, and only once its fixtures pass through the engine — so coverage never drops below what the retiring guard had. A `DECLARED` row naming a deleted command already fails as `wiring-declaration-stale`, and a command with no row already fails as `wiring-sibling-command`, so both directions of the deletion are gated rather than reviewed.
## Blockers, re-verified 2026-08-22 — this supersedes §8 above
* [CLOUD-446](https://linear.app/buttoninc/issue/CLOUD-446/issue-guard-cannot-retire-the-key-check-is-expressible-the-claimed-key) **— cleared, Done.** The claimed-key lookup it called unreachable from the mediated path is reachable: [CLOUD-776](https://linear.app/buttoninc/issue/CLOUD-776/agent-sourced-facts-deny-with-fixrun-read-the-result-off-posttooluse) landed the agent-sourced fact channel, and `claim-not-raced` is its worked instance.
* [CLOUD-461](https://linear.app/buttoninc/issue/CLOUD-461/batten-hook-has-no-advisory-channel-so-contract-drift-cannot-retire) **— cleared, landed (In Review).** The advisory channel is on `main`, and `contract-drift` retired with it. Its own release is not this row's precondition.
* **Rows 1-3 are no longer unblocked, and the line below is superseded by this one.** Re-verified 2026-08-27 against the live relations: this row's `blockedBy` set is [CLOUD-924](https://linear.app/buttoninc/issue/CLOUD-924/no-rule-kind-keys-on-the-tool-a-mediated-call-names-so-the-two), [CLOUD-925](https://linear.app/buttoninc/issue/CLOUD-925/budget-counts-a-file-set-so-a-per-call-ceiling-is-inexpressible-and), [CLOUD-987](https://linear.app/buttoninc/issue/CLOUD-987/a-mediated-row-cannot-condition-on-the-arguments-a-call-names-so-cloud) (a mediated row cannot condition on the arguments a call names, so rows 1-3 have nothing to retire onto) and [CLOUD-988](https://linear.app/buttoninc/issue/CLOUD-988/a-receipt-row-cannot-declare-a-maximum-age-so-cloud-508s-recency-bound) (a `receipt` row cannot declare a maximum age, so row 2's recency bound has no config surface). The sentence "Nothing blocks rows 1, 2, 3, 8, 10" below was true when written and is not now: of that set only rows 8 and 10 are unblocked, and 8 has landed.
* **New, per row rather than campaign-wide, and filed rather than deferred:** rows 4 and 5 are blocked on [CLOUD-924](https://linear.app/buttoninc/issue/CLOUD-924/no-rule-kind-keys-on-the-tool-a-mediated-call-names-so-the-two) (no rule kind keys on the tool a mediated call names); row 5 additionally needs a connector-grant table in `batten.toml`; row 6 is blocked on [CLOUD-925](https://linear.app/buttoninc/issue/CLOUD-925/budget-counts-a-file-set-so-a-per-call-ceiling-is-inexpressible-and) (`[budget]` counts a file set, so a per-call ceiling is inexpressible); row 7 is ordered after [CLOUD-919](https://linear.app/buttoninc/issue/CLOUD-919/persist-every-posttooluse-response-as-a-local-capture). Nothing blocks rows 1, 2, 3, 8, 10.
* **Two rows first named here as blockers are Done, and naming them would have been the defect this table gates against. **[CLOUD-684](https://linear.app/buttoninc/issue/CLOUD-684/mcp-allow-rules-name-server-labels-that-host-supplied-servers-never) (MCP allow rules naming labels host servers never register under) and [CLOUD-734](https://linear.app/buttoninc/issue/CLOUD-734/re-project-the-committed-mcp-grants-onto-the-sessions-generated-config) (re-projecting the grants at SessionStart) are both closed. What row 5 actually lacks is a config surface, which is why [CLOUD-924](https://linear.app/buttoninc/issue/CLOUD-924/no-rule-kind-keys-on-the-tool-a-mediated-call-names-so-the-two) exists and those two do not appear above.
Stating them per row is the correction: a single campaign-wide `blockedBy` is what let this row sit blocked on a capability that only one of its thirteen entries needed.
## The end-state test
Three predicates, all decidable by machinery that exists:
1. **Exactly one Batten registration per supported event**, per harness — `doctor hooks` already fails `hook-wiring-event-registered-n-times` and `hook-wiring-event-unregistered`, and `hook-wiring-matcher-narrows` on any matcher at all.
2. **No unmanaged sibling command** — `doctor hooks` reports `siblings == 0` and `merged == 0`, **or** every remainder is a `DECLARED` row naming a key that is still open. A row naming a closed key already fails, which is what keeps this from becoming a permanent waiver list.
3. **Every remaining dispatched behaviour is declared in committed configuration and validated from it** — each surviving program is a `[[hook.handler]]` row in `batten.toml` with a declared bound, and its behaviour is pinned by a differential case run through the door. Nothing reaches a hook surface that the committed authority does not name.
**Done** is the three above holding together, with `main` green: not "the scripts are gone", because a deleted script whose refusals nothing reproduces is a coverage loss wearing a retirement's clothes.
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-1167/no-fact-projects-a-path-outside-the-repository-root-so-9-governed">CLOUD-1167 No fact projects a path outside the repository root, so 9 governed gates have no successor — the harness wiring files they exist to read are all under `$HOME`</a></summary>
<p>
**Why**
`input.tree.*` is repo-rooted by construction, and none of the 26 `Fact` variants at `crates/batten/src/facts.rs:404-480` reads a path outside the repository root. **Nine governed programs exist precisely to compare against a file that lives outside it**, so each has a successor that cannot see its own subject.
Measured in command position, 2026-08-29:
| program | line | the path it must read |
| -- | -- | -- |
| `hooks-wiring-check.sh` | `:366` | `$HOME/$merged_rel` |
| `mcp-attach-check.sh` | `:135` | `${HOME}/.cache/claude-cli-nodejs/…` |
| `mcp-timeout-budget.sh` | `:279` | `${HOME}/.cache/claude-cli-nodejs/…` |
| `transcript-corpus-check.sh` | `:97` | `${BATTEN_TRANSCRIPT_ROOT:-$HOME/.claude/projects}` |
| `sbom.sh` | `:321` | `${CARGO_HOME:-$HOME/.cargo}` |
| `doctor.sh` | `:73`, `:75` | `${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/mise}` |
| `doctor-check.sh` | `:52`, `:53`, `:54` | `$rustlib/…` — rustup's toolchain root |
| `connector-allow-resolve.sh` | `:142` | `/tmp/mcp-config-cse_*.json` |
| `mcp-allow-check.sh` | `:327` | `/tmp/mcp-config-cse_*.json` |
**THIS LIST IS A LOWER BOUND, and the ninth row is why it must say so.** It was built by scanning for `$HOME` and the sibling root variables, and `doctor-check.sh` was missed on the first pass because its root reaches out of tree through an **intermediate variable** (`$rustlib`, assigned earlier from rustup's layout) — the literal token never appears on the reading line. Any program indirecting a root the same way is still invisible to that method. The member list is therefore evidence that the class is at least this large, never that it is exactly this large; [CLOUD-1174](https://linear.app/buttoninc/issue/CLOUD-1174/the-campaign-conflates-two-orthogonal-axes-seconds-belong-to-units-a)'s generated artifact is what replaces the scan with a derivation.
`hooks-wiring-check` alone is **135.1s — 11.1% of the 1219.4s corpus, the second-largest suite in the tree** behind `derived-check` (289.8s). *(Re-measured 2026-08-30 against* `2fe242e`*; this row and* [CLOUD-1160](https://linear.app/buttoninc/issue/CLOUD-1160/retire-hooks-wiring-check-1351s-111percent-the-second-largest-suite-no) *both said 84.4s, taken on a 33-commit-stale tree — it grew 51 seconds in that window.)* And **~83% of that is fixture-side**, spawned inline in `tests/hooks-wiring-check.bats:85` from a `setup()` that runs before each of 36 cases — so it is reclaimable **only** by the retirement this row unblocks, not by any ungoverned caller. [CLOUD-1174](https://linear.app/buttoninc/issue/CLOUD-1174/the-campaign-conflates-four-orthogonal-axes-seconds-belong-to-units-a)'s reachability axis is where that distinction now lives. Its own header at `:143` states the class outright: "They are launcher-provisioned files under `$HOME` *that this repository cannot …"* — the program has always known this; the fact model has never had an answer for it.
## Why this is not "let a module read any path"
House-style §5's read-only allowlist and non-negotiable rule 1 both refuse a filesystem scanner, and an unbounded `$HOME` read would make `crates/batten` know a consumer's file layout. **The shape that is admissible is a projection of a DECLARED, BOUNDED set** — the wiring paths a consumer's own `[host]` config names, resolved against the launcher's own roots, and nothing else. That keeps it the same kind of object as every other fact: a declaration in `batten.toml`, projected, with the consumer's specifics in the consumer's config.
`Fact::Tracked`'s doc comment is the precedent for stating the bound beside the variant rather than trusting the name — the walk honours `.gitignore` and says so — and this fact needs the same treatment for a stronger reason: a reader who assumes it reads `$HOME` freely has assumed a scanner.
## The home: substrate, and its consumers are already-shipped verbs or consumer modules
[CLOUD-1176](https://linear.app/buttoninc/issue/CLOUD-1176/the-retirement-campaign-has-one-disposition-port-it-into-the-core-so) requires a disposition before a successor, and it is worth stating here because this row is the one most likely to be mis-read as core-widening. **It is not.** The nine members' homes, and none is a new `crates/batten` verb:
| member(s) | home |
| -- | -- |
| `hooks-wiring-check` (**135.1s**) | **the existing **`doctor hooks` **verb** — it ships today, `doctor.sh:219` already invokes it, and "is the harness wired the way this repo declares" is exactly what `doctor` is for. [CLOUD-1160](https://linear.app/buttoninc/issue/CLOUD-1160/retire-hooks-wiring-check-844s-no-inbound-governed-reference-blocked) is the row. |
| `doctor`, `doctor-check` | **the existing **`doctor` **verb**, same argument one level up |
| `sbom` | **PRIOR ART** — a `$CARGO_HOME` walk is `syft`'s job, on PATH per §9. [CLOUD-1159](https://linear.app/buttoninc/issue/CLOUD-1159/retire-the-sbomrelease-artifact-unit-9-programs-598s-glued-by-six) is the row. |
| `mcp-attach-check`, `mcp-timeout-budget`, `connector-allow-resolve`, `mcp-allow-check` | **CONSUMER MODULE** — an MCP server roster and its cache layout are this consumer's facts by construction (rule 1) |
| `transcript-corpus-check` | **CONSUMER MODULE**, and see [CLOUD-1172](https://linear.app/buttoninc/issue/CLOUD-1172/inputcalltranscript-is-the-path-and-never-a-byte-of-it-so-the-two) for the harder half — the path is readable, a byte of the transcript is not |
**This row itself is SUBSTRATE and widens nothing that §9 protects.** §9 refuses consumer-specific behaviour *baked into the core*; the paths here come from the consumer's own `batten.toml` declaration and the engine only knows how to expand a launcher root. That is the same shape `Fact::Tracked` already has — a bounded walk whose bound is stated beside the variant — and the negative half in §2 (*an undeclared path is not readable by any module*) is what keeps it a projection rather than a scanner.
So the earlier reading of this row as "the campaign reaching for a filesystem scanner" is **withdrawn**: it is the enabling fact for four consumers that land in extension surfaces and three that land in verbs that already ship.
## The adjacent row, and why it is not this one
[CLOUD-1029](https://linear.app/buttoninc/issue/CLOUD-1029/nothing-guards-the-evidence-substrate-an-agent-can-edit-the-transcript) is about **protecting** out-of-tree paths — `protected` cannot express them, so an agent can edit the transcript a gate reads. This row is about **reading** them. They share the observation that these paths exist outside `protected`/`tracked`'s reach and they need the same path vocabulary; they are opposite halves and neither subsumes the other.
## The precedent this follows
[CLOUD-846](https://linear.app/buttoninc/issue/CLOUD-846/the-tree-surface-parses-four-structured-formats-so-12-of-the-20-tree) is the same shape one axis over — *"the tree surface parses four structured formats, so 12 of the 20 tree-scoped gates read content no fact can carry"* — and it landed. That row widened WHAT is parsed; this one widens WHERE a declared file may live. Read it for the projection pattern rather than re-deriving one.
---
**Refinement — Ready (a declared, bounded out-of-root projection; never a scanner)**
*Refinement gate: [Definition of Ready & Done](<https://linear.app/buttoninc/document/definition-of-ready-and-done-e4e8defb6774>). This body carries only specializations.*
* **Authority boundary (§1).** A new fact variant and its `batten.toml` declaration, in `crates/batten/src/facts.rs` and the projection it feeds. No `mise-tasks/` program and no `tests/**/*.bats` **is edited or added** — `V-SHELL-RULE-EDITED` refuses the first and `V-SHELL-RULE-ADDED` the second, which is why this row cannot fix its nine consumers by touching them.
* **Computable predicate (§2).** A tree-scoped module can read the contents of a path the consumer's config **declares**, resolved against a launcher root the engine knows how to expand (`$HOME`, `$XDG_DATA_HOME`, `$CARGO_HOME`, `$MISE_DATA_DIR`, `$TMPDIR`), **and a path outside the declared set is not readable by any module**. The negative half is the whole safety property; without it this row ships a scanner.
* **Could-not-look is distinct from absent (§2).** An unset root variable, a missing file, and an unreadable one are each `Look::CouldNotLook` and must be assertable apart from "the file says nothing". Rego reads undefined as *does not hold*, so collapsing them ships a gate that is silently off — [CLOUD-845](https://linear.app/buttoninc/issue/CLOUD-845/a-module-copied-from-policyrss-own-doc-passes-policy-test-green-and)'s recorded class, and the reason `input.tree.missing` exists.
* **Deliberately not in scope (§2).** Retiring any of the nine programs — each is its own row and this one only unblocks them. `protected` over out-of-tree paths, which is [CLOUD-1029](https://linear.app/buttoninc/issue/CLOUD-1029/nothing-guards-the-evidence-substrate-an-agent-can-edit-the-transcript). Any read not named by a declaration. Teaching `crates/batten` a consumer's directory layout, which non-negotiable rule 1 forbids: the paths come from config.
* Effect (§3). `read` — it opens declared files. It spawns nothing and opens no socket; `evaluator-io-check` stays the gate on that.
* Generated artifacts (§4). `schema/policy-input.schema.json` regenerates for the new family, since [CLOUD-879](https://linear.app/buttoninc/issue/CLOUD-879/the-policy-input-is-hand-projected-so-every-new-fact-family-is-an-edit) derives the projection from the declaration. Regenerate with `mise run fix`; never hand-edit. `schema-check` and `derived-check` gate both.
* **Output and exit (§5).** Pointer-only: the declared key and the path it resolved to — **never the file's contents**, which carry a consumer's permissions, MCP config and credentials. This is the one fact family where rule 4 is load-bearing rather than tidy. Could-not-look exits `3`, never a false `2`.
* Commit / bump (§6). `feat(facts)` — **patch** until `0.1.0`. Additive: a new declaration breaks no consumer's config, confirmed by `mise run semver` rather than asserted.
* **Test obligation (§7).** Over the compiled binary in `crates/batten/tests/`; no `.bats` file is added or edited. **Shown able to fail** per [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate), four observed: (a) a declared out-of-root file is read and decided over; (b) **an undeclared path is NOT readable** — the anti-scanner case, and the one a careless implementation passes by accident; (c) an unset root variable is could-not-look and distinguishable from an empty file; (d) the positive control, without which (a)–(c) are satisfied by a fact that resolves nothing. **Second tier is mandatory**: a `with input as` case fabricates the very shape the engine may be unable to produce ([CLOUD-845](https://linear.app/buttoninc/issue/CLOUD-845/a-module-copied-from-policyrss-own-doc-passes-policy-test-green-and)), and this family's whole risk is that the engine cannot reach the path at all.
* **Blockers (§8).** None. `relatedTo` [CLOUD-1160](https://linear.app/buttoninc/issue/CLOUD-1160/retire-hooks-wiring-check-844s-no-inbound-governed-reference-blocked) (the largest consumer, whose §2 carries this as an internal precondition and should defer to this row), [CLOUD-1029](https://linear.app/buttoninc/issue/CLOUD-1029/nothing-guards-the-evidence-substrate-an-agent-can-edit-the-transcript) (the protect half of the same path vocabulary), [CLOUD-846](https://linear.app/buttoninc/issue/CLOUD-846/the-tree-surface-parses-four-structured-formats-so-12-of-the-20-tree) (the projection precedent), [CLOUD-879](https://linear.app/buttoninc/issue/CLOUD-879/the-policy-input-is-hand-projected-so-every-new-fact-family-is-an-edit) (schema derivation), [CLOUD-845](https://linear.app/buttoninc/issue/CLOUD-845/a-module-copied-from-policyrss-own-doc-passes-policy-test-green-and) (the dead-gate class this must not ship), [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate), [CLOUD-1151](https://linear.app/buttoninc/issue/CLOUD-1151/waves-2-and-3-have-never-had-an-owner-cloud-843-warned-of-it-cloud-910) (the wave owner).
**Acceptance**
* A tree-scoped module reads a declared out-of-root file and decides over it, asserted over the compiled binary.
* **An undeclared path is unreadable, asserted** — the row fails its own review without this case.
* Could-not-look is distinguishable from empty, asserted, and exits `3`.
* No consumer file contents appear in any output, asserted.
* At least one of the nine programs is shown to have a landable successor shape as a consequence — the proof this row unblocks rather than merely declaring a family.
* The member list is **derived rather than scanned** before this row closes, so a program indirecting its root through a variable cannot sit outside the class unnoticed — the defect that lost `doctor-check.sh` on the first pass.
* [CLOUD-1160](https://linear.app/buttoninc/issue/CLOUD-1160/retire-hooks-wiring-check-844s-no-inbound-governed-reference-blocked)'s §2 is amended to cite this row instead of carrying the precondition itself.
Found by asking, for all 132 governed programs, whether a successor could express the predicate at all — the expressibility axis the campaign's reference census never measured.
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-898/hookhandler-one-door-to-the-hooking-surface-so-the-contract-is">CLOUD-898 `[[hook.handler]]`: one door to the hooking surface, so the contract is enforced whatever is dispatched behind it</a></summary>
<p>
**Why**
`batten hook` registers on every surface of every harness, fast, even for a no-op — and then thirteen bash hooks are registered *beside* it in `.claude/settings.json`, each carrying its own contract, its own timeout handling, its own fail-open posture and its own output shape. None of them measured. The engine is one voice among fourteen at the boundary it is supposed to own.
The fix is not to rewrite each script. It is to make `batten hook` **decide what to run and how**, including bash, so the only path to the hooking surface is through Batten and hooking is as consistent across harnesses as it can be.
**Why** `[[hook.action]]` **cannot serve**
[CLOUD-91](https://linear.app/buttoninc/issue/CLOUD-91/add-a-hookaction-plugin-surface-with-a-worked-example)'s surface already dispatches a declared command on an event, and its module doc states three properties as deliberate — each exactly wrong for a handler:
* `fire` returns `()`, and *"an action can never change the answer"* is called the load-bearing property, structural rather than promised;
* the child's streams are **discarded** as a rule-4 secret risk;
* `pre-tool` is **refused** outright.
Those are right for a side effect and wrong for a participant. So `action` keeps its guarantee unchanged and this is a second noun. A reader choosing between them has one question — *may this change the answer?* — and the kind is the answer.
`pre-tool` **is admitted, and not by symmetry.** `action`'s first objection does not transfer: a side effect there runs before a possible deny, which inverts what a mediated gate is for, but a handler *is* part of that decision. Its second objection transfers intact and is honoured: `run_hook`'s hot path touches no config when a pre-tool payload carries neither a command nor a write, so handler resolution takes [CLOUD-460](https://linear.app/buttoninc/issue/CLOUD-460/one-receipt-row-makes-every-mediated-call-pay-four-git-subprocesses)'s narrowing — the shape `reads_prospective` uses — and a call no handler selects for still does less work than `--help`.
**The four things the door enforces**, each previously re-derived differently in every script:
1. **A bound.** Declared per handler, imposed by the parent. `stop-guard` hand-rolls `timeout 1s cat` for exactly this and says so; the rest have none.
2. **Fail-open, centrally.** Spawn failure, timeout, an undefined exit code — all *could not look*, which allows. Each script spelled this itself, and a missed path was a hook that could refuse because it was broken.
3. **A stated output shape.** stdout on a passing exit is advisory text; a refusal's reason is on stderr. Anything else is a contract violation, reported and never forwarded. This is also what retires the "hooks announce success" defect ([CLOUD-891](https://linear.app/buttoninc/issue/CLOUD-891/three-registered-hooks-announce-success-on-the-normal-path-two-of-them)) as a property rather than a habit.
4. **One reply per call.** Handler results merge; the host reads one document.
**stdout is interpreted, never forwarded** — the rule-4 answer and the portability answer at once. `action` must discard its child's streams; a handler's output *is* the channel, so it is read into Batten's types and Batten re-renders per harness. A handler speaks to Batten, in Batten's vocabulary, never to the host — so it cannot emit a host decision document, and a repository's hooks behave the same on every harness rather than on the one whose JSON its author happened to write. A handler that tries anyway is reported rather than passed along.
**Refinement — Ready**
*Refinement gate: [Definition of Ready & Done](<https://linear.app/buttoninc/document/definition-of-ready-and-done-e4e8defb6774>). This body carries only specializations.*
* **Source of truth (§1).** `batten.toml`'s `[hook]` table, which [CLOUD-91](https://linear.app/buttoninc/issue/CLOUD-91/add-a-hookaction-plugin-surface-with-a-worked-example) already established as the place a repository attaches to hook events. One authority; `.claude/settings.json` keeps only the `batten hook` registration.
* **Computable predicate (§2).** The kind, its validator (empty id, unknown event, `unrecognized`, empty argv, zero timeout, duplicate id — all refused at load), and the dispatch. `selects` is the narrowing and is asserted, not intended.
* **Effect (§3).** `hook` is already classified; a handler is ambient by construction — it spawns with the calling user's authority, exactly as a `command` row does, and that is why the *contract* rather than the code is what constrains it.
* **Generated artifacts (§4).** `schema/batten.schema.json` is derived and drift-gated.
* **Output & exit (§5).** §7's table with no per-kind exception: `0` pass, `1` the handler reported, `2` refusal, anything else outside the contract. Pointer-only — a violation names the handler id and what it did, never a byte the handler wrote, since its streams are the widest secret surface on this path.
* **Commit / bump (§6).** `feat` → patch until `0.1.0`.
* **Test obligation (§7).** A payload no handler selects for does less work than `--help`; a handler that hangs is killed at its bound and the turn still ends; an undefined exit allows and reports; a host decision document on stdout is a violation, not an advisory; two refusals yield the first rather than a merged reason; every violation variant's pointer carries no handler output. Mutation coverage per [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate).
* **Blockers (§8).** None — the advisory channel it merges into is already on this branch.
**Done**
`.claude/settings.json` carries one entry per event, `batten.toml` carries the handlers, and the contract holds identically whether what runs behind the door is Rego, bash, or something nobody has written yet.
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-1051/replace-prose-only-and-filed-here-override-passwords-with-issued">CLOUD-1051 Replace prose-only and filed-here override passwords with issued capabilities</a></summary>
<p>
The issue remains blocked by [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is) and in Backlog until its typed precondition and Rego verdict ABI land.
## Shell-retirement specialization
Generated Batten completions are derived output and are not authored shell rules. This issue's retirement boundary is every authored `mise-tasks/**` guard and Bats suite required by the two adapter migrations.
`mise-tasks/prose-only-check.sh`, `tests/prose-only-check.bats`, `mise-tasks/filed-here-check.sh`, `tests/filed-here-check.bats`, `mise-tasks/land.sh`, `tests/land.bats`, `mise-tasks/stop-guard.sh`, `tests/stop-guard.bats`, `mise-tasks/closing-key-check.sh`, and `tests/closing-key-check.bats` are not edited in place. Their predicates and callers are ported into Rego/Rust policy-engine surfaces, then the authored shell rules and corresponding Bats suites are deleted with `carried`, `subsumed`, or `changed` successor records and compiled-binary replacement coverage. Related shell utilities are either left byte-identical and outside the changed-file set or receive the same full retirement treatment; no new shell task, Bats suite, or fallback parser is allowed.
[CLOUD-1059](https://linear.app/buttoninc/issue/CLOUD-1059/refuse-a-migration-that-touches-a-shell-rule-without-retiring-it) enforces this changed-file rule. This issue is blocked by both 1050 and 1059.
**Blocker.** This is blocked by [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is), which supplies the typed verdict and override precondition. It does not begin until that registry lands.
## The capability protocol
This section is the single authority for the scheme; nothing below restates it.
The capability is a **content-addressed admission**, not a derived nonce and not a random bearer token. Both earlier forms were wrong in opposite directions: derivation from public tuple data alone is forgeable, and a random token leaves the record unauditable.
```
admission = H("batten-admission-v1" ‖ JCS({rule, verdict, subject, head,
epoch, answers, prev, author}))
```
**The serialization is canonical, not concatenation.** Raw `a ‖ b` is ambiguous across field boundaries, so two different field splits can hash alike. One canonical form — JCS over a fixed object, or length-prefixed fields; the implementation picks one and the choice is part of what `v1` pins — is what makes the address well-defined at all. `epoch` binds a repository/config generation, so an admission does not survive the policy change that would have made it unnecessary.
**Authorization is the RECORD's existence and state, never possession of the name.** This is the rule that must travel with the scheme: the hash's property is BINDING, not unguessability. Anyone holding the answers can compute it, so the address is neither a secret nor evidence of who created it.
**The authority is the STORE; the address is integrity.** Stated as a boundary rather than left implied, because an earlier wording here ("nobody but the issuer can create the record") claimed issuer authentication from a hash, which content addressing does not provide. What the address proves is that the record is internally consistent with its own fields. What restricts who may create a record is the store's write path, and nothing else. Under this row that write path is a local filesystem store, so the boundary is: **anyone who can write the store can mint an admission.** That is acceptable here — the threat model is honest error, not a hostile local writer — and it is written down so a later reader does not mistake the hash for a signature. If the trust boundary ever widens to a shared or remote store, the scheme needs a MAC or signature over the record; that is a separate row, never an inference from this one.
Because it authorizes nothing on its own, an admission is safe to print, log, quote in a commit and leave in a transcript — which removes the "never print a bearer capability in ordinary output" constraint that a log or PR body would eventually violate anyway.
What content addressing buys that a random token cannot:
* **The store becomes self-verifying.** Recomputing the address from the record's own fields must equal the address, so editing the reasoning after the fact invalidates it. Under a random token, post-hoc tampering is undetectable — and the corpus is the entire diagnostic point.
* **Idempotence.** The same reasoning yields the same address, so a repeated request is one record rather than one per attempt.
* **The ordinal disappears.** Re-articulation stops needing a counter: reusing the previous answers reproduces the previous address, which is already spent, so overriding the same situation again requires genuinely different text. "Even in the same session" becomes a property of the construction.
* `prev` **makes the per-rule, per-subject history tamper-evident** — no backdating and no reordering — which is a far stronger sensor than a flat append.
* **One value is capability, record key and audit reference**, so the token and its record cannot drift apart.
The canonical in-toto receipt envelope stores the binding `(rule, verdict, canonical-subject, HEAD, epoch)`, the answers, `prev`, `author`, and the spent state, keyed by the admission. It lives in the canonical out-of-tree receipt store at `receipts/overrides/<fingerprint>.json`; `$GIT_DIR/batten-receipts/` is read for compatibility only during the two adapters' migration and is never an authority.
**The store has no locking today, and this row must supply it.** `receipt.rs` contains no locking at all — `fs4` appears only in `capture.rs` — so the atomic `issued → spent` transition is a thing to build, not a thing to reuse. An earlier draft of this row asserted "under the receipt store's existing lock"; that was false, and the review caught it. What the row specifies instead:
* **Transition.** Consume is a compare-and-set on the record's state, taken under an `fs4` advisory lock over the override store — the same choice, for the same reason, that `.claude/rules/rust.md` records for the capture lock: the kernel releases it when the holder dies, so a `SIGKILL` mid-write leaves a reader a defined prefix rather than a lock nobody can release.
* **Chain head.** `prev` is the previous admission for the same `(rule, subject)`; the head pointer moves inside the same locked section as the state transition, so a chain cannot fork.
* **Concurrent consume.** Exactly one consumer wins the compare-and-set; every other is a policy refusal (`2`), never an internal error.
* **Crash recovery.** A crash before the CAS leaves the record issued and still consumable; a crash after leaves it spent. There is no intermediate state, because the state lives in one record replaced by one atomic rename.
**Canonical subject, per gate**, since the binding claim is untestable without one. `prose-only-check`'s subject is the ordered set of tracked paths in `origin/main...HEAD` — the same set its refusal names — canonicalized as sorted repo-relative paths; the branch name is deliberately NOT part of it, so a rename cannot mint a fresh override for the same diff. `filed-here-check`'s subject is the single `CLOUD-<n>` key of the row being overridden — one admission per row rather than one per invocation, so overriding three rows costs three articulations.
The two adapters are explicitly write-effect at consumption because they spend state. Their legacy variables remain supported only behind a named compatibility adapter during migration; the adapter emits a migration finding and is deleted in the same release once both Bats suites prove issued-capability flow. No other override family is claimed.
**Why**
`BATTEN_PROSE_ONLY_OVERRIDE=1`, `BATTEN_FILED_HERE_OVERLAP=1`, `BATTEN_CLAIM_TAKEOVER=1`, `BATTEN_HOOK_BYPASS=1` and the rest share one property: **they are knowledge.** Grep the source — or read the refusal, which prints the name — and you hold the bypass forever, for every subject, in every session. Nothing is issued, nothing is scoped, nothing is spent.
That is why an override costs nothing to reach for. **Measured 2026-08-25:** an agent hit `prose-only-check`, held the env var name the moment it read the refusal, and put the override to a human. The change needed no override at all — the correct route was a clause the caller's paraphrase had dropped ([CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is) defect B). The override was the cheapest concrete thing in reach, so it was reached for.
## The recording half already exists and is post-hoc
Two gates already write a record: `prose-only-check` appends to `$GIT_DIR/batten-receipts/prose-only-overrides.<branch>` and `filed-here-check`'s `BATTEN_FILED_HERE_OVERLAP` "records which rows it overrode". Both write **after** the decision, as a trace.
[CLOUD-786](https://linear.app/buttoninc/issue/CLOUD-786/the-claim-receipt-records-that-a-gate-was-overridden-and-no-mechanism) **is the row for the other half** — *"the claim receipt records that a gate was overridden and no mechanism reads it, so a bypassed claim is indistinguishable from a clean one."* This row does **not** duplicate it and does not block on it: 786 is *nothing reads the record*, this is *the record is a precondition of release*. They compose, and 786's deliverable is this row's sensor clause. Stated rather than absorbed.
## What changes
The override stops being reachable by knowledge and becomes reachable only through a verb that issues it.
* **The gate never adjudicates the reason.** Non-negotiable rule 3 — *"a gate resolves to a command and an exit code over an object it decides, never a model verdict"* — and "is this justification good" is exactly a model verdict. The predicate is **reason present and well-formed ⇒ release**. The forcing function is articulation, not approval. This is the clause that keeps the mechanism honest, and it is also why it works: the cost is thinking, not asking.
* **The admission is content-addressed** over the proof chain (see the protocol section above). Single-use per SITUATION rather than per session, unharvestable onto a different subject because the binding is inside the hash, and re-articulation is forced by construction rather than by a counter — identical answers reproduce an already-spent address.
* **The questions must be falsifiable to the writer**, or a fluent agent performs the ritual and proceeds. The three that would have stopped the measured instance, and the answers it would have had to type:
1. *state the precondition this gate declares and the fact satisfying it* — `prose-only-check` declares "the prose is the deliverable and cannot wait"; the diff was executable data, not prose, and it waited an hour before riding another branch. Both halves false.
2. *name what is lost if you do not override* — nothing.
3. *name the route you rejected and why it does not apply* — forces engagement with routes the reader may never have received.
* **Re-present the declined routes at issue time.** Last cheap moment, and it catches the reader who never got route 1.
## The corpus is a sensor on the GATE, not only on the agent
Twenty overrides of one rule is not twenty justified exceptions — it is a wrong predicate or an unreachable remedy. A per-rule override rate crossing a threshold should surface as a finding **against that rule**, which turns the log from an accountability record into gate telemetry. That is the more valuable artifact, and it is [CLOUD-786](https://linear.app/buttoninc/issue/CLOUD-786/the-claim-receipt-records-that-a-gate-was-overridden-and-no-mechanism)'s deliverable pointed at the gate rather than at the claim.
## Composes with [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is)
**The boundary that made this claim reach across scopes is closed.** [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is)'s registry was drafted policy-only, while both gates this row migrates are `mise-tasks/*.sh` — so consuming `override.precondition` from it was a claim across a boundary that row declared. [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is) has since been broadened to cover refusal classes from **both** emitters (its Regal enforcement stays policy-only, because that is where a Rego AST exists to lint), so the dependency below is satisfiable as written.
That row makes `override: {env, precondition}` a typed field checked at authoring time — a gate declaring no precondition simply cannot be overridden, which is the right default. This row is that field's **runtime consumer**: the questions are generated from the gate's own declared precondition. One field, checked statically and spent at break-glass. If [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is) lands first the env name disappears entirely; if it does not, the ritual still works off whatever precondition the gate states in prose.
---
**Refinement — Ready**
*Refinement gate: [Definition of Ready & Done](<https://linear.app/buttoninc/document/definition-of-ready-and-done-e4e8defb6774>). This body carries only specializations.*
* **Source of truth (§1).** One issuing verb plus the override record in the canonical out-of-tree receipt store (`receipts/overrides/<fingerprint>.json`), with `$GIT_DIR/batten-receipts/` read for compatibility only during migration. The two gates that already write records route through it rather than keeping their own append — a second writer is the drift [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is) is about, one layer down.
* **Computable predicate (§2).** A gate's bypass is honoured **only** when a record exists at the presented admission, is unspent, and binds to the presented `(rule, verdict, subject, HEAD)`; a bare env var is refused. Requesting one without answering every declared question is refused. Consumption is one atomic `unspent → spent` compare-and-set under an `fs4` advisory lock this row introduces — the store carries none today. The address must recompute from the record's own fields, and `prev` must resolve, terminate and not cycle — the same chain predicate [CLOUD-1053](https://linear.app/buttoninc/issue/CLOUD-1053/a-refusal-spends-context-every-time-it-fires-make-the-hot-path-one) needs for token successors, so one implementation serves both. All are lookups and comparisons — no judgement, no clock, no network.
* **Effect (§3).** `write`, to the canonical out-of-tree receipt store named in §1. No new spawn, no network, no tracker.
* **Output & exit (§5).** Pointer-only on the gate side — rule, verdict, subject pointer, admission — never the reasoning, which lives in the record. The admission is safe on this channel precisely because it authorizes nothing on its own. Exits follow the one table. **Note the deliberate inversion**: rule 4 keeps content out of output everywhere else, and here the reasoning IS the payload and belongs in the record, because it is the author's own words rather than repository content.
* **Commit / bump (§6).** `feat(receipt)!` — a bare env var ceasing to work is a break in the consumer-visible surface and is declared, not hidden.
* **Test obligation (§7).** Shown able to fail ([CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate)), each reddening one predicate: a bare env var with no admission ⇒ refused; an admission bound to a different subject or HEAD ⇒ refused; a request with an unanswered question ⇒ nothing issued; a spent admission ⇒ refused, while the same situation with genuinely different answers ⇒ a new address, issued; a record whose answers were edited after issuance ⇒ address no longer recomputes ⇒ refused; a `prev` chain that cycles ⇒ refused. Plus the anti-vacuity arm — a correctly requested and answered override **succeeds**, so a mechanism that refused everything would fail.
* **Blockers (§8).** `blockedBy` [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is), which supplies the typed verdict and the `override.precondition` the questions are generated from. `relatedTo` [CLOUD-786](https://linear.app/buttoninc/issue/CLOUD-786/the-claim-receipt-records-that-a-gate-was-overridden-and-no-mechanism) (nothing reads the record — this row's sensor clause is that row's deliverable; they compose rather than block), [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is) (the typed `precondition` this consumes), [CLOUD-680](https://linear.app/buttoninc/issue/CLOUD-680/an-override-ask-is-presented-as-a-menu-of-routes-rather-than-the-one) (the menu-shaped ask this removes the pressure toward), [CLOUD-820](https://linear.app/buttoninc/issue/CLOUD-820/refined-this-sessions-forgery-resistant-half-is-opt-out-deleting-your) (the prior instance of a bypass that was opt-out by deleting your own receipt).
**Acceptance**
* No gate honours a bare env var.
* An admission is unusable against a different subject, a different HEAD, a different config epoch, or a second time.
* Two concurrent consumes of one admission resolve to exactly one success and one policy refusal.
* An unanswered question yields no admission.
* A record edited after issuance no longer recomputes to its own address, and is refused.
* The record carries the answers, and a per-rule rate is derivable from it.
* A legitimate override still completes, demonstrated end to end.
**What this row must NOT claim**
* **Not that it stops a determined agent lying.** It makes lying legible across a corpus; it does not prevent one well-written false answer. The value is the aggregate and the friction, not detection.
* **Not that the gate judges.** The moment the predicate grades a reason it becomes a model verdict inside a gate, which rule 3 forbids and which would be worse than today's password.
* **It adds latency to a genuine break-glass.** The ritual must be one structured answer, not an essay. And if the issuer is unreachable the override is unavailable — fail-closed on the bypass, which is probably right but must be a decision rather than a discovery.
## Grooming decisions — Ready
**Bounded migration.** This change migrates `prose-only-check` and `filed-here-check` only. Their existing override records are the evidence and their Bats suites are the adapter tests. Other `BATTEN_*` variables are not silently redefined; each requires its own migration row.
**Issuer and store.** Add `batten override request` as a write-effect CLI command. It takes `--rule`, `--verdict`, `--subject`, and structured answers generated from the rule's declared `override.precondition` in [CLOUD-1050](https://linear.app/buttoninc/issue/CLOUD-1050/cloud-122s-refusal-contract-has-no-mechanism-and-the-rego-migration-is). It appends an override record under the canonical out-of-tree receipt store at `receipts/overrides/<fingerprint>.json`; `$GIT_DIR/batten-receipts/` is read only for compatibility during the two adapters' migration.
**Capability protocol.** The issuer writes the record and returns its content address — see the protocol section above, which is the single authority for the scheme. The two migrated gates accept only `BATTEN_OVERRIDE_ADMISSION`; their legacy bare environment variables are refused. A verifier resolves the record, checks its binding and unspent state, and recomputes the address from the record's own fields, without a network call or a clock read.
**CLI artifacts and contract.** Add the command in `crates/batten/src/cli.rs`, `surface.rs`, and `lib.rs`; regenerate `completions/*` and `man/*` with `mise run completions` and `mise run man`, gated by `mise run derived-check`. Issuance and record writes are write-effect; gate output is pointer-only. Success is `0`; malformed request or missing required answers is `1`; a rejected or replayed capability is `2`; internal store failure is `3`. Land as `feat(receipt)!` → patch until `0.1.0`.
**Tests and rollout.** End-to-end binary tests cover issuance, a valid use, a bare legacy variable, a different subject or HEAD, a spent admission, the same situation re-answered differently, a record tampered with after issuance, a cycling `prev` chain, two concurrent consumes of one admission (exactly one wins), a crash between issuance and consumption, and an unavailable issuer. `tests/prose-only-check.bats` and `tests/filed-here-check.bats` prove both adapters honor only the issued capability. The record must permit a per-rule override-rate report; that report is telemetry, never a judgement of the written reason.
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-1294/only-drops-a-retired-reference-admits-a-removed-line-that-names-a">CLOUD-1294 `only_drops_a_retired_reference` admits a removed LINE that names a retired path but not a removed CASE that tested it, so CLOUD-312 row 10 cannot land — measured, 3 cases in 2 suites</a></summary>
<p>
**Why**
`shell-retirement`'s one admitted edit exists because the campaign was, in [CLOUD-1051](https://linear.app/buttoninc/issue/CLOUD-1051/replace-prose-only-and-filed-here-override-passwords-with-issued)'s
own words, *"structurally unable to complete a retirement it had itself mandated"*.
`only_drops_a_retired_reference` fixed that for a removed **line**: retiring a program
forces a one-line deletion in the sibling that declares it, so a removed line naming a
path this delta deletes is admitted.
**The same defect exists one shape further on, for a removed CASE.** A governed `.bats`
suite that TESTS a retiring path does not merely name it on one line — it wraps the
reference in an `@test` block whose other lines name nothing. The admission requires
**every** removed line to `mentions_retired`, so the block is refused, and there is no
other route: the suite cannot be deleted whole because its declared subject survives.
Measured on PR #804 ([CLOUD-312](https://linear.app/buttoninc/issue/CLOUD-312/the-engine-is-the-pre-tool-entry-point-the-shell-guards-retire-behind) row 10, retiring `.claude/hooks/session-start.sh`), over
the whole corpus — **2433 cases, exactly 3 failures**, all this shape:
| case | what it greps out of the retiring script |
| -- | -- |
| `tests/commit-attribution.bats:41` | `step attribution-identity mise run attribution-identity` |
| `tests/container-preflight.bats:131` | `container-preflight` |
| `tests/container-preflight.bats:138` | `container-preflight -- --degraded` |
Both refusals are the gate's own words, not a reading:
```
tests/commit-attribution.bats shell-rule-retired
tests/container-preflight.bats shell-rule-retired
tests/**/*.bats 2452->2439 [subject-alive mise-tasks/container-preflight.sh] bats-tests-not-deleted
```
`V-SHELL-RULE-EDITED` declares one route, no override and no `bypass_env`; the subjects
(`hk.pkl`, `mise.toml`, `mise-tasks/container-preflight.sh`) are alive, so
`retires_with` refuses the whole-file route. **Both landable shapes are unavailable, for****
****a change the campaign itself mandates.**
## This is NOT what [CLOUD-1268](https://linear.app/buttoninc/issue/CLOUD-1268/port-without-retirement-the-ledger-has-no-spelling-for-a-suite-whose) / PR #794 lands
#794 adds `// ported:` to `bats-tests-not-deleted`'s `conserves` — the case-COUNT gate,
for a suite being **deleted** whose subject survives. The refusal here is
`shell-retirement`'s **edit** arm on a suite that **stays**, and #794's own body
confirms that arm is untouched: *"re-subjecting a suite EDITS a governed*
`tests/**/*.bats` *(*`V-SHELL-RULE-EDITED`*, one route, no* `bypass_env`*)"*. The two are
complementary and neither subsumes the other.
Note also that #794's commit 4 already deletes `tests/session-start.bats`, which PR #804
also deletes — whichever lands first, the other reconciles.
## The shape of the fix
A fifth admitted-removal shape beside the four `admitted_addition` arms: a removed line
is admitted when it lies inside a **fully removed** `@test` **block** whose body mentions a
path this delta retires. Indices are available — `delta["base-lines"][path]` is an array
— so the span is expressible without a second parser:
* `s` where `base[s]` starts with `@test `, `e` the least `i > s` with `base[i] == "}"`;
* every index in `[s, e]` is in the removed set (a partially-removed block stays refused,
which is what keeps this from licensing maintenance-in-place);
* some line in `[s, e]` satisfies the existing `mentions_retired`.
**It must be additive, exactly as** [CLOUD-1130](https://linear.app/buttoninc/issue/CLOUD-1130/a-complete-conserves-ledger-substitutes-for-subject-death-so-a-bats) **required of the fourth conserves arm.** The
existing all-lines-name-a-retired-path term stands unchanged; this adds one admitted
shape rather than relaxing one.
---
**Refinement — Ready (a fifth admitted-removal shape; the case, not the line)**
*Refinement gate: Definition of Ready & Done. This body carries only specializations.*
* **Authority boundary (§1). **`policy/shell-retirement.rego` alone. Neither
`mise-tasks/` nor `tests/**/*.bats` is edited or added — the gate refuses both, which
is the point. `policy/shell-retirement.rego` **is a protected path** ([CLOUD-833](https://linear.app/buttoninc/issue/CLOUD-833/a-policy-row-runs-only-on-the-mediated-call-so-none-of-the-79-gate) derives
every `policy` row's `module` into the protected set), so the write owes an
articulation block.
* **Computable predicate (§2).** An edit to a governed `.bats` is admitted **iff** every
removed line is either the existing case (names a retired path) **or** lies inside a
wholly-removed `@test` block that does. Anything else still refuses.
* **The discriminating pair (§2), and it is the whole row.** A suite dropping a case that
greps a path this delta retires is admitted; **the same suite dropping a case that****
****greps a path that survives still refuses**. Without the second this is a licence to
delete any case during any retirement, which is the maintenance-in-place the ratchet
exists to stop.
* **Partial removal stays refused (§2).** A block with one line removed and the rest kept
is an edit, not a cleanup. Asserted, because it is the obvious hole.
* **Coverage must not fall (§2).** A removed case still owes its `conserves` arm —
`bats-tests-not-deleted` is untouched by this row and must stay so. This admits the
EDIT; the arm still records where the property went. Both gates fire independently and
a case that clears one must still clear the other.
* **Deliberately not in scope (§2).** Relaxing `named_and_alive` or any of the four
`admitted_addition` arms. [CLOUD-1268](https://linear.app/buttoninc/issue/CLOUD-1268/port-without-retirement-the-ledger-has-no-spelling-for-a-suite-whose)'s `// ported:` conserves arm. Re-subjecting a
suite. Any `bypass_env` or override route on `V-SHELL-RULE-EDITED` — the whole design
is that there is none.
* **Effect (§3). **`read`.
* **Output and exit (§5).** Pointer-only: the suite path and the case name. Never a case
body.
* **Commit / bump (§6). **`feat(policy)` — patch below `0.1.0`.
* **Test obligation (§7). **`crates/batten/tests/shell_retirement.rs` over the compiled
binary, which is this module's declared second tier (its own `#MUTANT-EXEMPT` says no
`tests/shell-retirement.bats` exists and none may). Shown able to fail per [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate).
A `#MUTANT` row over the wholly-removed term, chosen to discriminate — [CLOUD-1132](https://linear.app/buttoninc/issue/CLOUD-1132/the-two-shapes-retirement-rule-exists-only-in-a-rego-header-and-a)'s
warning about a mutation some other conjunct already excludes applies directly here.
* **Blockers (§8).** None. Sequenced against PR #794, which edits the same file, so
whichever lands second rebases. `relatedTo` [CLOUD-1051](https://linear.app/buttoninc/issue/CLOUD-1051/replace-prose-only-and-filed-here-override-passwords-with-issued) (the same defect one shape
back, and the precedent for the fix), [CLOUD-1149](https://linear.app/buttoninc/issue/CLOUD-1149/repoints-at-the-declared-successor-cannot-match-how-bash-spells-a) and [CLOUD-1219](https://linear.app/buttoninc/issue/CLOUD-1219/a-retirement-ledger-can-only-declare-a-successor-path-so-a-shell) (the two later
additions to the same admission), [CLOUD-1283](https://linear.app/buttoninc/issue/CLOUD-1283/shell-retirement-can-repoint-a-callers-binding-but-not-its-spend-so-a) (the sibling missing shape — a spend that
cannot be repointed), [CLOUD-312](https://linear.app/buttoninc/issue/CLOUD-312/the-engine-is-the-pre-tool-entry-point-the-shell-guards-retire-behind) (row 10, the blocked consumer), [CLOUD-1268](https://linear.app/buttoninc/issue/CLOUD-1268/port-without-retirement-the-ledger-has-no-spelling-for-a-suite-whose).
**Acceptance**
* A governed `.bats` dropping a case that tests a path this delta retires is admitted.
* **The same suite dropping a case that tests a surviving path still refuses**, asserted.
* A partially-removed block still refuses, asserted.
* `bats-tests-not-deleted` still demands a `conserves` arm for the dropped case, asserted
— this row admits the edit and buys no coverage.
* [CLOUD-312](https://linear.app/buttoninc/issue/CLOUD-312/the-engine-is-the-pre-tool-entry-point-the-shell-guards-retire-behind) row 10 lands: PR #804's three failing cases become removable, and
`mise run verify` is green over the retirement.
Found while landing [CLOUD-312](https://linear.app/buttoninc/issue/CLOUD-312/the-engine-is-the-pre-tool-entry-point-the-shell-guards-retire-behind) row 10. The migration itself is done and verified —
dispatching the real handler rows returns exit 0 and provisions the container — and this
is the only thing between it and `main`.
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-1283/retracted-shell-retirement-does-admit-a-spend-site-repointing-the">CLOUD-1283 RETRACTED — `shell-retirement` DOES admit a spend-site repointing; the refusal I measured was my own wrong `succ`</a></summary>
<p>
**RETRACTED 2026-09-01, by the session that filed it.** The claim below was wrong. `repoints_at_the_declared_invocation` admits `land-lock.sh`'s spend sites exactly as written; what failed was my choice of `succ`, not the clause.
## The correction
I decomposed with `succ = "batten task read"`, which leaves the derived span `"$reg" read` — and `is_retired_reference` rightly rejects that. With `succ = "batten task"` the same clause lands:
```
removed phase_since=$("$reg" read "$pid" phase_since 2>/dev/null) || return 1
added phase_since=$(batten task read "$pid" phase_since 2>/dev/null) || return 1
at = indexof(added, "batten task")
before = 'phase_since=$('
after = ' read "$pid" phase_since 2>/dev/null) || return 1'
startswith(removed, before) ok
endswith(removed, after) ok
span = '"$reg"'
spellings('"$reg"') = { '"$reg"', '$reg' } <- the matched-quote strip
is_retired_reference arm 2 '$reg' in { '$'+v | v in retired_path_vars } where v = reg
```
`retired_path_vars` resolves `reg` from the BASE line `reg="$(dirname "$0")/task-registry.sh"` through `is_retired_reference_by_text` arm 2 (ends in `/task-registry.sh`, head matches `shell-script-directory`). So all three spends are admitted additions, and the binding line plus the `[[ -x "$reg" ]]` guard are removal-only — both satisfy `mentions_retired`, the first by the script-directory-marker arm and the second by the `retired_path_vars` arm.
The ledger arm carries `runs:batten+task`.
What this means. `mise-tasks/task-registry.sh` and `tests/task-registry.bats` CAN retire, with no engine change and no new admitted addition. The clause [CLOUD-1219](https://linear.app/buttoninc/issue/CLOUD-1219/a-retirement-ledger-can-only-declare-a-successor-path-so-a-shell) shipped is sufficient. This row is a retraction, not a specification.
**Why it is worth keeping rather than deleting.** A block filed on the board is read by the next session and stops it too, so the retraction has to be as findable as the claim was. And the near-miss is instructive: the clause's admission depends on WHICH prefix of the successor you decompose against, and the shortest one that still spans the retired reference is the one that works. A future author hitting the same wall should try shortening `succ` before concluding the gate is at fault.
**What was right in the original.** Deleting `task-registry.sh` without repointing WOULD leave `holder_progress` returning 1 forever — every caller reads that as "no verdict", so [CLOUD-499](https://linear.app/buttoninc/issue/CLOUD-499/the-lease-grades-liveness-never-progress-a-wedged-land-beats-forever)'s stall and hang bails would silently stop firing. That remains the reason the repointing is mandatory rather than optional.
## The sibling case is still real
`mise-tasks/graph-check.sh:163,291,770` binds `lint` and spends it as `"$lint"` with NO arguments. The correction above does not obviously cover it: with no arguments there is no `after` to anchor on, so whether a repointing decomposes there is untested. That is the one open question this row leaves, and it goes live the day `ready-lint.sh` retires.
## What landed anyway
`mise-tasks/alive.sh` (the READER half of the same mechanism) retired cleanly, because no governed caller names it — `mise run alive` goes through the task name. Both halves of the port are built and tested in `crates/batten/src/task.rs` and `crates/batten/tests/task_registry.rs`; `ci-wait`'s `--progress "batten task"` consumes `tick` and `sig`. The writer's remaining leaves wait on this row.
---
**Refinement — Ready (a spend site is repointable, or a retirement says why it cannot be)**
*Refinement gate: [Definition of Ready & Done](<https://linear.app/buttoninc/document/definition-of-ready-and-done-e4e8defb6774>). This body carries only specializations.*
* **Authority boundary (§1). **`policy/shell-retirement.rego` and its `crates/batten/tests/shell_retirement.rs` tier. **No **`mise-tasks/` **program and no **`tests/**/*.bats` **is edited or added** — that is the rule this row is about, and it binds this row too.
* **Computable predicate (§2).** No new clause is owed. What remains is the `graph-check.sh` shape — a spend with no arguments — and the predicate is whether `repoints_at_the_declared_invocation` decomposes when `after` is empty. Measure it before writing anything: a clause added for a case that already works is the licence the module's comments refuse.
* **Deliberately not in scope (§2).** Retiring `land-lock` ([CLOUD-1274](https://linear.app/buttoninc/issue/CLOUD-1274/land-lock-cannot-retire-onto-the-engine-its-cas-is-a-network-push-and)). Loosening `V-SHELL-RULE-EDITED`'s override posture, which has been reached for twice and was wrong both times.
* **Effect (§3). **`read`.
* **Commit / bump (§6): **`fix(ci)`, patch below 0.1.0.
* **Generated artifacts (§4).** None; the module carries no config key.
* **Output and exit (§5).** Unchanged — the module's existing `{rule, verdict, subjects}` shape.
* **Test obligation (§7).** Both tiers, and the discriminating pair [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate) asks for: the `land-lock.sh` spend repoints and is ADMITTED, and an unrelated rewrite of the same line under the same deletion is still REFUSED. A clause asserting only the first is the licence this row must not become. The second tier is `crates/batten/tests/shell_retirement.rs` over the compiled binary, because a `with input as` case fabricates the very `base-lines` shape the engine may be unable to build.
* **Blockers (§8). **`relatedTo` [CLOUD-1170](https://linear.app/buttoninc/issue/CLOUD-1170/decided-the-engine-gets-a-supplied-instant-and-a-liveness-record-never), [CLOUD-1274](https://linear.app/buttoninc/issue/CLOUD-1274/land-lock-cannot-retire-onto-the-engine-its-cas-is-a-network-push-and), [CLOUD-499](https://linear.app/buttoninc/issue/CLOUD-499/the-lease-grades-liveness-never-progress-a-wedged-land-beats-forever), [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate).
**Acceptance**
* `mise-tasks/task-registry.sh` and `tests/task-registry.bats` retire, with `land-lock.sh`'s three spend sites repointed and the gate admitting it. **This is the retraction's own proof and is being done under **[CLOUD-1170](https://linear.app/buttoninc/issue/CLOUD-1170/decided-the-engine-gets-a-supplied-instant-and-a-liveness-record-never), not here.
* The zero-argument spend (`graph-check.sh`'s `"$lint"`) is measured, and a clause is added only if it does not already decompose.
* No new override route and no `bypass_env` on `V-SHELL-RULE-EDITED`.
Found while retiring the `alive`/`task-registry` pair for [CLOUD-1170](https://linear.app/buttoninc/issue/CLOUD-1170/decided-the-engine-gets-a-supplied-instant-and-a-liveness-record-never): the reader landed, the writer could not, and the difference is entirely whether a governed caller spends the callee with arguments.
</p>
</details>
<details>
<summary><a href="https://linear.app/buttoninc/issue/CLOUD-1218/the-lap-journal-records-a-hand-emptied-target-as-a-warm-lap-so-the">CLOUD-1218 The lap journal records a hand-emptied `target` as a WARM lap, so the ratchet inverts and the floor climbs to one nothing can satisfy</a></summary>
<p>
**Why**
[CLOUD-1157](https://linear.app/buttoninc/issue/CLOUD-1157/stem-of-cannot-group-an-extensioned-artifact-so-rlib-rmeta-and-so-are)'s lap journal ratchets each floor to the worst consumption it has observed, which is right. What decides *which* floor a lap is charged to then mislabels a from-scratch rebuild as **warm**, so the warm floor learns a cold lap's demand.
> **CORRECTED 2026-08-30, third instance (PR #770).** This paragraph originally read "what decides which floor a lap is charged to is whether **the escalation** dropped a basis-moving root." That has not been true since [CLOUD-1157](https://linear.app/buttoninc/issue/CLOUD-1157/stem-of-cannot-group-an-extensioned-artifact-so-rlib-rmeta-and-so-are) **(#756) landed** — `basis_of` already derives the basis from the TREE, not from the escalation:
>
> ```rust
> // crates/batten/src/prune.rs:1596
> fn basis_of(root: &Path) -> Basis {
> let populated = directories_named(root, "deps")
> .iter()
> .any(|deps| std::fs::read_dir(deps).is_ok_and(|mut e| e.next().is_some()));
> if populated { Basis::Warm } else { Basis::Cold }
> }
> ```
>
> **The residual defect is the** `.any()`, and the function's own doc comment already names it (`prune.rs:1582-1589`): "this reads EVERY `deps` under the root, so a populated `target/release/deps` *reports warm while the DEBUG build the lap is about to run is cold."* One populated profile masks another being empty.
>
> This matters because §2(b) below is written against the old mechanism and prescribes a fix the code already has. Reading it as written sends an author to replace an escalation check that is no longer there.
Measured on this container, 2026-08-30, while landing [CLOUD-746](https://linear.app/buttoninc/issue/CLOUD-746/execs-signal-forwarder-swallows-every-signal-after-the-first-and). `$GIT_DIR/batten-prune/laps.json` held:
```json
{"open":{"free_mb":9151,"basis":"warm","head":"d3788d66","measured":"2026-08-30"},
"ratchet":{"warm":{"mb":22861,"head":"5647a306","measured":"2026-08-30"},
"cold":{"mb":4640,"head":"090dd1f5","measured":"2026-08-30"}}}
```
`cold` 4640 MB below `warm` **22861 MB is impossible** — a cold lap consumes more than a warm one by definition, and `[prune]`'s declared numbers say so (warm 6242, cold 14914). The inversion is the tell, and it is the cheapest possible detector.
**How it got there, and why an agent will keep doing it.** A `land` lap that refuses on disk prints *"Free space outside ./target, or start a fresh session."* The obvious reading is `rm -rf target`, which is outside `target-prune` entirely. Each such lap then rebuilds from nothing and is charged to `warm`, ratcheting the warm floor toward the cold one. After a handful of laps the warm floor stood at 22861 MB — above anything a completed lap on this box can leave free — so **every** subsequent lap refused, in both directions: warm `target` failed the opening reading, cleared `target` failed the closing one.
The recovery is to delete the journal, which restores the declared floors. That is not discoverable from the refusal: the message names free space, the floor, and the roots it could not reclaim, and never that the floor it is quoting is one the tool taught itself.
## SECOND INSTANCE (2026-08-30, landing PR #751) — and it defeats this row's own detector
Reproduced on a different container, different branch, same mechanism. Journal at refusal:
```json
{"open":{"free_mb":8538,"basis":"warm","head":"6e0e7f6c","measured":"2026-08-30"},
"ratchet":{"warm":{"mb":15798,"head":"c96288c1","measured":"2026-08-30"},
"cold":{"mb":21519,"head":"c96288c1","measured":"2026-08-30"}}}
```
Against declared warm 6242 / cold 14914, **both floors are self-taught and both are ~2.5x and ~1.4x the declared value.** Two hand-emptyings produced them, in this order:
| act | journal's reading |
| -- | -- |
| `rm -rf target/debug/incremental` (the one root `target-prune` says it cannot reclaim) | *"consumed 15798MB — worse than any **warm** lap on record, so the observed warm floor rises to 15798MB"* |
| `rm -rf target` (after the above still refused) | *"consumed 21519MB — worse than any **cold** lap on record, so the observed cold floor rises to 21519MB"* |
After that, every lap refused in both directions exactly as this row describes, and `land` failed three consecutive times.
### Why §2(a) and acceptance bullet 1 would NOT have caught it
This ratchet is **correctly ordered** — cold 21519 > warm 15798 — so "a ratchet whose `cold` observation is below its `warm` *one is refused"* is silent here. **The inversion is one symptom of poisoning, not the class.** The recorded instance inverted because its two hand-emptyings happened to charge the wrong buckets; this one charged the *right* buckets and simply ratcheted both past what the box can satisfy. A detector keyed on ordering therefore catches the first instance and misses the second, which is the more ordinary shape — an agent following the refusal's own advice twice, in the order the refusal suggests it.
**So the predicate wants a second conjunct, and §2(b) is the one that generalises**: derive the basis from what the tree WAS at lap open. Under (b) both of my laps are cold and neither teaches the warm floor anything, which is the correct outcome and is reached without reference to ordering. Worth stating on the row because (a) is the cheaper check and reads as sufficient — it is not, and a fix that ships only (a) leaves this instance live.
A cheap third guard, orthogonal to both: **refuse to ratchet a floor above the declared one by more than some factor without saying so.** A learned number 2.5x its own config is more likely a mismeasurement than a real budget, and the refusal that quotes it should say which it is — which §5 already asks for and this instance makes concrete.
### The recovery worked, and its cost is the real damage
`rm -rf "$(git rev-parse --git-dir)/batten-prune"` restored declared floors and admitted the next lap at 8130MB free against warm 6242MB. **The cost was one full cold rebuild** — the `rm -rf target` this row predicts an agent will reach for, which consumed 21519MB and ~20 minutes of wall clock, and which was never necessary: the journal was the whole problem and deleting it alone would have sufficed at the very first refusal. That is the measured price of the undiscoverable recovery, and it is the strongest argument for §5's "observed vs declared" wording landing with the fix rather than after it.
Why it is not [CLOUD-861](https://linear.app/buttoninc/issue/CLOUD-861/target-prune-checks-the-disk-floor-once-per-lap-before-the-phase-that)'s or [CLOUD-1030](https://linear.app/buttoninc/issue/CLOUD-1030/target-prunes-escalation-guarantees-a-cold-build-but-the-floor-it)'s. [CLOUD-861](https://linear.app/buttoninc/issue/CLOUD-861/target-prune-checks-the-disk-floor-once-per-lap-before-the-phase-that) is the once-per-lap precondition, and the closing reading it added is what *reports* this — correctly, over a poisoned number. [CLOUD-1030](https://linear.app/buttoninc/issue/CLOUD-1030/target-prunes-escalation-guarantees-a-cold-build-but-the-floor-it) is the escalation invalidating the basis that certified the lap. This is the third: an external reclaim mislabels the basis a lap is **recorded** under, so the ratchet learns the wrong thing and the error compounds across laps rather than affecting one.
**Refinement — Ready**
*Refinement gate: [Definition of Ready & Done](<https://linear.app/buttoninc/document/definition-of-ready-and-done-e4e8defb6774>). This body carries only specializations.*
* **Authority boundary (§1). **`crates/batten/src/prune.rs` — `LapJournal`, `OpenLap.basis` and `Ratchet`. `[prune]`'s declared floors are untouched: this row makes the recorded basis honest, it does not move a measured number. No new config key and no runner decides any part of it.
* **Computable predicate (§2), REWRITTEN 2026-08-31 after both original clauses were implemented and refuted.** One clause, decidable over the compiled binary: **a profile cargo has BUILT whose **`deps` **is missing or empty makes the tree cold. **`target/<profile>/.fingerprint` is the marker — cargo writes one per profile it has built and leaves it behind when `deps` goes, so the absence becomes visible. Every `.fingerprint`'s sibling `deps` must be populated; no `.fingerprint` anywhere falls back to the pre-existing reading, which is what keeps this a narrowing rather than a new requirement. It needs no guess about which profile the caller will build next, so the comment's refusal of profile scoping still stands.
~~**(a) A ratchet whose **~~`~~cold~~` ~~**observation is below its **~~`~~warm~~` ~~**one is refused. **~~**REFUTED.** Silent on two of three instances (one journal carried `cold: null`; one carried a correctly ORDERED pair). The strengthened form — refuse a warm observation at or above the declared cold floor — was implemented and turned `a_warm_laps_consumption_does_not_raise_the_cold_floor` red: that case *deliberately* drives a warm lap consuming 22000MB against a 14000MB cold declaration, because a warm observation under it cannot discriminate a per-basis ratchet from a shared one, and its comment records that as a surviving mutation. The premise is also a unit error — the declared floor is a free-space budget, not a ceiling on consumption — and no factor separates the real case (3.96x) from the fixture's legitimate one (3.67x).
~~**(b) …narrowing **~~`~~basis_of~~`~~**'s **~~`~~.any()~~` ~~**to **~~`~~.all()~~`~~**.**~~ **REFUTED. **`directories_named` only yields directories that EXIST, and the reclaim an agent actually performs REMOVES `target/debug/deps`, which drops that profile out of the walk and leaves a populated `target/release/deps` satisfying either quantifier. `.all()` only helps for a `deps` that survives but is empty, which is not what any of the three instances did.
**"Empty or near-empty** `root`" was also wrong and would miss all three. Mine left `target/release`, `target/debug/build` and ~660 MB standing; the second instance deleted only `target/debug/incremental`. `deps` is the build basis; the rest of the tree is not.
* **Effect (§3).** Unchanged. `target-prune` keeps its classification; what changes is which bucket an observation lands in.
* **Output & exit (§5).** Unchanged, except that the refusal should be able to say the floor it quotes is observed-and-inverted rather than declared — the current message gives a reader no way to tell a learned floor from a configured one. Pointer-only throughout: megabytes, a head and a date, as today.
* **Commit / bump (§6). **`fix(prune)` — **patch** until `0.1.0`. `LapJournal`, `OpenLap.basis` and `Ratchet` are engine internals and `[prune]`'s declared floors do not move, so no verb, exit code, output shape or config key changes; `mise run semver` decides the library half. (Added 2026-09-01 — the block carried no §6, so `ready lint` emitted no `bump` fact for a row that has already landed three commits.)
* **Test obligation (§7).** Over the compiled binary, shown able to fail per [CLOUD-418](https://linear.app/buttoninc/issue/CLOUD-418/a-new-gate-is-never-shown-to-fail-so-a-test-that-cannot-discriminate). The discriminating case needs TWO profiles: `a_tree_emptied_by_something_other_than_the_escalation_is_still_a_cold_one` deletes `deps` on a one-profile tree, so the walk comes back empty and any quantifier answers cold — it cannot see this class. Red before: a built profile whose `deps` is REMOVED while another profile stays intact. Green and staying green: every built profile intact is still warm, and a tree cargo never fingerprinted still takes the fallback — without that pair the fix is satisfied by charging everything to `cold`, which raises the COLD floor instead and fails the same way one bucket over.
* **Blockers (§8).** None. `relatedTo` [CLOUD-1157](https://linear.app/buttoninc/issue/CLOUD-1157/stem-of-cannot-group-an-extensioned-artifact-so-rlib-rmeta-and-so-are) (whose journal this is), [CLOUD-861](https://linear.app/buttoninc/issue/CLOUD-861/target-prune-checks-the-disk-floor-once-per-lap-before-the-phase-that) (the closing reading that surfaces it), [CLOUD-1030](https://linear.app/buttoninc/issue/CLOUD-1030/target-prunes-escalation-guarantees-a-cold-build-but-the-floor-it) (the escalation-invalidates-basis half) and [CLOUD-1153](https://linear.app/buttoninc/issue/CLOUD-1153/target-prune-reports-a-refusing-batten-as-not-enough-disk-so-a) (the other way this refusal misreports its own cause).
**Acceptance**
* ✅ A profile cargo has built whose `deps` is missing or empty makes the tree cold, whichever other profiles survive. **Landed on #770** (`856c3746`).
* ✅ A tree whose every built profile is intact is still warm, and a tree cargo never fingerprinted is judged by the pre-existing reading. **Landed on #770.**
* ✅ The refusal names the journal that holds a learned floor, so the recovery is discoverable from the message rather than costing a full cold rebuild to find. **Landed on #770** (`1c896f89`).
* A ratchet with `cold` below `warm` never decides a refusal. **Withdrawn** — see §2. Silent on two of three instances, and the strengthened form conflicts with a landed measured test.
* A correctly-ordered but inflated ratchet is also caught. **Withdrawn with it**: no threshold separates the real case from the fixture's legitimate one. With the basis now honest, a cold lap is charged to the cold ratchet and the warm one is never taught a rebuild's demand, which is the route §2 takes instead.
* **Still open, and the reason this row is not Done:** the ratchet remains unbounded, so a genuinely mismeasured observation of any basis is still learned permanently. That needs a predicate over *satisfiability* — a floor above what a completed lap can leave free can only ever refuse — rather than over ordering or magnitude. Not attempted here.
Found while landing [CLOUD-746](https://linear.app/buttoninc/issue/CLOUD-746/execs-signal-forwarder-swallows-every-signal-after-the-first-and): six consecutive `land` laps refused on a floor the tool had taught itself from my own `rm -rf target`, and the fix was deleting a file no message named.
</p>
</details>
<!-- linear-review-link -->
<p><a href="https://linear.app/buttoninc/review/docstoolchain-correct-the-shell-discriminator-and-the-stale-wiring-9449b8565b52">Review in Linear</a></p>
|
Warning Review limit reachedNext included review available in 30 minutes. View limit detailsLimit details: You’ve used the included review currently available. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (14)
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 |
7d13a34 to
87452be
Compare
7497b52 to
ab11418
Compare
… blocker Three comments in `[tasks."test:bats"]` said the body "runs under /bin/sh with no `set -e`". Measured false: a mise task declaring no `shell =` runs under `sh -e`, and `shell = "bash -c"` is the every-line-runs case. The discriminator is a DECLARED SHELL, not the file. This cost a debugging cycle in #798, where a bare call aborted the task before its own error branch could run. `.claude/rules/toolchain.md` names the contrast at the section readers generalise from, so the next author does not re-derive the three comments. `batten.toml`'s `[hook] exclusive` blocker note named `hooks-wiring-check` on the `$HOME` surface. That stopped being the blocker when CLOUD-1167 landed `input.tree.external`. The real blocker is four live sibling registrations, and the row now carries the table plus the measurement `siblings: 2, merged_siblings: 2`. Admits: 12bc0d5675f0158bf1441a5f15ea8ad6ad04b6e46b25c30153d189a44185b023 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: c52854e Admits-epoch: c81e3ad211cddfce765e42d79469223b10d01ff822929696d3914e643004f748 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The next reader scoping CLOUD-1160 is sent at a blocker that no longer exists instead of at the four live sibling registrations that are the real one. That already happened once this session and cost the row a wrong scoping before it was returned to Backlog. Admits-answer-precondition: batten.toml is itself the owning surface for policy config, and the change is prose INSIDE it: the blocker note at :5185 names hooks-wiring-check on the $HOME surface, which stopped being the blocker when CLOUD-1167 landed input.tree.external. No other surface can express a correction to batten.toml's own comments, and the diff is three sentences a reviewer reads in place. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for its own comments; there is no narrower surface to redirect to. R-RESTORE-IT would discard the correction and leave the stale blocker naming a retired mechanism. Refs: CLOUD-1085, CLOUD-1268, CLOUD-1160
CLOUD-312 row 10. `.claude/hooks/session-start.sh` was the last by-path hook
registration this repository owned. It did three separable things and only one
of them was its own:
ordering -> the declaration order of ten `[[hook.handler]] on = "session-start"`
rows in batten.toml. `dispatch` runs handlers in config order, so
install < doctor < preflight is now readable from the authority
instead of grepped out of a stubbed call log.
the bound -> `timeout_ms`, imposed by the parent. The script had none, on any
step, so a hung `mise install` wedged the session.
the report -> `mise.toml`'s `session:*` tasks, which keep the `step` helper's
log-and-tail contract verbatim. The door does not provide it:
stdout on a passing exit is advisory text, so a step that prints
on success would announce itself every session (CLOUD-891).
Verified by dispatching the real rows, not by reasoning about them: exit 0, the
whole provisioning ran, and the reply carried exactly the two lines the retired
script also emitted. `crates/batten/tests/session_provisioning.rs` is the tier —
ten cases over the compiled binary against a fixture whose handlers are stubs,
proving order, the bound (0.749s against a 30s sleep), silence on a pass, the
reason reaching the session on a failure, and that stdout is never forwarded.
The bootstrap objection that pulled this from #714 is answered rather than
argued: `deps-install` (CLOUD-1085, #798) puts the released binary on PATH at
provisioning, before Claude launches, so there is something to dispatch with.
`session:batten` then replaces it with this checkout's build.
Two corrections the live dispatch measured, both of my own writing:
`signing-posture` prints on success, so registered bare it added a line the
retired script's `step` wrapper had swallowed — repointed at `session:signing`;
and the claim that only `session-census` may speak was wrong, because
`container-preflight` speaks too and did under the old script as well.
NOT READY TO LAND. Three cases in two governed suites grep the retired script
and go red: `tests/commit-attribution.bats:41` and
`tests/container-preflight.bats:131,138`. Measured over the whole corpus —
those three and nothing else. Both suites declare subjects that survive
(`hk.pkl`, `mise.toml`, `mise-tasks/container-preflight.sh`), so
`bats-tests-not-deleted` refuses deleting them and `shell-retirement` refuses
editing them: `only_drops_a_retired_reference` admits a removed line that names
a retired path, and a removed `@test` block's other lines name nothing.
`V-SHELL-RULE-EDITED` declares no override and no bypass_env.
Admits: dddb39ae4779d81289438ea653f09f6610f0945146dd12f8dab939d528de807b
Admits-rule: protected-mutation
Admits-verdict: V-PROTECTED-MUTATION
Admits-subject: batten.toml
Admits-head: 094058e
Admits-epoch: 121f33e1fca81092b00c148867959d9bf6cb54a82832540296b8b7f4fb2b7efb
Admits-author: alec@wenzowski.com
Admits-prev: 0d15402fe6e42f8c889653b827876c756c2d35c008671b41bf49c239f3b0360e
Admits-answer-lost: CLOUD-312 row 10 stays open, .claude/settings.json keeps the last by-path registration on any surface this repository owns, and 295 lines of bash keep ordering provisioning with no bound on any step. The verified work — ten rows, ten tasks, a compiled tier, and a live dispatch that returned exit 0 — would exist only in a container the platform reclaims.
Admits-answer-precondition: This commit writes ten hook.handler rows into batten.toml plus the corrections measured while dispatching them. The handler table is compiled-in config with no other surface: no mise task, rego module or CLI verb can add a row to it, so writing batten.toml directly is the only route, and every row lands in a diff a reviewer reads beside the script it replaces.
Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml is the owning surface for hook.handler and for its own comments. R-RESTORE-IT would discard the rows and leave the deleted script with nothing dispatching its steps.
Refs: CLOUD-312, CLOUD-1085, CLOUD-898
… with its subject CLOUD-1294, and the root cause is one line deeper than the row described. `shell-retirement`'s `line_sources` was `["mise-tasks/*.sh", "crates/batten/tests/**/*.rs"]` — no `.bats` entry. `governed_at_head` selects every bats suite, but their lines were never a declared source, so `only_drops_a_retired_reference` bound `base` from `base-lines`, came back UNDEFINED, and arm B refused every edited `.bats` unconditionally, whatever the edit was. The one admitted edit has been unreachable for half the governed corpus since it was written, and the refusal was byte-identical to a real one. Reading them costs `.claude/rules/rust.md`'s ~5.4 µs per declared document over ~137 suites — under a millisecond, on a 100 ms budget. That alone does not finish it. The arm admits a removed LINE that names the retired path, and a removed `@test` block's opener, assertions and closing brace name nothing. So the removal term is factored into `admitted_removal` — mirroring `admitted_addition`, which has had four shapes behind one name since CLOUD-1224 — and gains a second: a line belongs to a case whose opener is absent at head AND whose retired-path-naming line is itself among the removed. That last conjunct is the one that matters. Without it, deleting only an opener would pass, because an absent opener is what identifies a dead block — so a half-deleted case could buy its own removal. `#MUTANT case-half-deleted` is over exactly that conjunct, and `#MUTANT case-names-nothing-retired` over the term that stops this becoming a licence to delete any case during any retirement. The bound is stated at the rule rather than discovered later: `removed` is a set of LINES, so a removed line whose text exactly matches one inside the dying case is admitted even if it came from elsewhere in the file. Positional alignment would close it and needs a diff algorithm the engine does not hand over. `crates/batten/tests/it/shell_retirement.rs` carries the discriminating trio, a byte-identical fixture differing only in which block the head drops: the retired case is admitted, the live one is refused, the opener-only one is refused. Its `row()` now mirrors the committed `line_sources`, without which every case would have passed or failed for the wrong reason. Spends it immediately: the three cases greping the retired `.claude/hooks/session-start.sh` are dropped from `tests/commit-attribution.bats` and `tests/container-preflight.bats`, whose `conserves` arms the previous commit already carries. TWO MORE COLLISIONS THE FIRST FIX EXPOSED, both measured on the same three cases. `tests/container-preflight.bats` binds `HOOK` once in `setup()` and every case greps `"$HOOK"`, so no line inside either case carries a path and the arm above saw nothing. The one case it did admit was admitted because the retired program's stem happened to appear in its title — an accident of wording, not a predicate. So a case also earns its removal by SPENDING a variable whose binding this delta removes, which is CLOUD-1283's bound-and-spent shape on the removal side. `retired_path_vars` is deliberately not reused: it feeds the repointing arms on the ADDITION side, where the byte-check is the safety property. And `no-consumer-repo-name` bans a substring across `crates/**` while `bats-tests-not-deleted` requires a ledger arm to quote the deleted case title VERBATIM under `crates/batten/tests/**`. A governed case whose title carries that word was therefore unrecordable and unretirable by construction — measured on "the fixer is wired: session-start runs it, so a clone is compliant before it commits". The exclude is anchored at the four ledger markers, so nothing but an arm can reach it. Weakens: rule-predicate-changed at rule[no-consumer-repo-name].exclude Admits: 7ca6e8035a02da57c33a19d221be1af0e55e1fef1fea39c8ad9991841477f3d6 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: policy/shell-retirement.rego Admits-head: 883e6fc Admits-epoch: 1850ecd0e20d51a13ee1bac68cf83478e646722efa856949dbd7ba94592bc22a Admits-author: alec@wenzowski.com Admits-prev: a170b2fd2ffba82e64605dec51e5b566546a4d9da4aa00baf16c7d20823698c3 Admits-answer-lost: CLOUD-312 row 10 cannot land at all. The gate refuses both of its own landable shapes for a retirement it mandates: editing the two suites that grep the retiring script is V-SHELL-RULE-EDITED, and deleting them is refused because their subjects survive. That is the structural dead end CLOUD-1051 already fixed once for a removed line, still unfixed for a removed case. Admits-answer-precondition: policy/shell-retirement.rego is the owning surface for its own admission arms; no config key or CLI verb can add one. It is protected only because CLOUD-833 derives every policy row module into the protected set, and the class own remedy says to change it under review, which is what PR 804 is. The diff adds one admitted-removal shape and leaves every existing term byte-identical. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: this module is the owning surface for shell-retirement admission arms. R-RESTORE-IT would restore a gate with no landable spelling for the change it demands. Admits: c92d661175b162435c20a9c2a7e78f9e57c3e805f36049f1cac292dc42728f5c Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: 883e6fc Admits-epoch: 1850ecd0e20d51a13ee1bac68cf83478e646722efa856949dbd7ba94592bc22a Admits-author: alec@wenzowski.com Admits-prev: dddb39ae4779d81289438ea653f09f6610f0945146dd12f8dab939d528de807b Admits-answer-lost: The one admitted edit is structurally unreachable for the half of the governed set that is bats suites, and has been since the arm was written. Every retirement whose cleanup touches a suite is refused with no landable spelling, and the refusal is indistinguishable from a genuine one because the finding is identical. CLOUD-1294 measured it on three cases in two suites. Admits-answer-precondition: The shell-retirement row line_sources is ["mise-tasks/*.sh", "crates/batten/tests/**/*.rs"], so input.tree.lines and base-lines are undefined for every tests/**/*.bats path. only_drops_a_retired_reference binds base from base-lines, so it is UNDEFINED for a bats suite and arm B refuses every edited suite unconditionally, whatever the edit. A rule row line_sources exists only in batten.toml; no module or task can widen it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml is the owning surface for a rule row declared source list. R-RESTORE-IT restores a gate that cannot read the files it governs. Admits: 82401ea1725edb719c698e71a25f8697a8d14456174117cbe21e7be8ca95b815 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: policy/shell-retirement.rego Admits-head: 883e6fc Admits-epoch: 9b32b04b38932b50d9c3b13da9cde22f4f92ef55951a0ac1517c2eb9743945d2 Admits-author: alec@wenzowski.com Admits-prev: 7ca6e8035a02da57c33a19d221be1af0e55e1fef1fea39c8ad9991841477f3d6 Admits-answer-lost: Two of the three cases CLOUD-312 row 10 must drop stay refused, so the retirement still cannot land. The arm would admit only the case that happens to name the path in its title, which is an accident of wording rather than a predicate. Admits-answer-precondition: The container-preflight cases reference the retired script only through the HOOK variable, whose binding this same delta removes, so no line inside either case names the retired path and the case arm cannot see them. Resolving that binding is a property of this module admission arms; there is no other surface for it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: this module owns its admission arms. R-RESTORE-IT leaves the gate refusing a cleanup it mandates. Admits: 0d93e4b921f90a44889c603f2698d85c37d62c6ef5ef15fad2127fe103dd4804 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: 883e6fc Admits-epoch: 9b32b04b38932b50d9c3b13da9cde22f4f92ef55951a0ac1517c2eb9743945d2 Admits-author: alec@wenzowski.com Admits-prev: c92d661175b162435c20a9c2a7e78f9e57c3e805f36049f1cac292dc42728f5c Admits-answer-lost: CLOUD-312 row 10 cannot record the commit-attribution case it drops, so that case cannot be retired at all. More generally every governed bats case whose title carries the word is permanently unretirable, and the refusal reads as a rule 1 violation over what is actually a quoted historical string about a line being deleted. Admits-answer-precondition: A retirement ledger arm must quote the deleted case title verbatim for bats-tests-not-deleted to match it, and declared_in puts every arm under crates/**, which is this row glob. A case whose title contains the banned word is therefore unrecordable and unretirable by construction. The exclude is the only surface that can say so; a rule row lives in batten.toml alone. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml owns a forbid row exclude. R-RESTORE-IT restores a rule that makes a class of retirement structurally impossible. Refs: CLOUD-1294, CLOUD-312, CLOUD-1051 Weakens: rule-predicate-changed rule[shell-retirement].line_sources Weakens: rule-predicate-changed rule[no-consumer-repo-name].exclude
`format_push_string` and then `items_after_statements` — the workspace lints refuse the extra allocation and then refuse the import that avoids it if it sits after the first statement. The earlier auto-fix was lost when main's CLOUD-1210 restructure moved this file into the `it` group during the rebase. Refs: CLOUD-1294, CLOUD-1210
`a_bats_case_spending_a_surviving_binding_is_refused` failed, and for a real reason rather than a harness one: the fixture bound `HOOK` to the literal repo-relative path, so `retired_path_vars` resolved it, `mentions_retired`'s variable arm saw the spelling inside the case body, and the FIRST `case_earns_removal` arm admitted the case whether or not the binding died. The anti-vacuity mirror could not hold, and the binding arm was untested. Rebound through `$BATS_TEST_DIRNAME/../…`, which is what `tests/container-preflight.bats` writes and what the arm was added for: it matches neither `shell-script-directory` nor the marker pattern, so `retired_path_vars` is empty and the case body names nothing. The pair now discriminates — binding removed is admitted, binding kept is refused. The suite cost table is regenerated in the same commit because the rebase took main's copy, which still records the suite this branch retires. Refs: CLOUD-1294, CLOUD-1283
e0c6032 to
6063049
Compare
|
❌ The last analysis has failed. |
|
/fast-forward |
CLOUD-1253 builds the gate for the closing half: a closed retirement row judged against the tree, from the `conserves` arms that already exist. It cannot carry the READING half, and the reading half is where the loss happens. A gate runs at `verify` and at close; an agent consults a row's state continuously and acts on it at once, so between two runs of any gate a wrong state is load-bearing prose that an agent trusts. MEASURED TWICE IN ONE SESSION, both mine. `CLOUD-1162` sat In Review with `board-diff-overlap.sh` still tracked, because the merge moves a row the moment a PR attaches to it. My first correction was a warning paragraph inside the body with the state left alone — which is that row's own recorded finding one level up, where a correction block does not correct a title. A state is read by more automation than a title is. `CLOUD-1160` sat In Progress with nothing shipped and no PR. Its only attachment was #804, whose title reads "CLOUD-312 row 10 + CLOUD-1294: session-start.sh retires" — different work, already merged. I read "attachment present + In Progress" as another session's live work and declined to race it, reporting the largest single-program retirement available as taken. The contradicting evidence was in the payload I had already printed. THE THREE CLAUSES, and each names a failure that actually happened rather than one that is easy to imagine: the tree settles it; the row moves BACK to Backlog rather than Todo, since parking an unpullable row in the ready queue hands the next agent work that cannot be started; and the move owes a comment, never a note inside the body. NOT GATED, AND SAID SO. Non-negotiable rule 3 puts "did the agent consult the tree before believing a row" outside what a gate decides — it is a model verdict. So this is feedforward with CLOUD-1253's predicate as the gated half, which is `.claude/rules/scanning.md`'s own shape for its suitability axis. LINE-NEUTRAL, because `[budget.instructions]` was at 199/199. The section it joins is compressed to pay for it: 198 lines and 3405 tokens, three fewer than before the rule existed. No budget was raised to make room. Five cases in the new tier, and the fourth is not decoration on the third: annotating in place is the failure that happened, so a reader who takes "move it back" as satisfied by an explanatory paragraph has made the same mistake. The fifth is the anti-vacuity one — it pins the text inside the board section of the always-loaded file, since the other four would pass just as well over a rules file that loads at a trigger, and "I am about to trust a row's state" has none. Shown able to fail on the real file rather than a fixture: replacing one clause turned exactly its own case red with the other four green, and restoring it returned all five. Refs: CLOUD-1305 Refs: CLOUD-1253
… reach The launcher registers two hooks under `$HOME` that this repo cannot unregister through a settings file, because Claude Code merges hooks across them. From that measured fact one session inferred that turning them off is "an owner action on the environment configuration, outside this repository" — and the sentence then propagated by quotation into four tracked files, three memories and six board rows, where each copy read as an independent finding rather than as one claim restated. It is false. Outside the repository ROOT is not outside its REACH: `batten wiring reclaim` removes a merged registration without touching the file that carries it, and a `[[hook.handler]] on = "session-start"` row runs it every session — the surface `session:identity` and `session:signing` already use to beat this launcher's own `--global` writes. A repair that runs once loses to a rewrite that runs every session; a repair that also runs every session does not. `.claude/rules/commits.md` is the tracked instance corrected here; the merge-precedence measurement above it stands unchanged, since only the conclusion was wrong. The two `policy/*.rego` headers and `batten.toml` carry the same sentence and are protected paths, so they land with the handler row rather than here. Both memory edits file a deferral rather than annotate one. Each read "unfiled because the tracker was unreachable" — true when written, and a class that suppresses its own report is under-represented by construction rather than rare. Filed from a session whose connector is bound: CLOUD-1359 (both MCP gates pass green while no connector tool binds) and CLOUD-1361 (a container's first `linear-check` refuses on a symlink no turn has yet had the chance to write, against a `[transcript]` comment saying the opposite). Refs: CLOUD-1356 Admits: 24cfdf1da8480ffc7591864e990b03f329771211c9b1da15a1539b43483b37ae Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/toolchain-and-hooks.md Admits-head: d8fb32b Admits-epoch: 0a320ce25727111b30416e5cc416e9294fc3303b59762fab85faa9ababbcff71 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The deferral stays deferred and self-justifying: a note explaining that it cannot be filed, in a repository where the reason it could not be filed has gone away, reads to every later session as a standing constraint rather than as an expired one. That is the exact shape this branch exists to retract one instance of, so declining here would leave the same class uncorrected in the file that records it. The stale remedy is the second cost — it points the next reader at a retired script, so acting on it produces a change that cannot land. Admits-answer-precondition: The memory IS the owning surface for this content, and the write was made through `mcp__serena__edit_memory` — the route the path's own redirect names — so this records the change rather than authorising a route around it. What the edit does is close a deferral the memory itself declared: the `.claude/.transcript.jsonl` note read "Unfiled: the tracker was unreachable in the session that measured it", which was true when written and stopped being true in this session, whose connector is bound. The finding is now CLOUD-1361 and the note points at it. It also corrects that note's remedy line, which prescribed a write "beside the other things `session-start.sh` already asserts" — that script is retired (CLOUD-312 row 10, #804), so the remedy named a surface that no longer exists. The write lands in the reviewed PR for CLOUD-1356, where `prettier`, `rules-drift` and `no-docs-tree` judge it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is what was TAKEN, not rejected: the edit went through Serena's `edit_memory`, and this admission exists only because `commit check` records every protected path in the diff regardless of the route that wrote it. R-RESTORE-IT does not apply because nothing was destroyed — the edit replaces one three-line note with a longer one that keeps its original wording quoted, so the superseded claim stays readable beside its correction rather than being deleted. Admits: 9d1e8ef441db67ee9cdf8c10962322b30886c00d8b232a7118c79b7dba285fa6 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/connector-allowlist-recovery.md Admits-head: d8fb32b Admits-epoch: 0a320ce25727111b30416e5cc416e9294fc3303b59762fab85faa9ababbcff71 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The sensor gap stays unfiled behind a reason that has expired, and every later session that reads this file is told the tracker cannot be reached when it can. The open question keeps a premise now known to be false, so whoever measures it next designs a single before/after reading that cannot distinguish "my write took effect" from "my write survived" — and gets a green answer to the wrong one of those two questions, which is the failure this file already documents in three other forms. Admits-answer-precondition: The memory IS the owning surface for this content, and the write went through `mcp__serena__edit_memory` — the route the path's redirect names — so this records the change rather than authorising a route around it. Two edits. The first closes the deferral the file declared in its own words, "Sensor gap, unfiled because the tracker is the unreachable thing": that is now CLOUD-1359, and the paragraph keeps the original sentence quoted because the reason it went unfiled is itself the finding — a defect whose occurrence blocks its own report is under-represented by construction rather than rare. The second answers nothing and says so: the file's open question about whether a SessionStart settings write reaches the session that is starting is still unanswered, and the edit adds only the measurement this session did produce — both launcher scripts carry mtime 16:59, MID-session — which falsifies the question's premise that startup is one ordered moment. It lands in the reviewed PR for CLOUD-1356. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is what was TAKEN, not rejected: the edit went through Serena's `edit_memory`, and this admission exists only because `commit check` records every protected path in the diff regardless of the route that wrote it. R-RESTORE-IT does not apply because nothing was destroyed — both edits preserve the superseded wording as a quotation, deliberately, since in the first case the retracted reason is the evidence for the row that replaces it.
… reach The launcher registers two hooks under `$HOME` that this repo cannot unregister through a settings file, because Claude Code merges hooks across them. From that measured fact one session inferred that turning them off is "an owner action on the environment configuration, outside this repository" — and the sentence then propagated by quotation into four tracked files, three memories and six board rows, where each copy read as an independent finding rather than as one claim restated. It is false. Outside the repository ROOT is not outside its REACH: `batten wiring reclaim` removes a merged registration without touching the file that carries it, and a `[[hook.handler]] on = "session-start"` row runs it every session — the surface `session:identity` and `session:signing` already use to beat this launcher's own `--global` writes. A repair that runs once loses to a rewrite that runs every session; a repair that also runs every session does not. `.claude/rules/commits.md` is the tracked instance corrected here; the merge-precedence measurement above it stands unchanged, since only the conclusion was wrong. The two `policy/*.rego` headers and `batten.toml` carry the same sentence and are protected paths, so they land with the handler row rather than here. Both memory edits file a deferral rather than annotate one. Each read "unfiled because the tracker was unreachable" — true when written, and a class that suppresses its own report is under-represented by construction rather than rare. Filed from a session whose connector is bound: CLOUD-1359 (both MCP gates pass green while no connector tool binds) and CLOUD-1361 (a container's first `linear-check` refuses on a symlink no turn has yet had the chance to write, against a `[transcript]` comment saying the opposite). Refs: CLOUD-1356 Admits: 24cfdf1da8480ffc7591864e990b03f329771211c9b1da15a1539b43483b37ae Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/toolchain-and-hooks.md Admits-head: d8fb32b Admits-epoch: 0a320ce25727111b30416e5cc416e9294fc3303b59762fab85faa9ababbcff71 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The deferral stays deferred and self-justifying: a note explaining that it cannot be filed, in a repository where the reason it could not be filed has gone away, reads to every later session as a standing constraint rather than as an expired one. That is the exact shape this branch exists to retract one instance of, so declining here would leave the same class uncorrected in the file that records it. The stale remedy is the second cost — it points the next reader at a retired script, so acting on it produces a change that cannot land. Admits-answer-precondition: The memory IS the owning surface for this content, and the write was made through `mcp__serena__edit_memory` — the route the path's own redirect names — so this records the change rather than authorising a route around it. What the edit does is close a deferral the memory itself declared: the `.claude/.transcript.jsonl` note read "Unfiled: the tracker was unreachable in the session that measured it", which was true when written and stopped being true in this session, whose connector is bound. The finding is now CLOUD-1361 and the note points at it. It also corrects that note's remedy line, which prescribed a write "beside the other things `session-start.sh` already asserts" — that script is retired (CLOUD-312 row 10, #804), so the remedy named a surface that no longer exists. The write lands in the reviewed PR for CLOUD-1356, where `prettier`, `rules-drift` and `no-docs-tree` judge it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is what was TAKEN, not rejected: the edit went through Serena's `edit_memory`, and this admission exists only because `commit check` records every protected path in the diff regardless of the route that wrote it. R-RESTORE-IT does not apply because nothing was destroyed — the edit replaces one three-line note with a longer one that keeps its original wording quoted, so the superseded claim stays readable beside its correction rather than being deleted. Admits: 9d1e8ef441db67ee9cdf8c10962322b30886c00d8b232a7118c79b7dba285fa6 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/connector-allowlist-recovery.md Admits-head: d8fb32b Admits-epoch: 0a320ce25727111b30416e5cc416e9294fc3303b59762fab85faa9ababbcff71 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The sensor gap stays unfiled behind a reason that has expired, and every later session that reads this file is told the tracker cannot be reached when it can. The open question keeps a premise now known to be false, so whoever measures it next designs a single before/after reading that cannot distinguish "my write took effect" from "my write survived" — and gets a green answer to the wrong one of those two questions, which is the failure this file already documents in three other forms. Admits-answer-precondition: The memory IS the owning surface for this content, and the write went through `mcp__serena__edit_memory` — the route the path's redirect names — so this records the change rather than authorising a route around it. Two edits. The first closes the deferral the file declared in its own words, "Sensor gap, unfiled because the tracker is the unreachable thing": that is now CLOUD-1359, and the paragraph keeps the original sentence quoted because the reason it went unfiled is itself the finding — a defect whose occurrence blocks its own report is under-represented by construction rather than rare. The second answers nothing and says so: the file's open question about whether a SessionStart settings write reaches the session that is starting is still unanswered, and the edit adds only the measurement this session did produce — both launcher scripts carry mtime 16:59, MID-session — which falsifies the question's premise that startup is one ordered moment. It lands in the reviewed PR for CLOUD-1356. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE is what was TAKEN, not rejected: the edit went through Serena's `edit_memory`, and this admission exists only because `commit check` records every protected path in the diff regardless of the route that wrote it. R-RESTORE-IT does not apply because nothing was destroyed — both edits preserve the superseded wording as a quotation, deliberately, since in the first case the retracted reason is the evidence for the row that replaces it.
.claude/hooks/session-start.shwas the last by-path hook registration thisrepository owned. It retires; its ordering and its bounds become
batten.toml.Landing it required repairing the gate that refused it, so this is two changes
with one dependency between them.
Closes CLOUD-1294.
DO-NOT-CLOSE CLOUD-312 — this lands row 10 of its thirteen; the issue itself is
already Done and the remaining rows are other people's.
DO-NOT-CLOSE CLOUD-1085 — it stays In Review. Its acceptance clause (a) is an
observation on a FRESH container (
battenon PATH before the first hook fires,proved by the contract snapshot being written at SessionStart), and no commit can
discharge that.
Verified by dispatching the real rows, not by reasoning about them.
printf '{"hook_event_name":"SessionStart",…}' | batten hook --harness claude-code→ exit 0, the whole provisioning ran, and the reply carried exactly the two
advisory lines the retired script also emitted.
1. The retirement (CLOUD-312 row 10)
The script did three separable things and only one was its own:
[[hook.handler]] on = "session-start"rows.dispatchruns handlers in config order, soinstall < doctor < preflightis readable from the authority instead of grepped out of a stubbed call log.timeout_ms, imposed by the parent. The script had none, on any step, so a hungmise installwedged the session. Every bound here is cold-sized — the opposite standard frommcp-attach-check's warm 2000ms, because these rows are the cold case.mise.toml'ssession:*tasks, keeping thestephelper's log-and-tail contract verbatim. The door does not provide it: stdout on a passing exit is advisory text, so a step that prints on success would announce itself every session (CLOUD-891).doctor hookssiblings forclaude-codefalls 2 → 1. The remainder isrun-shape-guard(CLOUD-856), so[hook] exclusiveis one retirement away.The bootstrap objection is answered, not argued. #714 pulled this move
because a handler is dispatched by the binary and nothing installed one first.
CLOUD-1085 closed that:
deps-install(#798) puts the released binary on PATH atprovisioning, before Claude launches.
session:battenthen replaces it with thischeckout's build. The residual exposure is stated at the rows rather than hidden.
Two corrections the live dispatch measured, both of my own writing:
signing-postureprints on success, so registered bare it added a line theretired script's
stepwrapper had swallowed — repointed atsession:signing;and the claim that only
session-censusmay speak was wrong, becausecontainer-preflightspeaks too, and did under the old script as well.2. The gate that could not admit it (CLOUD-1294)
Dropping the three cases that greped the retired script was refused by
shell-retirement, and neither of its two landable shapes reached them. Theroot cause was one line deeper than the row was filed on.
line_sourceswas["mise-tasks/*.sh", "crates/batten/tests/**/*.rs"]— no.batsentry.governed_at_headselects every bats suite, but their lineswere never a declared source, so
only_drops_a_retired_referenceboundbasefrom
base-lines, came back undefined, and arm B refused every edited.batsunconditionally, whatever the edit. The one admitted edit has beenunreachable for half the governed corpus since it was written, and the refusal
was byte-identical to a real one.
Reading them costs ~5.4 µs per declared document over ~130 suites — under a
millisecond, on a 100 ms budget.
That alone does not finish it, so the removal term is factored into
admitted_removal— mirroringadmitted_addition, which has had four shapesbehind one name since CLOUD-1224 — and gains a second shape: a line belongs to a
@testcase whose opener is gone from head and which earned its removal.Two ways to earn it, and the second was forced by measurement:
bound-and-spent shape.
container-preflight.batsbindsHOOKonce insetup()and every case greps"$HOOK", so no line inside either case carriesa path at all. The one case the first arm admitted was admitted because the
program's stem happened to appear in its title, which is an accident of
wording rather than a predicate.
retired_path_varsis deliberately not reused: it feeds the repointing arms onthe addition side, where nothing is going away and the byte-check is the
whole safety property.
A third collision, structural.
no-consumer-repo-namebans a substringacross
crates/**whilebats-tests-not-deletedrequires a ledger arm to quotethe deleted case title verbatim under
crates/batten/tests/**. A governedcase whose title carries that word was unrecordable, and therefore unretirable,
by construction. The exclude is anchored at the ledger markers, so nothing but an
arm can reach it.
Both
batten.tomlpredicate changes are declared: groomed onto CLOUD-1294 withthe honest note that the clauses were written after the work started, and
carried as
Weakens:commit trailers.Evidence
differing only in which block the head drops: retired case admitted, live
case refused, half-deleted case refused. Without the second this arm is
a licence to delete any case during any retirement.
#MUTANTrows over exactly the conjuncts that make each refusal.mise run batten-checkclean, where it reported five findings before.mise run config-lint: 0 smells.mise run lint:clippyclean.Both protected paths carry their
Admits:articulation blocks, andbatten commit checkpasses over the range after every amend.Also folded in
mainmoved four times during this work. CLOUD-1210 grouped the integrationtests into one
tests/it/target with a count ratchet, so the new tier landsthere with a
modline. #794 landed its// ported:conserves arm; the twochanges touch the same two files in different functions and rebased cleanly.
One environment finding recorded rather than worked around: the prune lap journal
had learned a 12702MB warm floor from the CLOUD-1210 rebuild lap, so every lap
escalated and dropped cache — twice deleting the binary out from under a running
test and surfacing as an unrelated red. Measured onto CLOUD-1218's open bullet.
Refs: CLOUD-312, CLOUD-1085, CLOUD-1283, CLOUD-1268, CLOUD-1160, CLOUD-898, CLOUD-1051, CLOUD-1218
🤖 Generated with Claude Code
https://claude.ai/code/session_0134WDLd3bJpLPxZyRS6zPW7