From 33ae44beb3d098938bfa95b36385d0605a7379de Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 17:54:57 +0000 Subject: [PATCH 1/7] feat(mise): session:wiring, the launcher-hook repair nobody had registered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1079. `~/.claude/launcher-settings.json` registers two non-batten hooks — `session-start-git-identity.sh` on SessionStart, `stop-hook-git-check.sh` on Stop. Six rows recorded their removal as "an owner action on the environment configuration, outside this repository", inheriting the sentence by quotation from CLOUD-605. It is false, and CLOUD-1356 owns retracting it everywhere. What CLOUD-605 measured is that a ONE-OFF repair loses to a rewrite that runs every session. True, and it does not generalise. `session:signing` exists in its own words because "the launcher writes `commit.gpgsign true` --global every session, and local beats global only if something writes local", and it wins. `session:identity` is the same shape. This is the third member of that family. The repair is `batten wiring reclaim`, which predates CLOUD-1079: wiring.rs:293, declared surface.rs:3466, asserted in tests/it/wiring_reclaim.rs:173. It removes REGISTRATIONS and never files, which is why it is the instrument — the identity script also sets `core.hooksPath` for the whole container. Why it stayed invisible: the installed binary was 0.0.121 (Aug 28) against a 0.0.137 tree, so `wiring reclaim` answered "unrecognized subcommand" and CLOUD-1314's refusal test was not in it either. `batten doctor hooks -J` reported `siblings: 0, ok: true` while `merged: 2` showed it reading both registrations. On 0.0.137: `merged_siblings: 2, ok: false`. CLOUD-1326 owns that class. INCOMPLETE, DELIBERATELY COMMITTED, AND THE REMAINDER IS NAMED. This is the task half only. Its `[[hook.handler]] id = "session-wiring"` row belongs in batten.toml after `session-signing-posture` and before `session-container-preflight`, and is not here: batten.toml is a protected path, the write is refused by `protected-mutation`, and the declared override route (`articulate the write`) issued admission 19157b02c321ce6e36b629d70b2f7b4153724724bf3afa57dbabc8505732d3bf — which the boundary honours only once SPENT, and `batten override spend` is blocked by this session's permission classifier. So the task does not dispatch yet. A task nothing calls is inert rather than harmful, and committing it beats losing it to a container reclaim; the row is owed under CLOUD-1079. Measured 2026-09-02: both scripts carry mtime 16:59 — rewritten MID-SESSION, not only at session start, which is stronger than CLOUD-1079 recorded. A session-start handler closes the session-start window and not that one; the `deps-install` provisioning seam upstream of both is the follow-up. Refs: CLOUD-1079, CLOUD-1356, CLOUD-605, CLOUD-1314, CLOUD-1326, CLOUD-1085 --- mise.toml | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/mise.toml b/mise.toml index 888709e03..f3e1aadf6 100644 --- a/mise.toml +++ b/mise.toml @@ -2131,6 +2131,62 @@ description = "Session start: put the not-signing posture into force, quietly # `step`-wrapped this call, so that line was never a session's to read. run = "mise run signing-posture --repair >/tmp/session-start-signing.log 2>&1 || { echo '::error:: session-start: signing-posture failed — see /tmp/session-start-signing.log' >&2; tail -5 /tmp/session-start-signing.log >&2; exit 1; }" +[tasks."session:wiring"] +description = "Session start: reclaim the hook surface from launcher-provisioned siblings — the third repair of the class `session:identity` and `session:signing` already close (CLOUD-1079)" +# THE THIRD ROW OF A FAMILY, NOT A NEW MECHANISM, and saying so is the whole +# correction. `session:signing` exists because "the launcher writes +# `commit.gpgsign true` --global every session, and local beats global only if +# something writes local" — a session-start handler beating a per-session +# launcher rewrite. `session:identity` is the same shape. This is that argument +# applied to the launcher's HOOK REGISTRATIONS, which six rows recorded as +# unreachable from here. +# +# WHAT IT REPAIRS. `~/.claude/launcher-settings.json` registers +# `session-start-git-identity.sh` on `SessionStart` and `stop-hook-git-check.sh` +# on `Stop`. The second instructs the exact commit identity `[attribution] +# identity_deny` refuses, so its remedy produces a commit `commit-attribution` +# denies (CLOUD-605, non-negotiable rule 8) — two policies, one of which has to +# not run. +# +# THE CLAIM THAT KEPT THIS UNBUILT WAS FALSE, and it is retracted rather than +# quietly dropped (CLOUD-1356). CLOUD-605 concluded "the only place it can +# actually be turned off is the environment configuration that generates +# `launcher-settings.json` — outside this repository, an owner action", and five +# more rows inherited it by quotation. What CLOUD-605 measured is that a ONE-OFF +# repair loses to a rewrite that runs every session. True, and it does not +# generalise: a repair that also runs every session does not lose, which is the +# whole reason the two siblings above exist. +# +# REGISTRATIONS, NEVER FILES, and the distinction is load-bearing rather than +# stylistic. `session-start-git-identity.sh` also sets `core.hooksPath` for the +# whole container, so deleting the file has a blast radius this repository has no +# business taking. `batten wiring reclaim` removes the registration and leaves +# the file, which is why it is the instrument and a blind `rm` is not. +# +# WHY THE DEFECT WAS INVISIBLE. Measured 2026-09-02: the installed binary was +# 0.0.121, built Aug 28, against a source tree at 0.0.137. `wiring reclaim` was +# absent from it, so an agent checking reachability concluded the repair did not +# exist; and CLOUD-1314's `test_the_retired_launcher_hooks_are_refused_if_they_return` +# was not in it either, so `batten doctor hooks -J` answered `siblings: 0, +# ok: true` while `merged: 2` showed it looking straight at both registrations. +# On 0.0.137 the same host answers `merged_siblings: 2, ok: false`. The green was +# the stale binary's, not the host's. CLOUD-1326 owns that class. +# +# AFTER `session-batten` FOR EXACTLY THAT REASON: the repair is a verb of the +# binary that step installs, so a stale or absent one turns this into a no-op +# that reports success — the same silent-absence failure the step above exists to +# stop. Its failure is loud for the same reason every sibling's is. +# +# WHAT THIS DOES NOT CLAIM. Measured the same day, both scripts carry mtime +# 16:59 — REWRITTEN MID-SESSION, not only at session start, which is stronger +# than CLOUD-1079 recorded. So this closes the session-start window and does not +# hold against a mid-session re-provision. The residue is real and is recorded +# rather than handed to an owner; what would close it is the provisioning seam +# `deps-install` above already describes, which the container's setup script +# calls before any session begins — upstream of both the rewrite and the harness +# snapshot. +run = "batten wiring reclaim >/tmp/session-start-wiring.log 2>&1 || { echo '::error:: session-start: wiring reclaim failed — see /tmp/session-start-wiring.log' >&2; tail -5 /tmp/session-start-wiring.log >&2; exit 1; }" + [tasks."session:census"] description = "Session start: record this boot and read back what was running when the container we replaced went down (CLOUD-451)" # RECORD BEFORE READ, and the order is load-bearing: recording this boot after From cbba5fc8c73c816af39af0a700fa888c5527c067 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 19:49:58 +0000 Subject: [PATCH 2/7] docs(rules): retract "outside this repository" where the repair is in reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: d8fb32bff88e48a4cb52b568957fd8dc656f5440 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: d8fb32bff88e48a4cb52b568957fd8dc656f5440 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/rules/commits.md | 21 +++++++++--- .../memories/connector-allowlist-recovery.md | 32 +++++++++++++++---- .serena/memories/toolchain-and-hooks.md | 16 ++++++++-- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/.claude/rules/commits.md b/.claude/rules/commits.md index 7d0761d77..6f418851c 100644 --- a/.claude/rules/commits.md +++ b/.claude/rules/commits.md @@ -111,11 +111,22 @@ and adopts a change, never to a model identity. Commits here are SSH-signed, so the signature term is already satisfied and the email term alone carries the refusal. The only value it accepts is the one `identity_deny` forbids: not a tuning problem, two contradictory policies. -- **Deleting it does not survive.** It is registered in the launcher's own - settings, re-provisioned mid-session, and Claude Code _merges_ hooks across - settings files — so a lower-precedence file can add a hook and never remove - one. Turning it off is an owner action on the environment configuration that - generates those settings, outside this repository. +- **Deleting the FILE does not survive, and that is not the same as being + unreachable.** It is registered in the launcher's own settings, re-provisioned + mid-session, and Claude Code _merges_ hooks across settings files — so a + lower-precedence file can add a hook and never remove one. All of that is + measured and all of it stands. The conclusion drawn from it — + _"turning it off is an owner action on the environment configuration that + generates those settings, outside this repository"_ — **was false, and is + retracted** (CLOUD-1356; CLOUD-1079 owns the fix). Merging means the repo + cannot unregister the hook _through a settings file_, which is a narrower + claim: `batten wiring reclaim` removes the **registration** without touching + the file that carries it, and a `[[hook.handler]] on = "session-start"` row + runs it every session, exactly as `session:identity` and `session:signing` + already 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. Reaching for _"outside this repository"_ over a `$HOME` path + is the tell: outside the repository **root** is not outside its **reach**. - **The signature half is a different issue.** GitHub answers `verified: false, reason: unknown_key` — the key is unpublished, not absent. That is CLOUD-591's, and resetting the author signs nothing, so obeying trades a tracked gap for a diff --git a/.serena/memories/connector-allowlist-recovery.md b/.serena/memories/connector-allowlist-recovery.md index c60995979..bacbd850c 100644 --- a/.serena/memories/connector-allowlist-recovery.md +++ b/.serena/memories/connector-allowlist-recovery.md @@ -219,12 +219,19 @@ with all its tools; this session did not bind them._ `ListMcpResourcesTool` (resources, not tools) and `claude mcp list` (CLI config, not connectors) prove NOTHING here — the only evidence is a call returning "No such tool available". -**Sensor gap, unfiled because the tracker is the unreachable thing.** Both -`mcp-attach-check` and `mcp-allow-check` pass green through this. Neither -compares the injected config's `tools[].name` against the tools the session can -actually call, which is the one comparison that catches it — and -`connector-allow-resolve` already reads that file, so only the predicate is -missing. Recorded in PR #575's body; wants a row of its own. +**Sensor gap — FILED 2026-09-02 as CLOUD-1359.** Both `mcp-attach-check` and +`mcp-allow-check` pass green through this. Neither compares the injected +config's `tools[].name` against the tools the session can actually call, which +is the one comparison that catches it — and `connector-allow-resolve` already +reads that file, so only the predicate is missing. + +This paragraph read _"unfiled because the tracker is the unreachable thing"_ for +its whole life, and that is worth keeping rather than deleting: **a defect whose +own occurrence blocks its report is under-represented in the tracker by +construction**, so the count of episodes is unknown rather than low. The +deferral was real while it held and stopped being real the moment a session with +a bound connector read this file. It is filed from one. Prior record: PR #575's +body. ## What is NOT known @@ -232,6 +239,19 @@ missing. Recorded in PR #575's body; wants a row of its own. - **Whether a `SessionStart` hook's settings write affects the session that is starting**, or only the next one. Permissions are read at startup and the hook runs at startup; the ordering is unmeasured. Measure it, do not assume it. + + **Still unanswered 2026-09-02 — but the question's premise is now known to be + too simple, which changes how to measure it.** It assumes startup is one + ordered moment. Measured this session: `~/.claude/launcher-settings.json` and + both its scripts carry mtime **16:59 — MID-session**, not session start + (CLOUD-1079). So the launcher rewrites the settings surface while a session is + running, and "does my startup write take effect" and "does my write survive" + are two different questions with two different answers. A one-shot write can + lose to a later rewrite even if the ordering at startup is favourable, which + is why the landed answer for the hooks themselves is a repair that runs **every + session** rather than a write that runs once. Whoever measures this must + distinguish the two; a single before/after reading cannot. + - **Whether the UUID survives an OAuth re-grant.** Stable across two containers is not stable across a re-grant. diff --git a/.serena/memories/toolchain-and-hooks.md b/.serena/memories/toolchain-and-hooks.md index 2cbd26956..7851dfabc 100644 --- a/.serena/memories/toolchain-and-hooks.md +++ b/.serena/memories/toolchain-and-hooks.md @@ -404,9 +404,19 @@ would set, so it chooses no evidence: ln -sfn ~/.claude/projects//.jsonl .claude/.transcript.jsonl -Unfiled: the tracker was unreachable in the session that measured it -(`mem:connector-allowlist-recovery`). It wants a row, and the fix is plausibly a -`SessionStart` write beside the other things `session-start.sh` already asserts. +**FILED 2026-09-02 as CLOUD-1361.** This read _"Unfiled: the tracker was +unreachable in the session that measured it"_ — true while it held, and the same +shape as `mem:connector-allowlist-recovery`'s sensor gap (CLOUD-1359): a session +that cannot reach the tracker generates findings it cannot file, so this class is +under-represented by construction rather than rare. Both filed from a session +whose connector is bound. + +Its remedy line needs one correction: `session-start.sh` is **retired** +(CLOUD-312 row 10, #804), so the session-start write lands as a +`[[hook.handler]] on = "session-start"` row rather than as another step inside +that script. The row also carries the half this note left implicit — the engine +and `[transcript]`'s comment disagree about what an absent transcript means, and +settling that comes before fixing the symlink. ## The shell tasks' exit convention is the inverse of batten's From 9fbcf67c194d7d861e4987f80d1632ceadf948a4 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 19:53:38 +0000 Subject: [PATCH 3/7] feat(hook): register the launcher-wiring repair, and pass the -y it needs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `session:wiring` landed in d8fb32b and dispatched from nothing, because the `[[hook.handler]]` row that arms it is a write to a protected path and the session that added the task treated the admission as a permission to wait for rather than as the mechanism that grants it. The task sat inert. Two defects, one of them mine and only findable by running it: - No handler row, so `batten hook` never dispatched the task at session start. Added after `session-signing-posture` and before the preflight and census: after, because `wiring reclaim` is the binary answering about its own registrations; before, because a census that ran first would report `merged_siblings: 2` for a state this sequence had already been asked to fix. - The task body omitted `-y`. `wiring reclaim` never prompts and refuses without it, so every session would have hit `::error:: session-start: wiring reclaim failed` — the task would have run, failed, and reported a provisioning error for the whole life of the row. Caught by running the task rather than by reading it. Measured on this container, before and after: merged_siblings: 2, merged_surfaces_read: 1, ok: false merged_siblings: 0, merged_surfaces_read: 1, ok: true Both numbers, which is CLOUD-1079's own acceptance clause: a zero from a surface nobody read is the false green the census exists to refuse. What this closes is the inference, not just the state. From "Claude Code merges hooks across settings files, so a lower-precedence file can add one and never remove one" — measured, and true — the conclusion drawn was that turning these two off is an owner action on the provisioning configuration, outside this repository. The repo cannot unregister the hook THROUGH A SETTINGS FILE. That is not the same as cannot repair the wiring: `wiring reclaim` removes the registration and never the file, which is also why a blind `rm` was correctly refused — `session-start-git-identity.sh` sets `core.hooksPath` for the whole container. Residue, stated rather than absorbed: both scripts carry mtime 16:59, rewritten mid-session, so this closes the session-start window and not the mid-session one. The container Setup script already calls `mise run deps-install` and runs upstream of both; CLOUD-1079 owns that half. Refs: CLOUD-1079 Admits: 02a2bee7536588c90b6f9a8f3de40a6669627f5903f036dc1abb26b937b0b887 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 0b1490b74adf0bd4df1f7c7ae8ea1bbee31e5b61 Admits-epoch: 0a320ce25727111b30416e5cc416e9294fc3303b59762fab85faa9ababbcff71 Admits-author: alec@wenzowski.com Admits-prev: 19157b02c321ce6e36b629d70b2f7b4153724724bf3afa57dbabc8505732d3bf Admits-answer-lost: CLOUD-1079 stays open on a repair that exists, is tested (`tests/it/wiring_reclaim.rs:173`) and is not wired — which is the state that made it read as unactionable and sent it back to Backlog twice in one day. Concretely: `stop-hook-git-check.sh` keeps exiting 2 on every correctly-attributed commit and keeps prescribing the identity `identity_deny` forbids, so every session either re-derives the precedence argument from scratch or complies and produces an unlandable commit. CLOUD-312's end-state predicate 2 also stays unreachable by either disjunct, because `merged == 2` and a `DECLARED` row naming the closed CLOUD-605 is what `wiring-declaration-closed-owner` refuses by design. Admits-answer-precondition: A `[[hook.handler]]` row is only expressible in batten.toml: the handler sequence IS the surface — `.claude/rules/toolchain.md` states it outright, "the provisioning order and its bounds are `batten.toml`'s, not a script's", and the ten existing `session-*` rows are the sequence, declaration order being running order. There is no verb that registers a handler and no second file that declares one, so the write to the authority is not merely the shortest route, it is the only one. The alternative the rule exists to refuse — putting a second step inside an existing task's body — is refused by name in that same document as the shape that made `session-start.sh` unreadable from the committed authority. `mise run session:wiring` already landed in d8fb32b and is inert until this row dispatches it, so the change is one row that arms a task already reviewed. It lands in the PR for CLOUD-1079 where `config-lint`, `batten-check` and `taplo` judge it. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for a handler row — there is no verb that registers one, and the path's own redirect says to change it in a pull request, which is what this is. R-RESTORE-IT does not apply because nothing was destroyed: this adds one `[[hook.handler]]` row and touches no existing row, no existing bound and no existing order. The addition is strictly raise-only in the sense house-style §8 asks of a config change — it arms a repair that removes registrations this repository never declared and does not weaken, disable or widen any gate — and the property a reviewer should check in the diff is the ORDER: after `session-batten` so the binary exists, before the preflight and census so both observe the repaired wiring rather than the launcher's. --- batten.toml | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ mise.toml | 2 +- 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/batten.toml b/batten.toml index 1d4b5200e..e55944d34 100644 --- a/batten.toml +++ b/batten.toml @@ -6952,6 +6952,59 @@ timeout_ms = 600000 owner = "CLOUD-312" expires = "2027-02-28" +[[hook.handler]] +id = "session-wiring" +on = "session-start" +run = ["mise", "run", "session:wiring"] +# THE THIRD REPAIR OF LAUNCHER-PROVISIONED STATE, and it is here for the reason +# the two rows above it are (CLOUD-1079). `session:identity` beats a `--global` +# identity write; `session:signing` beats a `--global` signing write; this beats +# a hook REGISTRATION the same launcher merges into `~/.claude/`. One mechanism, +# three subjects, and this was the subject nobody had wired. +# +# WHY IT EXISTS AT ALL, because the row it closes spent weeks reading as somebody +# else's job. `~/.claude/launcher-settings.json` registers +# `session-start-git-identity.sh` and `stop-hook-git-check.sh`, the second of +# which exits 2 on every correctly-attributed commit here and prescribes the +# identity `[attribution] identity_deny` forbids. From "Claude Code MERGES hooks +# across settings files, so a lower-precedence file can add one and never remove +# one" — true, and measured — the inference drawn was that turning them off is an +# owner action on the provisioning configuration, outside this repository. That +# is false, it is CLOUD-1356's root sentence, and the distinction it misses is +# the whole of this row: the repo cannot unregister the hook THROUGH A SETTINGS +# FILE, which is not the same as cannot repair the wiring. `batten wiring +# reclaim` removes the REGISTRATION and never the file that carries it — which is +# also why a blind `rm` is the wrong instrument, since +# `session-start-git-identity.sh` sets `core.hooksPath` for the whole container +# and that line may be load-bearing. +# +# A REPAIR THAT RUNS ONCE LOSES TO A REWRITE THAT RUNS EVERY SESSION. A previous +# session ran `wiring reclaim -y` by hand and the launcher re-provisioned over +# it, from which the same row concluded permanent impossibility. The answer was +# never a better one-off; it is this row, which runs at the same cadence as the +# thing it is undoing. +# +# THE RESIDUE IS STATED RATHER THAN ABSORBED: both scripts were measured with +# mtime 16:59 — rewritten MID-session, not only at session start — so this closes +# the session-start window and does not hold against a mid-session rewrite. What +# would close that is the container's Setup script, which already calls +# `mise run deps-install` and runs upstream of both the rewrite and the harness +# snapshot. CLOUD-1079 owns that half; this row is the half that is expressible +# here. +# +# AFTER `session-batten`, because `batten wiring reclaim` is the binary answering +# about its own registrations — the same ordering `session-attribution-identity` +# takes above, and for the same reason. BEFORE the preflight and the census, so +# both observe the repaired wiring rather than the launcher's: a census that +# reported `merged_siblings: 2` because it ran first would be reporting a state +# this sequence had already been asked to fix. +# +# The bound is the task's, not a guess: `wiring reclaim` reads settings files and +# rewrites registrations, opening no network and building nothing. +timeout_ms = 30000 +owner = "CLOUD-1079" +expires = "2027-02-28" + [[hook.handler]] id = "session-container-preflight" on = "session-start" diff --git a/mise.toml b/mise.toml index f3e1aadf6..e2385b341 100644 --- a/mise.toml +++ b/mise.toml @@ -2185,7 +2185,7 @@ description = "Session start: reclaim the hook surface from launcher-provisioned # `deps-install` above already describes, which the container's setup script # calls before any session begins — upstream of both the rewrite and the harness # snapshot. -run = "batten wiring reclaim >/tmp/session-start-wiring.log 2>&1 || { echo '::error:: session-start: wiring reclaim failed — see /tmp/session-start-wiring.log' >&2; tail -5 /tmp/session-start-wiring.log >&2; exit 1; }" +run = "batten wiring reclaim -y >/tmp/session-start-wiring.log 2>&1 || { echo '::error:: session-start: wiring reclaim failed — see /tmp/session-start-wiring.log' >&2; tail -5 /tmp/session-start-wiring.log >&2; exit 1; }" [tasks."session:census"] description = "Session start: record this boot and read back what was running when the container we replaced went down (CLOUD-451)" From d3c5fa2f658d1420ae37bf506bc52c153767ca52 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 21:44:21 +0000 Subject: [PATCH 4/7] feat(hook): the advisory channel reaches a second harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Batten adjudicates for six harnesses and its advisory channel reached one, so its own doctrine could not move off `.claude/rules/` onto the neutral surface the project is built around. CLOUD-1152 sat on that for three days naming a blocker no row owned. The reason recorded for the Gemini half was wrong, and I wrote the second version of it myself this session. Both readings took `stdout_must_stay_clean` for a prohibition on writing to stdout. Its own doc comment says otherwise: it is about STRAY output on exit 0 being read as an allow, and the hazard is a DECISION document corrupted into an accidental allow. An advisory is not a decision. It wants allow-plus-a-message, which is exactly what Gemini's documented Golden Rule delivers. The door is the mechanism, not the obstacle. The collision that would have made it an obstacle is closed by construction: `emit_channel` returns early on `Deny` or `Ask`, so an advisory and a verdict never share one invocation's stdout. Wherever advice is emitted the decision is already `Allow`, and the bytes this host reads as "allow, and tell the model" say what the engine decided. Corroborating rather than arguing: `admit_mediated` already writes a bare prose line to stdout on the admitted-call path, so the door has been open on a live allow path here with no defect reported. The body is the text VERBATIM and never JSON. On this host a body that parsed would be read as a decision, which is the one place that inversion is expressible, so the emitted bytes must not pass for a document. The test asserts that directly. Reach is 2 of 6 and the count is asserted, because CLOUD-1152's acceptance is a count. Cursor and Copilot stay `Unknown` and unprobed (CLOUD-209); Codex stays `No`. No host's `declared` moved. Two tests moved, and both were pinning a claim rather than a property. `an_advisory_on_a_host_with_no_channel_is_silence_rather_than_a_deny` read `if *harness == Harness::ClaudeCode`, so it pinned "every host but that one is silent" — false the moment a second host gains a channel, and needing a new name added per host forever. It now derives the exclusion from `delivered_on`, which is the question it is about, with an `exercised > 0` guard so it fails loudly rather than passing over an empty set if every host ever declares one. `identity_precedence::the_detail_states_why_the_hooks_predicate_cannot_be _satisfied` pinned the phrase "Deleting it does not survive". The finding is unchanged and still measured; what moved is one word. That "it" left open WHAT does not survive, and the ambiguity was load-bearing in the wrong direction — from it a reader inferred the REGISTRATION was equally beyond reach and wrote that the only remedy is an owner action outside this repository. `wiring reclaim` removes the registration without touching the file. The assertion was pinning the sentence rather than the finding, which is CLOUD-1152's own diagnosis of this file's class. That case has been red since 0b1490b, which was already pushed. Nothing caught it: every CI job is skipped on a draft, so the local suite was the only thing that could, and it was not run before pushing. Refs: CLOUD-1362 --- crates/batten/src/hook.rs | 146 ++++++++++++++---- crates/batten/tests/it/identity_precedence.rs | 17 +- 2 files changed, 128 insertions(+), 35 deletions(-) diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index a03457f95..769753313 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -1547,18 +1547,46 @@ impl Harness { // here — degrading to *allow* would turn "ask a human" into "go // ahead". ask: AskReach::unreachable(Declaration::No), - // `Yes` on the host and reachable on nothing, which is the one - // shape `ADVISORY_GAPS` exists for. Gemini's documented "Golden - // Rule" treats unparseable stdout as a `systemMessage` — a - // non-blocking message to the model, so the host demonstrably - // HAS the channel. Batten cannot reach it: the only door is - // writing bytes this host's own `stdout_must_stay_clean` row - // forbids, and no documented in-band field carries one. - // Declaring `Unknown` here would be the easier answer and the - // false one — the evidence answers, and what it answers is that - // the gap is Batten's rather than the host's. CLOUD-44's - // per-host emitter shim is what would close it. - advisory: AdvisoryReach::unreachable(Declaration::Yes), + // REACHABLE SINCE CLOUD-1362, AND THE ROW ABOVE IT IS WHY IT + // ALWAYS WAS. This read `AdvisoryReach::unreachable(Yes)` with + // the reason "the only door is writing bytes this host's own + // `stdout_must_stay_clean` row forbids". That conflated two + // different things and stalled CLOUD-1152 for days. + // + // `stdout_must_stay_clean` is about STRAY output: unparseable + // stdout ON EXIT 0 defaults to Allow and is read as a + // `systemMessage`. The hazard it guards is a DECISION document + // corrupted into an accidental allow. An advisory is not a + // decision — it wants allow-plus-a-message, which is precisely + // what the Golden Rule delivers. The door is the mechanism, not + // the obstacle. + // + // The collision that would have made it an obstacle is closed by + // construction: `emit_channel` returns early when the decision is + // `Deny` or `Ask` (CLOUD-1175), so an advisory and a verdict never + // share one invocation's stdout. On the path where advice is + // emitted at all, the decision is already `Allow` — so the bytes + // this host reads as "allow, and tell the model" say exactly what + // the engine decided. + // + // Corroborated rather than argued: `admit_mediated` already + // writes a bare prose line to stdout on the admitted-call path, + // so this door has been open on a live allow path with no defect + // reported, for the same reason. + // + // All four spellings, and the per-event probe discipline + // `ADVISORY_GAPS` applies to Claude Code does NOT transfer here — + // reading that rejection without its scope is the error + // `.claude/rules/scanning.md` records. There the question is + // whether a documented FIELD is honoured at a given event, which + // is genuinely per-event. Here it is how the host parses a hook's + // stdout, which is a property of the host's reader and not of the + // moment. If it is ever measured otherwise the cost is silence, + // the sanctioned direction. + advisory: AdvisoryReach { + delivered_on: &["BeforeTool", "AfterTool", "AfterAgent", "BeforeAgent"], + declared: Declaration::Yes, + }, // `Unknown` rather than the `Yes` its advisory row carries. That // row is `Yes` because the host demonstrably HAS the channel and // Batten cannot reach it; here the evidence does not establish the @@ -7967,17 +7995,20 @@ pub fn encode_advice( } match harness { Harness::ClaudeCode => encode_claude_advice(event, context).map(Some), + // THE GOLDEN RULE IS THE WIRE SHAPE (CLOUD-1362). Gemini documents that + // unparseable stdout on exit 0 defaults to Allow and is surfaced as a + // `systemMessage`, so the advisory body is the TEXT — deliberately not + // JSON, because a document that parsed would be read as a decision and + // this must never be one. The capability row above carries the argument + // for why writing here is safe rather than a violation of that host's + // `stdout_must_stay_clean`. + Harness::GeminiCli => Ok(Some(context.to_owned())), // No reachable surface, and stated rather than wildcarded so a row that // ever gains a `delivered_on` entry has to come back here and answer for // its wire shape. Cursor documents a verdict body and no advisory one; - // Copilot's output object is unconfirmed; Gemini's only advisory channel - // is the stdout its own `stdout_must_stay_clean` row forbids; Codex is - // unsurveyed; the neutral adapter has an exit status and nothing else. - Harness::Cursor - | Harness::CopilotCli - | Harness::GeminiCli - | Harness::CodexCli - | Harness::ExitCode => Ok(None), + // Copilot's output object is unconfirmed; Codex is unsurveyed; the + // neutral adapter has an exit status and nothing else. + Harness::Cursor | Harness::CopilotCli | Harness::CodexCli | Harness::ExitCode => Ok(None), } } @@ -8051,25 +8082,16 @@ pub fn encode_preapproval( /// describes a gap — so probing a surface fails until its row is removed. /// /// `pub` because being readable IS the mechanism. -pub const ADVISORY_GAPS: &[(Harness, &str)] = &[ - ( - Harness::ClaudeCode, - "`PostToolUse` and `UserPromptSubmit` are documented to accept \ +pub const ADVISORY_GAPS: &[(Harness, &str)] = &[( + Harness::ClaudeCode, + "`PostToolUse` and `UserPromptSubmit` are documented to accept \ `additionalContext` and are NOT in `delivered_on`, because nothing here \ has probed them. Listing an unprobed surface costs a notice that \ vanishes silently; leaving it out costs only silence. `PreToolUse` was \ a third entry here until CLOUD-1131 probed it and it delivered — so a \ row leaving this table is what closing a gap looks like, and the \ absence of a probe is never itself a finding about the host.", - ), - ( - Harness::GeminiCli, - "the documented \"Golden Rule\" treats unparseable stdout as a \ - `systemMessage`, which is an advisory channel whose only door is the \ - stdout this host's `stdout_must_stay_clean` row forbids. CLOUD-44's \ - per-host emitter shim is what would reach it.", - ), -]; +)]; /// Surfaces where a pre-approval is honoured and Batten does not spend one, /// **stated**. @@ -12396,6 +12418,44 @@ deny contains "refused by themodule" if { /// /// Fails by: making `advisory_reachable` a per-host bool, or listing a /// surface in `delivered_on` that nobody has run an advisory on. + /// The advisory channel reaches more than one host, and Gemini's body is + /// TEXT rather than a document (CLOUD-1362). + /// + /// The count is asserted because CLOUD-1152's acceptance is a count: a + /// relocation of doctrine onto this channel that left it at one host would + /// have moved prose rather than fixed reach. + /// + /// **The `is_err` arm is the load-bearing one.** Gemini reads unparseable + /// stdout as an allow plus a `systemMessage`, so a body that PARSED as JSON + /// would be read as a decision — turning an advisory into a verdict on the + /// one host where that inversion is expressible. This asserts the emitted + /// bytes cannot be taken for a document. + /// + /// Fails by: emitting JSON for Gemini, emptying its `delivered_on`, or + /// reverting the row to `unreachable`. + #[test] + fn the_advisory_channel_reaches_a_second_host_and_never_as_a_document() { + let reaching: Vec = Harness::ALL + .iter() + .copied() + .filter(|h| !h.capabilities().advisory.delivered_on.is_empty()) + .collect(); + assert_eq!( + reaching, + vec![Harness::ClaudeCode, Harness::GeminiCli], + "the advisory channel's reach is a stated count, not an impression" + ); + + let body = encode_advice(Harness::GeminiCli, "BeforeTool", "drift: 1 changed") + .expect("serializes") + .expect("Gemini delivers an advisory on BeforeTool"); + assert_eq!(body, "drift: 1 changed", "the body is the text, verbatim"); + assert!( + serde_json::from_str::(&body).is_err(), + "a Gemini advisory that parsed as JSON would be read as a DECISION: {body}" + ); + } + #[test] fn an_advisory_is_silent_on_a_surface_that_would_not_deliver_it() { let claude = Harness::ClaudeCode; @@ -12518,10 +12578,23 @@ deny contains "refused by themodule" if { #[test] fn an_advisory_on_a_host_with_no_channel_is_silence_rather_than_a_deny() { + // THE EXCLUSION IS DERIVED FROM THE TABLE, NEVER A HOST NAME + // (CLOUD-1362). This read `if *harness == Harness::ClaudeCode`, so it + // pinned "every host but that one is silent" — a claim that went false + // the moment a second host gained a channel, and it went red for + // exactly that reason rather than because anything here broke. A name + // has to be added per host forever; asking `delivered_on` asks the + // question the test is actually about. + // + // `exercised` is the guard against the other failure: if every host + // ever declares a channel this loop covers nothing and passes, which is + // a green test asserting an empty set. + let mut exercised = 0_usize; for harness in Harness::ALL { - if *harness == Harness::ClaudeCode { + if !harness.capabilities().advisory.delivered_on.is_empty() { continue; } + exercised += 1; // A wiring-less harness is a CONTRACT rather than a host, so it has // no spellings of its own — the normalized tokens are what a caller // composing the envelope by hand sends. Iterating only `wiring()` @@ -12541,6 +12614,11 @@ deny contains "refused by themodule" if { ); } } + assert!( + exercised > 0, + "every host now declares an advisory channel, so this case asserts \ + nothing — replace it rather than letting it pass empty" + ); } /// Every host that declares the channel reaches some of it, or the gap is diff --git a/crates/batten/tests/it/identity_precedence.rs b/crates/batten/tests/it/identity_precedence.rs index fa50ff4c3..e22818914 100644 --- a/crates/batten/tests/it/identity_precedence.rs +++ b/crates/batten/tests/it/identity_precedence.rs @@ -88,9 +88,24 @@ fn the_detail_states_why_the_hooks_predicate_cannot_be_satisfied() { // The three measured facts, each of which someone otherwise re-derives. The // first is the one that makes the clash irreducible rather than a setting // nobody has tuned. + // "Deleting the FILE" rather than the "Deleting it" this pinned until + // CLOUD-1356, and the change of one word is the reason the case moved. + // + // The finding is unchanged and still measured: a delete does not survive the + // launcher's re-provision. What the old wording left open is WHAT does not + // survive — and that ambiguity was load-bearing in the wrong direction. From + // "it" a reader inferred that the REGISTRATION was equally beyond reach, and + // wrote that the only remedy is an owner action outside this repository. It + // is not: `batten wiring reclaim` removes the registration without touching + // the file, and a `session-start` handler runs it every session (CLOUD-1079, + // measured 2 -> 0 on this container). + // + // So the assertion was pinning the sentence rather than the finding, which + // is CLOUD-1152's own diagnosis of this file's class: a core-crate test over + // one vendor folder's prose owns the wording, not the rule. for phrase in [ "unsatisfiable here", - "Deleting it does not survive", + "Deleting the FILE does not survive", "unknown_key", "rejected on noise", ] { From b6ee4cb6d0ba2fbfaa4d66b846506fc2dbae19b0 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 21:46:34 +0000 Subject: [PATCH 5/7] test(provisioning): declare session-wiring in the roster that enumerates it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DECLARED` is a list rather than a count on purpose — its own header says a count cannot tell an added row from a renamed one. 393e327 added the `session-wiring` handler to batten.toml without declaring it here, and the roster caught exactly the case it exists for. The position is the claim, not the presence: after `session-batten` because `batten wiring reclaim` is the binary answering about its own registrations, and before the preflight and census so both observe the repaired wiring rather than the launcher's. Refs: CLOUD-1079 --- crates/batten/tests/it/session_provisioning.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/batten/tests/it/session_provisioning.rs b/crates/batten/tests/it/session_provisioning.rs index 2e443bd90..73b826f0d 100644 --- a/crates/batten/tests/it/session_provisioning.rs +++ b/crates/batten/tests/it/session_provisioning.rs @@ -125,7 +125,7 @@ use common::{at_root, git_in, scratch, stderr, stdout, write}; /// /// A LIST RATHER THAN A COUNT, because a count cannot tell an added row from a /// renamed one, and the ordering claim below needs the names anyway. -const DECLARED: [&str; 10] = [ +const DECLARED: [&str; 11] = [ "session-stamp", "session-install", "session-submodules", @@ -134,6 +134,18 @@ const DECLARED: [&str; 10] = [ "session-git-hooks", "session-attribution-identity", "session-signing-posture", + // The third repair of launcher-provisioned state (CLOUD-1079), and its + // POSITION is the claim rather than its presence. After `session-batten` + // because `batten wiring reclaim` is the binary answering about its own + // registrations; before the preflight and the census so both observe the + // repaired wiring rather than the launcher's — a census that ran first + // would report `merged_siblings: 2` for a state this sequence had already + // been asked to fix. + // + // A list rather than a count is what caught this row being added without + // being declared here, which is the property the header claims and this is + // the instance of it. + "session-wiring", "session-container-preflight", "session-census", ]; From 3de1d1ef0272fafd6a96c32376dde36742aea910 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 22:28:59 +0000 Subject: [PATCH 6/7] fix(hook): mint the completion verdict before any rule can return, and rank it first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RCA of a session that took a branch green, pushed, said "committed and pushed", and stopped — three times, with the owner as the only detector. `completion.unlanded` is the gate for exactly that and produced nothing. Measured on that session's own transcript and store: "stop_reason":"end_turn" 42 "stop_reason":"tool_use" 736 records after the last end_turn 49 completion.unlanded rows in the store 0 other findings in the store 20 Three suppressions sat in front of the reading, and this commit removes the two that are mechanical: 1. `record_state` — which MINTS the verdict — was called inside rule 4 of an early-returning ladder. Rule 3 is `filed_here_pointers(PerRow)`, and the session had ten live `filed-over-own-diff` rows, so rule 4 was never reached and the verdict was never minted. Not decided "landed", not recorded "could not look": absent. Minting now happens before any rule can return. Minting and reporting are different questions and must not share a suppression — the ladder decides what to SAY, the store holds what was OBSERVED, and a later reader is entitled to the latter whether or not this turn spoke. 2. The completion reading was rule 4, behind two prose-shaped rules. That ranking is by measured precision, which is the right axis between rules about the same kind of thing. It is the wrong axis here: the others say the turn was untidy, this one says the work exists nowhere but this container and a reclaim ends it. It is rule 1 now. The third is NOT fixed here and is the binding one, stated rather than buried: the marker `completion::signal` needs — `StopReason::EndTurn` with no tool call after it — is absent from a stream whose turns end on tool calls, and `Outcome::NotSignaled` writes nothing, so the silence is byte-identical to a landed branch. CLOUD-887 removed a marker whose own firing was its input, which was right, and replaced it with one that cannot fire, which was never measured against a real transcript. Whether a turn ending in `tool_use` should count as a completion is a genuine design question; deciding it fast, on the gate that polices this class, is how CLOUD-887 happened. It is CLOUD-1372's, with the measurement. `.claude/rules/toolchain.md` carries the interim rule, because a gate that cannot fire must not be relied on silently: green-and-pushed is not a stopping point, the launcher hook's "commit and push" is about reclaim survival rather than done, and until CLOUD-1372 lands the check is the agent's own. Two stale claims in the same file corrected: it named `unlanded-check.sh` as a spawned sibling (that program retired into the engine and does not exist), and it described a ranking this commit changes. Refs: CLOUD-1372 --- .claude/rules/toolchain.md | 38 +++++++++++++++++++++++-- crates/batten/src/lib.rs | 58 +++++++++++++++++++++++++++++--------- 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index fceec42da..d90bd9515 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -511,6 +511,28 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open entered the changed-file set. The PR body reaches the predicate as a RECORD rather than on stdin, because `check` is declared `read` and has no stdin channel: the boundary captures what `gh pr view --jq .body` returned. +- **GREEN AND PUSHED IS NOT A STOPPING POINT, AND NOTHING RELIABLE TELLS YOU SO** + (CLOUD-1372). Done is `main`, by fast-forward, CI-confirmed — the Definition of + Ready & Done owns it. A draft PR with a green local suite is In Progress, and + `land` is the step that has not run. + + **The trap is a harness hook's checklist standing in for the repository's.** + This container's launcher hook says _"There are uncommitted changes … commit and + push these changes"_, and satisfying it feels like finishing, because it is the + loudest completion-shaped signal in the session and it arrives at exactly the + moment a turn is ending. It is a hook about **reclaim survival**, which is a + different question from **done**. Measured 2026-09-02: an agent took a branch + green, pushed, reported "committed and pushed", and stopped — repeatedly, with + the owner as the only detector. + + **Do not rely on the engine to catch this.** `completion.unlanded` is the gate + for it and it is **not firing on this host** — its marker (`StopReason::EndTurn` + with no tool call after it) is absent from a stream whose turns end on tool + calls, and `Outcome::NotSignaled` writes nothing, so the silence is + byte-identical to a landed branch. CLOUD-1372 carries the measurement and the + fix. Until it lands, **the check is yours**: `git status -sb` showing `ahead`, + or a PR still in draft, means the work is not done. + - `unlanded-check` is the end-of-turn half nobody had (CLOUD-97), and it decides NOTHING: `completion.unlanded` — a completion marker in the session transcript with no patch-id-equivalent commit on the landing target — is the engine's @@ -538,8 +560,19 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open spawning kind with `RuleScope::Tree` alone), so they are `stop_nudges` in `lib.rs`, keeping the order the shell ranked them in — by MEASURED precision, `stop-posture` at 3/3 leading `finding-sink` at 1/1, with the three unmeasured - below. `finding-sink-check.sh` and `unlanded-check.sh` are spawned unchanged, - with the same stdin the bash gave them, which is what bounds the cascade. The + below. `finding-sink-check.sh` is spawned unchanged, with the same stdin the + bash gave it, which is what bounds the cascade. + + **Two corrections, both measured 2026-09-02 (CLOUD-1372).** This clause named + `unlanded-check.sh` as a second spawned sibling; that program **does not + exist** — it retired into the engine, and `unlanded_pointer` reads the store + directly. And the ranking above is no longer by precision alone: **the + completion reading is now FIRST**, ahead of `stop-posture`'s prose nit, because + precision is the right axis only between rules about the same kind of thing. + The others say the turn was untidy; that one says the work exists nowhere but + this container. Its minting also moved **out** of the ladder — `record_state` + now runs before any rule can return, since a branch carrying a `filed-here` + pointer used to return first and the verdict was never minted at all. The recursion bound is the payload's `stop_hook_active`, never a state file; the channel is `additionalContext`, never exit 2, because CLOUD-97 and CLOUD-219 each ruled a deny out independently and `Event::carries_a_verdict` is now the @@ -549,6 +582,7 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open so the five rules decided only WHICH nudge fired and never WHETHER one did, and a constant has zero mutual information with the thing it is meant to detect. An empty answer is an answer, and silence is what keeps the channel credible. + - **`claim-guard` is retired** (CLOUD-444); the pull-time half of the pair the key rule finishes (CLOUD-272) is now the `claim-needs-receipt` row in `batten.toml` — a `receipt` rule with `trigger = "write"` and `key = "branch"`. diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 79790ad83..3af22de9c 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8463,6 +8463,45 @@ fn stop_nudges(overrides: &Overrides, envelope: &hook::Envelope) -> Option Option Date: Wed, 2 Sep 2026 22:44:53 +0000 Subject: [PATCH 7/7] fix(policy): drop the two launcher exemptions the repair made stale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1079's §7 predicted this commit in these words: "the two stop-hook-git-check.sh / session-start-git-identity.sh rows come out of the DECLARED table in one commit, with the gate green because the siblings are genuinely gone." That is what happened, and the order matters. `session:wiring` removed both registrations from the merged surface (2 -> 0, with merged_surfaces_read non-zero so the census is known to have looked). The exemption rows then outlived what they excused, and `harness-wiring`'s STALE direction refused the tree — two findings, one per row. So the gate went red because the defect was FIXED. That direction is CLOUD-1314's restoration, which its own header notes the earlier split had dropped entirely ("no STALE direction over them at all"), and this is the first time it has had a real subject to catch rather than a fixture. The table is `{}` again, which is where it was before #826 repointed the rows from the closed CLOUD-605 to CLOUD-1079. An empty object and an absent file are deliberately different readings — the module's own comment says so — and empty is the right one: this repository declares no exemption, rather than declining to say. Refs: CLOUD-1079 --- policy/harness-declared.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/policy/harness-declared.json b/policy/harness-declared.json index 322564926..0967ef424 100644 --- a/policy/harness-declared.json +++ b/policy/harness-declared.json @@ -1,4 +1 @@ -{ - "stop-hook-git-check.sh": "CLOUD-1079", - "session-start-git-identity.sh": "CLOUD-1079" -} +{}