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/.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/.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 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/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/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 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", ] { 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", ]; diff --git a/mise.toml b/mise.toml index 888709e03..e2385b341 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 -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)" # RECORD BEFORE READ, and the order is load-bearing: recording this boot after 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" -} +{}