From 99d16b222b2f709cd736cb99b672596b9d933fdc Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 19:30:58 +0000 Subject: [PATCH 01/32] fix(hook): deny a call this build cannot adjudicate, rather than exiting non-zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `load_policy` failed with `?`, which raises a `UsageError` — exit `1` — and `exit.rs` makes only `2` a denial precisely so no failure path can block a call. The harness therefore read a config this build could not load as a non-blocking hook error and ran the mediated tool anyway. Measured over one 5-day session: 1,149 calls proceeded unjudged through seven windows of a mid-edit `batten.toml`, and ~456 more through a preset the installed build did not ship — `policy.rs`'s unknown-preset arm raises exactly this error. The discrimination is CLOUD-1572's, one level up. Where the engine is guessing about the call — unreadable stdin, an undecodable payload, an event the host does not declare — allowing is right, because nothing is known. Here the engine has read its own authority and been told it cannot enforce it, so proceeding reports a clean allow over rules that never ran. It renders rather than propagates, because `render` owns the per-harness deny channel: Claude Code answers in its JSON decision object at exit `0`, where the document is the deny, and the neutral adapter answers `Violation`. A `Denial` raised here would send `2` to a host that reads the document instead. The bypass is honoured first, which is what keeps a container recoverable: a stale binary meeting a newer config denies every call until one of them moves. Refs: CLOUD-1688 --- crates/batten/src/lib.rs | 65 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index a9b933c08..0882496f9 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -12256,8 +12256,71 @@ fn run_hook( // arms, ~0.7 ms against a 100 ms budget. `!adjudicable` keeps its old // behaviour, because an event with nothing to adjudicate has no protected // gate to run either, and that is the arm the hot path actually rides. + // A CONFIG THIS BUILD CANNOT READ IS CERTAINTY, AND CERTAINTY DENIES + // (CLOUD-1688). `?` here propagated a `UsageError` — exit `1` — and + // `exit.rs` makes only `2` a denial precisely so no FAILURE path can block a + // call. So the harness read this whole class as a non-blocking hook error + // and ran the tool anyway. Measured over one 5-day session: 1,149 calls + // proceeded unjudged through seven windows of a mid-edit `batten.toml`, and + // ~456 more through a preset this build did not ship (`policy.rs`'s + // unknown-preset arm, which raises exactly this error). + // + // THE DISCRIMINATION IS THE SAME ONE `UNREADABLE_STDIN` SITS ON THE OTHER + // SIDE OF, and CLOUD-1572 drew it one level down. Where the engine is + // GUESSING about the call — stdin it could not read, a payload that would + // not decode, an event the host does not declare — allowing is right, + // because nothing is known and refusing would make Batten the reason a + // session cannot proceed. Here the engine has READ its own authority and + // been told it cannot enforce it: the rule set is named, and unavailable. + // Proceeding then is not caution — it is a gate reporting a clean allow over + // rules it never ran, which is the false green this engine exists to catch. + // + // A DECISION, NOT AN ERROR, which is why it RENDERS rather than propagates. + // `render` owns the per-harness deny channel, so Claude Code gets its JSON + // decision object at exit `0` — where the document is the deny — and the + // neutral adapter gets `Violation`. Raising a `Denial` here would send `2` + // to a host that reads the document instead, which is the one number that + // host does not consult. + // + // THE HATCH IS HONOURED FIRST, and that is what keeps a container + // recoverable rather than bricked. A stale binary meeting a newer config + // denies every call until one of them moves, so the operator's declared + // escape has to still work — the bootstrap window CLOUD-1688 flags as + // needing a decision is exactly this state, and this arm is the part of it + // that can be settled without one. let (policy, waivers) = if adjudicable { - load_policy(overrides, harness)? + match load_policy(overrides, harness) { + Ok(loaded) => loaded, + Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()), + Err(unreadable) => { + // Pointer-only (non-negotiable rule 4): the loader's own message + // names the key or path that would not load, never its contents. + let refusal = Refusal::new( + "engine-cannot-adjudicate", + format!( + "this build could not load the rules it is registered to enforce, so \ + nothing judged this call: {unreadable}" + ), + // No remedy the ENGINE may declare: the repair is rebuilding + // or reinstalling the binary, or fixing the config, and both + // are the consumer's own commands (non-negotiable rule 1). + Fix::None, + ); + let rendering = Rendering { + hatch: hook::BYPASS_ENV, + ceiling: None, + }; + return render( + harness, + &envelope, + hook::Decision::Deny(refusal), + &rendering, + mode, + out, + err, + ); + } + } } else { (hook::Policy::declaring_nothing(harness), Vec::new()) }; From edfef8a261c14b507b36db92ad87c3394bd1f633 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 19:36:59 +0000 Subject: [PATCH 02/32] test(hook): show the fail-open arm can fail, and that its fix is not an outage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four cases over the compiled binary, because the defect is not in `adjudicate` — which is pure and whose unit cases passed throughout — but in what the boundary does with a load that failed. `mediated_admission.rs` records the same lesson from the other side. The pairing is the point. Under the declared mutation `unloadable-config-allows`, which restores the old fall-through, the two deny cases redden and the two allow cases stay green: FAIL a_config_this_build_cannot_load_denies_rather_than_failing_open FAIL on_claude_code_the_refusal_is_the_document_rather_than_the_number PASS a_loadable_config_still_allows_an_ordinary_call PASS the_declared_hatch_still_reaches_a_clone_whose_config_will_not_load Proved by hand rather than left to the nightly. The mirror is what stops the change being satisfied by an adjudicator that denies every call in the fleet, which is an outage wearing a fix's clothes; the hatch case is what keeps a container recoverable when a stale binary meets a newer config. The fixture is a `batten.toml` mid-edit, which is the largest measured bucket: seven windows across one 5-day session, 1,149 calls, none of them judged. Refs: CLOUD-1688 --- crates/batten/src/lib.rs | 2 + crates/batten/tests/it/adjudicate_absent.rs | 149 ++++++++++++++++++++ crates/batten/tests/it/main.rs | 1 + 3 files changed, 152 insertions(+) create mode 100644 crates/batten/tests/it/adjudicate_absent.rs diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 0882496f9..3c356b0c8 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -12288,6 +12288,8 @@ fn run_hook( // escape has to still work — the bootstrap window CLOUD-1688 flags as // needing a decision is exactly this state, and this arm is the part of it // that can be settled without one. + //MUTANT-SUITE crates/batten/tests/it/adjudicate_absent.rs + //MUTANT unloadable-config-allows|s@ Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()),@ Err(_) => (hook::Policy::declaring_nothing(harness), Vec::new()),@|a_config_this_build_cannot_load_denies_rather_than_failing_open let (policy, waivers) = if adjudicable { match load_policy(overrides, harness) { Ok(loaded) => loaded, diff --git a/crates/batten/tests/it/adjudicate_absent.rs b/crates/batten/tests/it/adjudicate_absent.rs new file mode 100644 index 000000000..232ebca04 --- /dev/null +++ b/crates/batten/tests/it/adjudicate_absent.rs @@ -0,0 +1,149 @@ +//! A call this build cannot adjudicate is DENIED, never allowed by a failure. +//! +//! The tier that proves the engine does not fail open when it has read its own +//! authority and been told it cannot enforce it. Unit cases over `adjudicate` +//! cannot host this: the defect is not in the decision, it is in what the +//! BOUNDARY does with a load that failed, and only the compiled binary answers +//! that — `mediated_admission.rs`'s header records the same lesson, where unit +//! cases passed while the binary allowed the write. +//! +//! # The mirror is not decoration +//! +//! `a_loadable_config_still_allows_an_ordinary_call` is what stops this being +//! satisfied by an adjudicator that denies everything. A fail-closed hook that +//! refuses each call is not a fix, it is an outage — CLOUD-1688's falsifier says +//! so in as many words, and that is why the pair lands together. +//! +//! # Scope +//! +//! The CONFIG half of CLOUD-1688: a `batten.toml` this build cannot load. The +//! VERB half — a registration spelling a subcommand the installed binary does +//! not have — needs `doctor` to interrogate the installed artifact rather than +//! itself, because a self-check runs in the build that mise resolves and never +//! in the one the hook does. It lands with that part and belongs in this file. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; + +use common::{Fixture, run_with_stdin}; + +/// A `batten.toml` mid-edit, which is the largest measured bucket: seven windows +/// across one 5-day session, 1,149 calls, every one of them unjudged. +const WILL_NOT_PARSE: &str = "version = 1\nthis is not toml\n"; + +/// A config that loads and declares nothing this call matches. +const LOADS: &str = "version = 1\n"; + +/// A fixture carrying `body` as its committed authority. +fn fixture(name: &str, body: &str) -> PathBuf { + Fixture::new(name) + .config(body) + .file("notes.md", "ordinary\n") + .git() + .base_commit() + .build() +} + +/// A Claude Code `PreToolUse` envelope carrying a command, so the boundary +/// treats it as adjudicable and reaches the config load at all. +fn payload() -> String { + "{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + \"tool_input\":{\"command\":\"echo hello\"}}" + .to_owned() +} + +/// Adjudicate on the neutral adapter, where the VERDICT IS THE NUMBER. +/// +/// `exit-code` rather than `claude-code` for the two cases that ask what the +/// code is: on Claude Code the deny is the JSON document at exit `0`, so a case +/// asserting a number there would assert the wrong channel. The document is +/// checked separately below. +fn code(dir: &Path) -> Option { + run_with_stdin(dir, &["adjudicate", "--harness", "exit-code"], &payload()) + .status + .code() +} + +#[test] +fn a_config_this_build_cannot_load_denies_rather_than_failing_open() { + // The defect: this was `2` only if the load succeeded. A `?` on the load + // raised a `UsageError` — exit `1` — and a harness reads `1` as a + // non-blocking hook error, so the mediated tool ran with nothing judging it. + let dir = fixture("adjudicate-unloadable", WILL_NOT_PARSE); + assert_eq!( + code(&dir), + Some(2), + "a call nothing could judge must be refused, not allowed by the failure" + ); +} + +#[test] +fn a_loadable_config_still_allows_an_ordinary_call() { + // THE MIRROR. Without it the case above is satisfied by an adjudicator that + // denies every call in the fleet, which is an outage wearing a fix's clothes. + let dir = fixture("adjudicate-loadable", LOADS); + assert_eq!( + code(&dir), + Some(0), + "a config that loads and refuses nothing must still allow" + ); +} + +#[test] +fn the_declared_hatch_still_reaches_a_clone_whose_config_will_not_load() { + // What keeps a container recoverable rather than bricked. A stale binary + // meeting a newer config refuses every call until one of them moves, so the + // operator's declared escape has to survive exactly the state that needs it. + // + // `common::batten()` scrubs every bypass variable by construction, so setting + // one here is the only way it is present — a case that inherited it from the + // developer's shell would pass without testing anything. + use std::io::Write as _; + use std::process::Stdio; + + let dir = fixture("adjudicate-hatch", WILL_NOT_PARSE); + let mut child = common::batten() + .args(["adjudicate", "--harness", "exit-code"]) + .current_dir(&dir) + .env("BATTEN_HOOK_BYPASS", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("the binary runs"); + child + .stdin + .as_mut() + .expect("stdin is piped") + .write_all(payload().as_bytes()) + .expect("the payload is writable"); + let output = child.wait_with_output().expect("the binary answers"); + assert_eq!( + output.status.code(), + Some(0), + "the declared hatch must still pass a call the engine cannot judge" + ); +} + +#[test] +fn on_claude_code_the_refusal_is_the_document_rather_than_the_number() { + // THE CHANNEL IS PER-HARNESS AND THE NUMBER IS NOT (`run_hook`'s own + // contract). This host reads the JSON decision object and ignores the code, + // so a deny raised as `2` here would be a refusal nobody receives — which is + // the reason this arm renders rather than raising a `Denial`. + let dir = fixture("adjudicate-document", WILL_NOT_PARSE); + let output = run_with_stdin( + dir.as_path(), + &["adjudicate", "--harness", "claude-code"], + &payload(), + ); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!( + rendered.contains("deny"), + "the decision object must carry the deny: {rendered}" + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 70c2072bb..07e8d7501 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -42,6 +42,7 @@ mod acquisition_metric; mod acquisition_sweep; mod address_resolve; mod address_transport; +mod adjudicate_absent; mod admission; mod admission_narrowing; mod advisory_drain; From ef31a573f0ebbaa01cadc541079ceaec0e6d379e Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 20:20:57 +0000 Subject: [PATCH 03/32] fix(hook): extract the refusal, and move four asserted codes off the fail-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_hook` went to 126 lines against a 100 budget, so the deny arm becomes `deny_unadjudicable` rather than gaining an `#[allow]` — a boundary this load-bearing reads better on its own than as a match arm nine levels in. The four `call_arguments` cases are the substantive half, and the change is deliberate rather than green-making. Each asserted that a malformed config on the ADJUDICATE path answers `1`: a bound of zero is a usage error, not a very strict policy a named key with no projection is a usage error a row that can never fire is a usage error, not a silently inert gate a projection on a branch-keyed row is a usage error, not an ignored column Every one of those classifications is still true and none is edited. What changed is that `1` is the code a harness reads as a non-blocking hook error, so on the mediated boundary each of these let the call through unjudged — 1,149 calls did exactly that over one measured session. The surfaces stay separate rather than one principle beating the other: `doctor` still never answers `2` (`a_failing_diagnosis_is_never_a_policy_verdict`), and the CLI verbs still raise a usage error over a config they cannot read. `adjudicate` is the one surface where "cannot judge" must not resolve to "proceed", because there the alternative is a tool call nobody looked at. The diagnostics ride through unchanged, which the neighbouring assertion that stderr still names `max_age = 0` is what proves. Refs: CLOUD-1688 --- crates/batten/src/lib.rs | 83 ++++++++++++++++-------- crates/batten/tests/it/call_arguments.rs | 35 ++++++++-- 2 files changed, 86 insertions(+), 32 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 3c356b0c8..320c4fa11 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -12295,32 +12295,7 @@ fn run_hook( Ok(loaded) => loaded, Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()), Err(unreadable) => { - // Pointer-only (non-negotiable rule 4): the loader's own message - // names the key or path that would not load, never its contents. - let refusal = Refusal::new( - "engine-cannot-adjudicate", - format!( - "this build could not load the rules it is registered to enforce, so \ - nothing judged this call: {unreadable}" - ), - // No remedy the ENGINE may declare: the repair is rebuilding - // or reinstalling the binary, or fixing the config, and both - // are the consumer's own commands (non-negotiable rule 1). - Fix::None, - ); - let rendering = Rendering { - hatch: hook::BYPASS_ENV, - ceiling: None, - }; - return render( - harness, - &envelope, - hook::Decision::Deny(refusal), - &rendering, - mode, - out, - err, - ); + return deny_unadjudicable(harness, &envelope, &unreadable, mode, out, err); } } } else { @@ -12564,6 +12539,62 @@ fn run_hook( render(harness, &envelope, decision, &rendering, mode, out, err) } +/// Refuse a call whose rules this build could not load (CLOUD-1688). +/// +/// Lifted out of [`run_hook`] rather than left inline because that function is +/// already at its line budget, and a boundary this load-bearing should be +/// readable on its own rather than as a match arm nine levels in. +/// +/// **A DECISION, NOT AN ERROR, WHICH IS WHY IT RENDERS.** [`render`] owns the +/// per-harness deny channel: Claude Code answers in its JSON decision object at +/// exit `0`, where the document *is* the deny, and the neutral adapter answers +/// [`ExitCode::Violation`]. Raising a [`Denial`] here would send `2` to the one +/// host that reads the document instead of the number. +/// +/// **The rendering carries no ceiling and the general hatch**, because both live +/// on the policy that would not load. `None` reads downstream as "no declared +/// bound" rather than as a bound of zero, which is the direction that keeps a +/// refusal about an unreadable config from being truncated by a value nobody +/// could read. +fn deny_unadjudicable( + harness: hook::Harness, + envelope: &hook::Envelope, + unreadable: &dyn std::fmt::Display, + mode: Mode, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + // Pointer-only (non-negotiable rule 4): the loader's own message names the + // key or the path that would not load, never the file's contents. The + // existing `max_age = 0` and unknown-key diagnostics ride through here + // unchanged, which is what keeps the operator's repair as findable as it was + // when this arm exited `1`. + let refusal = Refusal::new( + "engine-cannot-adjudicate", + format!( + "this build could not load the rules it is registered to enforce, so nothing judged \ + this call: {unreadable}" + ), + // No remedy the ENGINE may declare: the repair is rebuilding or + // reinstalling the binary, or fixing the config, and each is the + // consumer's own command (non-negotiable rule 1). + Fix::None, + ); + let rendering = Rendering { + hatch: hook::BYPASS_ENV, + ceiling: None, + }; + render( + harness, + envelope, + hook::Decision::Deny(refusal), + &rendering, + mode, + out, + err, + ) +} + /// Run the refusing row's declared repair, and say what the boundary decides now. /// /// **Every other decision passes straight through**, and most `Deny`s do too: diff --git a/crates/batten/tests/it/call_arguments.rs b/crates/batten/tests/it/call_arguments.rs index 693fbae26..6a1076ed7 100644 --- a/crates/batten/tests/it/call_arguments.rs +++ b/crates/batten/tests/it/call_arguments.rs @@ -257,7 +257,10 @@ reason = "unreachable" .build(); assert_eq!( verdict(&contradictory, "mcp__Linear__save_issue", r"{}"), - Some(1), + // `2` rather than `1` for CLOUD-1688's reason, stated in full at the + // `max_age = 0` case: the classification below is unchanged, but on the + // mediated boundary `1` is non-blocking and let the call through. + Some(2), "a row that can never fire is a usage error, not a silently inert gate" ); @@ -429,8 +432,11 @@ reason = "unreachable" "mcp__Linear__save_issue", r#"{"id":"CLOUD-1"}"# ), - Some(1), - "a named key with no projection is a usage error" + // `2` rather than `1` for CLOUD-1688's reason, stated in full at the + // `max_age = 0` case below: the classification is unchanged, but on the + // mediated boundary `1` is non-blocking and let the call through. + Some(2), + "a call whose rules would not load must be refused, not allowed by the failure" ); let wrong_key = Fixture::new("args-from-wrong-key") @@ -454,7 +460,10 @@ reason = "unreachable" .build(); assert_eq!( verdict(&wrong_key, "mcp__Linear__save_issue", r#"{"id":"CLOUD-1"}"#), - Some(1), + // `2` rather than `1` for CLOUD-1688's reason, stated in full at the + // `max_age = 0` case: the classification below is unchanged, but on the + // mediated boundary `1` is non-blocking and let the call through. + Some(2), "a projection on a branch-keyed row is a usage error, not an ignored column" ); } @@ -628,10 +637,24 @@ reason = "unreachable" .git() .base_commit() .build(); + // WAS `1`, AND THE CHANGE IS THE POINT (CLOUD-1688). The classification this + // asserted is still true — a bound of zero is a misconfiguration, never a + // very strict policy — but `1` is the code the harness reads as a + // NON-BLOCKING hook error, so on the mediated path it let the call through + // unjudged. Measured over one 5-day session, 1,149 calls proceeded exactly + // this way. + // + // The surfaces stay separate rather than one winning: `doctor` still never + // answers `2` (`a_failing_diagnosis_is_never_a_policy_verdict`), and the CLI + // verbs still raise a usage error over a config they cannot read. This is the + // one surface where "cannot judge" must not resolve to "proceed", because + // here the alternative is a tool call nobody looked at. + // + // The diagnostic is unchanged, which the next assertion is what proves. assert_eq!( verdict(&zero, "mcp__Linear__save_issue", r#"{"id":"CLOUD-1"}"#), - Some(1), - "a bound of zero is a usage error, not a very strict policy" + Some(2), + "a call whose rules would not load must be refused, not allowed by the failure" ); let refusal = run_with_stdin( &zero, From d73bec68515514bda8fb3540780162297b375284 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 20:37:31 +0000 Subject: [PATCH 04/32] refactor(hook): give the adjudicable predicate a name, and the fifth code its reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_hook` sat at exactly its 100-line budget, so the deny arm put it over. Extracting `is_adjudicable` buys the room, and the predicate reads better named than as a five-clause disjunction mid-function: every clause was added by a separate measured defect — a dead `Stop` gate whose own suite stayed green (CLOUD-1051), a `SessionStart` mint that could not see its manifests (CLOUD-856) — and the doc keeps that history where the next reader meets it. `call_ceiling`'s partial-ceiling case is the fifth of the same class as the four in `call_arguments`: a config fault on the mediated path asserted as `1`. Its comment cited `rules/rust.md`'s rule that no Batten failure may read as a deny, and that rule still holds where it was written — `doctor` and the CLI verbs. The mediated boundary is the exception, because there `1` is non-blocking and the call it could not judge simply ran. The `measures` diagnostic it pins is unchanged. Refs: CLOUD-1688 --- crates/batten/src/lib.rs | 109 +++++++++++++++---------- crates/batten/tests/it/call_ceiling.rs | 12 ++- 2 files changed, 73 insertions(+), 48 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 320c4fa11..264b915d9 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -12226,18 +12226,7 @@ fn run_hook( // end-of-turn surface. The retired shell hook this replaces paid ~330-440ms // at the same boundary; `perf`'s `passthrough` and `noop` arms are pre-tool // shapes and are untouched by this clause. - let adjudicable = !envelope.command.is_empty() - || envelope.writes.is_some() - || envelope.event == hook::Event::Stop - // A FOURTH TIME, and for a mint rather than a verdict (CLOUD-856). Session - // start carries no command, no write and no tool name, so this predicate - // was false there and config was never loaded — which means the receipt - // this event exists to mint could not know which manifests were declared. - // The cost is one config load per SESSION, not per call, which is the - // same trade the `Stop` clause above makes, and it buys the whole reason - // `Fact::Document` can stay `None` on the mediated path. - || envelope.event == hook::Event::SessionStart - || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()); + let adjudicable = is_adjudicable(&envelope); // A BYPASSED CALL NOW PAYS THE CONFIG READ, and that invariant is retired // deliberately rather than eroded. // @@ -12256,38 +12245,9 @@ fn run_hook( // arms, ~0.7 ms against a 100 ms budget. `!adjudicable` keeps its old // behaviour, because an event with nothing to adjudicate has no protected // gate to run either, and that is the arm the hot path actually rides. - // A CONFIG THIS BUILD CANNOT READ IS CERTAINTY, AND CERTAINTY DENIES - // (CLOUD-1688). `?` here propagated a `UsageError` — exit `1` — and - // `exit.rs` makes only `2` a denial precisely so no FAILURE path can block a - // call. So the harness read this whole class as a non-blocking hook error - // and ran the tool anyway. Measured over one 5-day session: 1,149 calls - // proceeded unjudged through seven windows of a mid-edit `batten.toml`, and - // ~456 more through a preset this build did not ship (`policy.rs`'s - // unknown-preset arm, which raises exactly this error). - // - // THE DISCRIMINATION IS THE SAME ONE `UNREADABLE_STDIN` SITS ON THE OTHER - // SIDE OF, and CLOUD-1572 drew it one level down. Where the engine is - // GUESSING about the call — stdin it could not read, a payload that would - // not decode, an event the host does not declare — allowing is right, - // because nothing is known and refusing would make Batten the reason a - // session cannot proceed. Here the engine has READ its own authority and - // been told it cannot enforce it: the rule set is named, and unavailable. - // Proceeding then is not caution — it is a gate reporting a clean allow over - // rules it never ran, which is the false green this engine exists to catch. - // - // A DECISION, NOT AN ERROR, which is why it RENDERS rather than propagates. - // `render` owns the per-harness deny channel, so Claude Code gets its JSON - // decision object at exit `0` — where the document is the deny — and the - // neutral adapter gets `Violation`. Raising a `Denial` here would send `2` - // to a host that reads the document instead, which is the one number that - // host does not consult. - // - // THE HATCH IS HONOURED FIRST, and that is what keeps a container - // recoverable rather than bricked. A stale binary meeting a newer config - // denies every call until one of them moves, so the operator's declared - // escape has to still work — the bootstrap window CLOUD-1688 flags as - // needing a decision is exactly this state, and this arm is the part of it - // that can be settled without one. + // A config this build cannot read is CERTAINTY, and certainty denies rather + // than exiting non-zero — `deny_unadjudicable` carries the whole argument, + // including why the hatch is read first. //MUTANT-SUITE crates/batten/tests/it/adjudicate_absent.rs //MUTANT unloadable-config-allows|s@ Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()),@ Err(_) => (hook::Policy::declaring_nothing(harness), Vec::new()),@|a_config_this_build_cannot_load_denies_rather_than_failing_open let (policy, waivers) = if adjudicable { @@ -12539,12 +12499,73 @@ fn run_hook( render(harness, &envelope, decision, &rendering, mode, out, err) } +/// Whether this envelope has anything for the config to decide about. +/// +/// **The gate on whether a call pays a config read at all**, which is why the +/// hot path stays cheap: `perf`'s `passthrough` arm — a `Read` with a +/// `file_path`, no command, no write — takes the `false` branch, and its +/// below-`noop` reading comes from doing so. +/// +/// Every clause was added by a measurement rather than by symmetry, and the +/// history is the argument for keeping them enumerated here: +/// +/// * a command or a write is the original shape; +/// * `Stop` carries neither, so a `mediated_call` module registered for the end +/// of turn could not run at all — a dead gate whose own suite stayed green, +/// because a `with input as` case fabricates the shape the boundary never +/// built (CLOUD-1051); +/// * `SessionStart` likewise, and for a MINT rather than a verdict (CLOUD-856): +/// the receipt that event exists to write could not know which manifests were +/// declared. One config load per session, not per call; +/// * a `PreTool` call naming a tool is the shape a tool-keyed row exists to +/// judge, and without it such a row was loaded for no call that could match. +fn is_adjudicable(envelope: &hook::Envelope) -> bool { + !envelope.command.is_empty() + || envelope.writes.is_some() + || envelope.event == hook::Event::Stop + || envelope.event == hook::Event::SessionStart + || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()) +} + /// Refuse a call whose rules this build could not load (CLOUD-1688). /// /// Lifted out of [`run_hook`] rather than left inline because that function is /// already at its line budget, and a boundary this load-bearing should be /// readable on its own rather than as a match arm nine levels in. /// +/// # Certainty denies; guessing allows +/// +/// The load used to propagate with `?`, raising a [`UsageError`] — exit `1` — +/// and [`crate::exit`] makes only `2` a denial precisely so no FAILURE path can +/// block a call. So a harness read this whole class as a non-blocking hook error +/// and ran the mediated tool anyway. Measured over one 5-day session: 1,149 +/// calls proceeded unjudged through seven windows of a mid-edit `batten.toml`, +/// and ~456 more through a preset the installed build did not ship — the +/// unknown-preset arm in [`crate::policy`] raises exactly this error. +/// +/// This is the discrimination `UNREADABLE_STDIN` sits on the other side of, and +/// the one CLOUD-1572 drew one level down. Where the engine is GUESSING about +/// the call — stdin it could not read, a payload that would not decode, an event +/// the host does not declare — allowing is right, because nothing is known and +/// refusing would make Batten the reason a session cannot proceed. Here the +/// engine has READ its own authority and been told it cannot enforce it: the +/// rule set is named, and unavailable. Proceeding is not caution then, it is a +/// gate reporting a clean allow over rules it never ran. +/// +/// # The surfaces stay separate +/// +/// `doctor` still never answers `2` — a diagnosis is not a policy verdict — and +/// the CLI verbs still raise a usage error over a config they cannot read. The +/// mediated boundary is the one place where "cannot judge" must not resolve to +/// "proceed", because here the alternative is a tool call nobody looked at. +/// +/// # The hatch is read before this is reached +/// +/// [`run_hook`] takes the bypass arm first, and that is what keeps a container +/// recoverable rather than bricked: a stale binary meeting a newer config +/// refuses every call until one of them moves, so the operator's declared escape +/// has to survive exactly the state that needs it. +/// /// **A DECISION, NOT AN ERROR, WHICH IS WHY IT RENDERS.** [`render`] owns the /// per-harness deny channel: Claude Code answers in its JSON decision object at /// exit `0`, where the document *is* the deny, and the neutral adapter answers diff --git a/crates/batten/tests/it/call_ceiling.rs b/crates/batten/tests/it/call_ceiling.rs index 2e53fb73f..2042b3a5f 100644 --- a/crates/batten/tests/it/call_ceiling.rs +++ b/crates/batten/tests/it/call_ceiling.rs @@ -169,12 +169,16 @@ reason = "..." &["adjudicate", "--harness", "exit-code"], &payload(&prompt_of(10)), ); - // Exit 1 is the usage code: a config fault, never a policy verdict, so no - // Batten failure can read as a deny (`rules/rust.md`). + // A CONFIG FAULT IS STILL NOT A POLICY VERDICT — and on THIS surface it is + // still a refusal (CLOUD-1688). `rules/rust.md`'s rule that no Batten failure + // may read as a deny is what keeps `doctor` and the CLI verbs on `1`; the + // mediated boundary is the exception, because there `1` is non-blocking and + // the call it could not judge simply ran. The classification below is + // unchanged and so is the `measures` diagnostic the next assertion pins. assert_eq!( output.status.code(), - Some(1), - "a partial ceiling is a config fault: {}", + Some(2), + "a partial ceiling is a config fault, and one this call cannot be judged under: {}", stderr(&output) ); assert!( From 54aa93d22da608d1afc56a9543bb696dee212331 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 22:56:15 +0000 Subject: [PATCH 05/32] fix(hook): refuse only a declaration nothing could read, and say why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass refused on every `load_policy` failure. That conflates three faults the tree already separates, and only one of them is a refusal. Gates are registered fail-open, so a gate that fails open is INERT — it neither allows nor denies, it is absent. A config fault is therefore never a choice between refusing and allowing: it is a choice between keeping the enforcement surface we still have and losing it entirely. An unknown key costs its own row, a table whose validator refuses names that table, a version this build is too old for still says so — each leaves every other row readable and enforceable, and leaves an agent that can be told to repair the broken one. Refusing there trades a working partial gate for nothing. A file that is not TOML has no partial function to preserve: zero rows are readable, nothing is enforced, and the refusal is the only signal left. That asymmetry is the whole scope of the change. `Native::ConfigUnreadable` carries it. The class is a DISCRIMINATOR rather than a label, and it is identified positively: keying on "carries no declared class" would also have caught the unsupported-version and `min_batten_version` refusals, which leave the file readable, and would widen what denies with every future unclassed error. The syntax probe runs on the error path only. `toml::de::Error` is one type for two unlike faults and renders both as "TOML parse error at line N" — measured on the `[[fact]]`-with-no-`returns` fixture, a schema fault the message alone classed as unreadable. A `Table` parse answers it exactly, and costs nothing until a parse has already failed, which is the probe `parse_ungated` records as removed for costing one on the hot path. Under `unloadable-config-allows` the two deny cases redden and the mirror, the hatch and the rule-4 case stay green. 798 tests pass across the six suites this touches; nine assertions from the first pass are reverted to their originals. Refs: CLOUD-1677 --- crates/batten/src/config.rs | 38 ++++- crates/batten/src/lib.rs | 131 +++++++++++++++--- crates/batten/src/lint.rs | 2 +- crates/batten/src/verdict.rs | 44 ++++++ crates/batten/tests/it/adjudicate_absent.rs | 44 +++++- crates/batten/tests/it/call_arguments.rs | 35 +---- crates/batten/tests/it/call_ceiling.rs | 12 +- crates/batten/tests/it/cli.rs | 56 ++++++-- .../tests/it/config_forward_compatible.rs | 27 +++- 9 files changed, 311 insertions(+), 78 deletions(-) diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 73084dd12..16aa5bb8a 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -1386,7 +1386,8 @@ pub const RETIRED_KEYS: &[(&str, &str)] = &[( /// /// [`trust::load_base`]: crate::trust::load_base pub fn parse_base(text: &str, source: &str) -> Result { - let mut table: toml::Table = toml::from_str(text).map_err(|err| config_error(source, &err))?; + let mut table: toml::Table = + toml::from_str(text).map_err(|err| config_error(source, text, &err))?; // Nothing is reported when a key is dropped: the report this feeds is a // comparison of two policies, and "the base declared a key this build no // longer has" is a fact about the build rather than about either policy. @@ -1518,7 +1519,8 @@ pub fn parse_override(text: &str, source: &str) -> Result { prune_unresolvable::(text, binary_is_behind_the_config(source, text)); let config = match pruned.config { Some(config) => config, - None => toml::from_str(&pruned.text).map_err(|err| config_error(source, &err))?, + None => toml::from_str(&pruned.text) + .map_err(|err| config_error(source, &pruned.text, &err))?, }; (config, pruned.dropped) }; @@ -1967,8 +1969,35 @@ fn names_an_unknown_key(rendered: &str) -> bool { //MUTANT-SUITE crates/batten/tests/it/config_skew.rs //MUTANT skew-reads-as-malformed|s@ if !names_an_unknown_key(&rendered) {@ if true {@|an_unknown_key_names_the_rebuild //MUTANT every-parse-error-blames-skew|s@ if !names_an_unknown_key(&rendered) {@ if false {@|a_malformed_config_does_not_mention_a_rebuild -pub(crate) fn config_error(source: &str, err: &toml::de::Error) -> anyhow::Error { +pub(crate) fn config_error(source: &str, text: &str, err: &toml::de::Error) -> anyhow::Error { let rendered = err.to_string(); + // THE SYNTAX PROBE, AND IT RUNS ONLY HERE — ON THE ERROR PATH (CLOUD-1677). + // + // `toml::de::Error` is the one type for two very different faults, and the + // rendering hides it: a missing field and an invalid type both arrive as + // "TOML parse error at line N, column C", exactly like a stray brace. Reading + // the message cannot tell them apart, and `names_an_unknown_key` answers a + // third question again — measured on the `[[fact]]`-with-no-`returns` fixture, + // which is a SCHEMA fault that renders as a parse error and was classed as + // unreadable by the message alone. + // + // A `Table` parse answers it exactly: if the bytes are well-formed TOML then + // whatever failed was the SCHEMA over them, and the file still has rows a + // build can read. This is the probe the comment in `parse_ungated` records as + // removed for costing a parse on the hot path — it is free here, because + // nothing reaches this function until a parse has already failed. + if toml::from_str::(text).is_err() { + // CLASSED, AND THE CLASS IS A DISCRIMINATOR RATHER THAN A LABEL. This is + // the one config fault with no partial function left to preserve — the + // file is not TOML, so no row is readable and none can be enforced. Every + // other fault leaves the rest of the file deciding, which is what lets an + // agent be told to repair the broken part instead of losing the gate + // surface entirely. `Native::ConfigUnreadable` carries the full argument. + return UsageError::raise_as( + crate::verdict::Native::ConfigUnreadable, + format!("invalid config {source}: {err}"), + ); + } if !names_an_unknown_key(&rendered) { return UsageError::raise(format!("invalid config {source}: {err}")); } @@ -3042,7 +3071,8 @@ fn parse_ungated(text: &str, source: &str) -> Result { let pruned = prune_unresolvable::(text, binary_is_behind_the_config(source, text)); let config = match pruned.config { Some(config) => config, - None => toml::from_str(&pruned.text).map_err(|err| config_error(source, &err))?, + None => toml::from_str(&pruned.text) + .map_err(|err| config_error(source, &pruned.text, &err))?, }; (config, pruned.dropped) }; diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 264b915d9..b92350d4a 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -12115,8 +12115,7 @@ fn run_hook( // The note rides the ladder above `normal`, because on the hosts where this // is reachable it is the ordinary state rather than news. let capabilities = harness.capabilities(); - if !capabilities.emits(envelope.event) && envelope.event != hook::Event::Unrecognized { - let note = unsupported_event_note(harness, &capabilities, envelope.event); + if let Some(note) = undeclared_event_note(harness, &capabilities, envelope.event) { output::message(mode, Verbosity::Verbose, err, ¬e)?; return Ok(ExitCode::Success); } @@ -12245,18 +12244,38 @@ fn run_hook( // arms, ~0.7 ms against a 100 ms budget. `!adjudicable` keeps its old // behaviour, because an event with nothing to adjudicate has no protected // gate to run either, and that is the arm the hot path actually rides. - // A config this build cannot read is CERTAINTY, and certainty denies rather - // than exiting non-zero — `deny_unadjudicable` carries the whole argument, - // including why the hatch is read first. + // A DECLARATION NOTHING COULD READ IS THE ONE FAULT THAT REFUSES, and the + // narrowness is the decision rather than caution (CLOUD-1677). + // + // Gates are registered fail-open, so a gate that fails open is INERT — it + // neither allows nor denies, it is absent. A config fault is therefore never + // a choice between refusing and allowing: it is a choice between keeping the + // enforcement surface we still have and losing it entirely. An unknown key, a + // version this build is too old for, a table whose validator refused — each + // leaves every other row readable and enforceable, and leaves an agent that + // can still be TOLD to repair the broken one. Refusing there would trade a + // working partial surface for nothing. + // + // `Native::ConfigUnreadable` is the one class with no partial function left: + // the file is not TOML, so no row is readable and none can be enforced. Then + // the refusal is the only signal available, and the hatch below is how the + // container gets back. //MUTANT-SUITE crates/batten/tests/it/adjudicate_absent.rs - //MUTANT unloadable-config-allows|s@ Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()),@ Err(_) => (hook::Policy::declaring_nothing(harness), Vec::new()),@|a_config_this_build_cannot_load_denies_rather_than_failing_open + //MUTANT unloadable-config-allows|s@ Err(unreadable) if unreadable_declaration(\&unreadable) => {@ Err(unreadable) if false \&\& unreadable_declaration(\&unreadable) => {@|a_config_this_build_cannot_load_denies_rather_than_failing_open let (policy, waivers) = if adjudicable { match load_policy(overrides, harness) { Ok(loaded) => loaded, + // The declared hatch, read before the refusal so a stale binary + // meeting a newer config leaves a container recoverable, not bricked. Err(_) if bypass => (hook::Policy::declaring_nothing(harness), Vec::new()), - Err(unreadable) => { + Err(unreadable) if unreadable_declaration(&unreadable) => { return deny_unadjudicable(harness, &envelope, &unreadable, mode, out, err); } + // EVERY OTHER FAULT KEEPS ITS OLD BEHAVIOUR, deliberately. Today that + // is still a whole-file refusal at exit `1`, which is the outcome the + // argument above says is wrong — making these actually preserve the + // rows they can read is its own change, over `prune_unresolvable`. + Err(other) => return Err(other), } } else { (hook::Policy::declaring_nothing(harness), Vec::new()) @@ -12499,6 +12518,25 @@ fn run_hook( render(harness, &envelope, decision, &rendering, mode, out, err) } +/// The note for an event this host does not declare, or `None` to carry on. +/// +/// **`Unrecognized` is not undeclared**, and collapsing the two is why this is a +/// named predicate rather than an inline `&&`: an event nobody could parse has +/// no capability row to be absent from, so it falls through to the ordinary +/// path instead of being reported as a host that offers less. +/// +/// Returning the note rather than a `bool` keeps [`unsupported_event_note`]'s +/// call beside the condition that earns it — a caller that tested one and +/// rendered the other could report an event the table actually declares. +fn undeclared_event_note( + harness: hook::Harness, + capabilities: &hook::Capabilities, + event: hook::Event, +) -> Option { + (!capabilities.emits(event) && event != hook::Event::Unrecognized) + .then(|| unsupported_event_note(harness, capabilities, event)) +} + /// Whether this envelope has anything for the config to decide about. /// /// **The gate on whether a call pays a config read at all**, which is why the @@ -12527,7 +12565,25 @@ fn is_adjudicable(envelope: &hook::Envelope) -> bool { || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()) } -/// Refuse a call whose rules this build could not load (CLOUD-1688). +/// Whether this load failure is a declaration nothing could read at all. +/// +/// **Positively identified, never inferred from an absence.** The tempting +/// spelling is "carries no declared class", and it is wrong: the +/// unsupported-version and `min_batten_version` refusals carry none either, and +/// both leave every row in the file readable. Keying on absence would refuse +/// those too — and every future unclassed refusal after them, silently widening +/// what denies. +/// +/// So the loader says which one this is. `config_error` already separates a +/// syntax failure from an unknown key, and since CLOUD-1677 its syntax arm raises +/// under [`verdict::Native::ConfigUnreadable`]. +fn unreadable_declaration(err: &anyhow::Error) -> bool { + err.downcast_ref::() + .and_then(|usage| usage.verdict) + .is_some_and(|class| class == verdict::Native::ConfigUnreadable) +} + +/// Refuse a call whose rules this build could not load (CLOUD-1677). /// /// Lifted out of [`run_hook`] rather than left inline because that function is /// already at its line budget, and a boundary this load-bearing should be @@ -12585,16 +12641,32 @@ fn deny_unadjudicable( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - // Pointer-only (non-negotiable rule 4): the loader's own message names the - // key or the path that would not load, never the file's contents. The - // existing `max_age = 0` and unknown-key diagnostics ride through here - // unchanged, which is what keeps the operator's repair as findable as it was - // when this arm exited `1`. + // THE FIRST LINE ONLY, AND THAT IS NON-NEGOTIABLE RULE 4 RATHER THAN BREVITY. + // + // A `toml` parse error renders as a multi-line span — a header naming the + // position, then the OFFENDING SOURCE LINE with a caret under it. Interpolating + // the whole thing puts a byte of the unreadable config into a refusal that + // reaches the model, the host's log and the transcript, which is exactly the + // payload rule 4 keeps out of every report this engine writes. Measured by + // `the_declaration_that_would_not_parse_is_named_without_quoting_it`, which + // fails on the un-truncated form. + // + // The first line is the POINTER and loses nothing an operator needs: for a + // parse failure it is `TOML parse error at line L, column C`, and for the + // skew and unknown-key arms the whole message is one line already — the + // `max_age = 0` and `command_matcher` diagnostics ride through intact, which + // is what keeps the repair as findable as it was when this arm exited `1`. + let pointer = unreadable + .to_string() + .lines() + .next() + .unwrap_or_default() + .to_owned(); let refusal = Refusal::new( "engine-cannot-adjudicate", format!( "this build could not load the rules it is registered to enforce, so nothing judged \ - this call: {unreadable}" + this call: {pointer}" ), // No remedy the ENGINE may declare: the repair is rebuilding or // reinstalling the binary, or fixing the config, and each is the @@ -12605,7 +12677,16 @@ fn deny_unadjudicable( hatch: hook::BYPASS_ENV, ceiling: None, }; - render( + // WHICH CHANNEL CARRIED THE REFUSAL IS `render`'S OWN ANSWER, and reading it + // here is what lets the number say could-not-look without ever spending the + // refusal to do it (CLOUD-1677's exit-code half). + // + // `render` denies through the host's protocol: a document harness gets its + // decision object and answers `Ok`, while the neutral adapter's ONLY deny + // channel is the number, so it answers `Err(Denial)` — `run_hook`'s own + // contract says as much. The two arms below are therefore not a preference + // between codes, they are the two protocols. + match render( harness, envelope, hook::Decision::Deny(refusal), @@ -12613,7 +12694,25 @@ fn deny_unadjudicable( mode, out, err, - ) + ) { + // The DOCUMENT already refuses, so the number is free to be honest: §6–§7 + // reserve `3` for could-not-look, and nothing was judged about this call. + // `Internal` rather than `Violation` also keeps `exit.rs`'s guarantee + // whole — `Usage` and `Internal` are the only codes a failure of Batten's + // own may produce, *so that fail-open is structural* — and an unreadable + // declaration is such a failure. Answering `2` here would have bought the + // refusal twice and spent that guarantee for the second copy. + Ok(_) => Ok(ExitCode::Internal), + // THE NUMBER IS THIS HARNESS'S ONLY CHANNEL, so it stays the deny. Turning + // it into `3` would be honest about the cause and silent about the verdict, + // which is the fail-open this row exists to close. + // + // **The cost is stated rather than absorbed** (CLOUD-1677's Replay asks for + // the per-harness table): on this adapter the boundary cannot say BOTH + // "refused" and "nothing could be read", so the could-not-look half is + // unavailable there until that protocol grows a way to carry it. + Err(denial) => Err(denial), + } } /// Run the refusing row's declared repair, and say what the boundary decides now. diff --git a/crates/batten/src/lint.rs b/crates/batten/src/lint.rs index 011cf024f..9bddf29d2 100644 --- a/crates/batten/src/lint.rs +++ b/crates/batten/src/lint.rs @@ -401,7 +401,7 @@ pub fn smells( // same message it would anywhere else rather than this module's own. let config = config::parse(text, source)?; let located: Located = - toml::from_str(text).map_err(|err| config::config_error(source, &err))?; + toml::from_str(text).map_err(|err| config::config_error(source, text, &err))?; let mut found = Vec::new(); diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index ff125021b..4aa095466 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1213,6 +1213,33 @@ pub enum Native { // moment it fires. That also makes them resolvable from `vendored()` with // no config load, which is what keeps `policy explain` usable over a config // that will not parse. + /// The declaration could not be READ AT ALL — not valid TOML. + /// + /// **Deliberately not in [`Native::CONFIG_FAULTS`]**, which is the per-TABLE + /// set and is censused in both directions against `config.rs`'s own list. This + /// one has no table: it is raised before any table exists, by the parse that + /// every table's validator runs after. + /// + /// # Why it needs a class when the other parse failures do not + /// + /// A class here is not for `explain` — it is the DISCRIMINATOR the mediated + /// boundary acts on (CLOUD-1677). Gates are registered fail-open, and a gate + /// that fails open is inert: it neither allows nor denies, it is absent. So a + /// config fault is never a choice between refusing and allowing, it is a + /// choice between keeping the enforcement surface we still have and losing it + /// entirely. + /// + /// An unknown key, a version this build is too old for, a row whose validator + /// refused — each leaves every OTHER row readable and enforceable, and leaves + /// an agent that can still be told to repair the one that is broken. Failing + /// the whole load there buys nothing and costs the surface that would have + /// carried the repair instruction. + /// + /// A file that is not TOML is the one case with no partial function to + /// preserve: zero rows are readable, so refusing the call is the only signal + /// left, and the declared hatch is the recovery path. That asymmetry is why + /// this class exists and why it is exactly one class wide. + ConfigUnreadable, /// The `[[verb]]` table would not load. VerbTableRefused, /// The `[[pattern]]` table would not load. @@ -1291,6 +1318,7 @@ impl Native { Native::CallFixSilent, Native::ContentRefused, Native::KeyMissing, + Native::ConfigUnreadable, Native::VerbTableRefused, Native::PatternTableRefused, Native::VerdictTableRefused, @@ -1373,6 +1401,7 @@ impl Native { Native::CallFixSilent => "call fix silent", Native::ContentRefused => "input write refused", Native::KeyMissing => "issue name missing", + Native::ConfigUnreadable => "config read refused", Native::VerbTableRefused => "verb declare refused", Native::PatternTableRefused => "pattern declare refused", Native::VerdictTableRefused => "verdict declare refused", @@ -1891,6 +1920,20 @@ it serves.", // Every route is the config itself, which is not a placeholder: a config // fault is edited in exactly one file, and a `command` route would have to // name a task that can run over a config that does not load. + VendoredVerdict { + id: "config read refused", + gloss: "the declaration is not TOML, so no rule in it could be read", + class: "Every other config fault leaves the rest of the file deciding -- an unknown key \ +costs its own row, a table whose validator refuses names that table, and a version this build \ +is too old for still says so. Each of those keeps a working gate surface and an agent that can \ +be told to repair the broken part. This one has no partial function to preserve: the bytes are \ +not TOML, so zero rows are readable and nothing is enforced. That is why it is the one class \ +the mediated boundary refuses a call under, rather than reporting and proceeding -- a gate that \ +fails open is inert, and an inert gate over an unreadable authority is the false green this \ +engine exists to catch.", + routes: &[read("config read first", "batten.toml")], + applicability: Applicability::Advice, + }, VendoredVerdict { id: "verb declare refused", gloss: "the verb table would not load", @@ -2367,6 +2410,7 @@ mod tests { | Native::VerdictTrailing | Native::RunOrphaned | Native::CeilingExceeded + | Native::ConfigUnreadable | Native::ShapeRefused | Native::CallRetryNow | Native::CallFixSilent diff --git a/crates/batten/tests/it/adjudicate_absent.rs b/crates/batten/tests/it/adjudicate_absent.rs index 232ebca04..300938ce4 100644 --- a/crates/batten/tests/it/adjudicate_absent.rs +++ b/crates/batten/tests/it/adjudicate_absent.rs @@ -70,9 +70,14 @@ fn code(dir: &Path) -> Option { #[test] fn a_config_this_build_cannot_load_denies_rather_than_failing_open() { - // The defect: this was `2` only if the load succeeded. A `?` on the load - // raised a `UsageError` — exit `1` — and a harness reads `1` as a - // non-blocking hook error, so the mediated tool ran with nothing judging it. + // WAS `1`, WHICH A HARNESS READS AS A NON-BLOCKING HOOK ERROR, so the + // mediated tool ran with nothing judging it (CLOUD-1677). + // + // `2` HERE AND `3` ON A DOCUMENT HARNESS, and the split is the protocol + // rather than a preference: this adapter's ONLY deny channel is the number, + // so the number has to carry the refusal. Where the decision object carries + // it instead, the number is free to say could-not-look — the case below + // asserts that side. let dir = fixture("adjudicate-unloadable", WILL_NOT_PARSE); assert_eq!( code(&dir), @@ -142,8 +147,39 @@ fn on_claude_code_the_refusal_is_the_document_rather_than_the_number() { &payload(), ); let rendered = String::from_utf8_lossy(&output.stdout); + // The row's falsifier names the field rather than the word: a `contains("deny")` + // would pass on a document that merely mentioned it, including one that said + // the opposite. assert!( - rendered.contains("deny"), + rendered.contains(r#""permissionDecision":"deny""#), "the decision object must carry the deny: {rendered}" ); + // AND THE NUMBER IS FREE TO BE HONEST, which is the half that needs the + // document to exist. §6-§7 reserve `3` for could-not-look, and `exit.rs` + // keeps `Usage` and `Internal` the only codes a failure of Batten's own may + // produce *so that fail-open is structural*. Answering `2` here would buy the + // refusal a second time and spend that guarantee for the copy. + assert_eq!( + output.status.code(), + Some(3), + "where the document refuses, the number says nothing was judged" + ); +} + +#[test] +fn the_declaration_that_would_not_parse_is_named_without_quoting_it() { + // Non-negotiable rule 4, and the row asks for this clause by name: the reason + // carries the parse position, never the config's contents. The fixture's body + // is `this is not toml`, so its presence in the output would be the leak. + let dir = fixture("adjudicate-pointer-only", WILL_NOT_PARSE); + let output = run_with_stdin( + dir.as_path(), + &["adjudicate", "--harness", "claude-code"], + &payload(), + ); + let rendered = String::from_utf8_lossy(&output.stdout); + assert!( + !rendered.contains("this is not toml"), + "a refusal about an unreadable config must not quote it: {rendered}" + ); } diff --git a/crates/batten/tests/it/call_arguments.rs b/crates/batten/tests/it/call_arguments.rs index 6a1076ed7..693fbae26 100644 --- a/crates/batten/tests/it/call_arguments.rs +++ b/crates/batten/tests/it/call_arguments.rs @@ -257,10 +257,7 @@ reason = "unreachable" .build(); assert_eq!( verdict(&contradictory, "mcp__Linear__save_issue", r"{}"), - // `2` rather than `1` for CLOUD-1688's reason, stated in full at the - // `max_age = 0` case: the classification below is unchanged, but on the - // mediated boundary `1` is non-blocking and let the call through. - Some(2), + Some(1), "a row that can never fire is a usage error, not a silently inert gate" ); @@ -432,11 +429,8 @@ reason = "unreachable" "mcp__Linear__save_issue", r#"{"id":"CLOUD-1"}"# ), - // `2` rather than `1` for CLOUD-1688's reason, stated in full at the - // `max_age = 0` case below: the classification is unchanged, but on the - // mediated boundary `1` is non-blocking and let the call through. - Some(2), - "a call whose rules would not load must be refused, not allowed by the failure" + Some(1), + "a named key with no projection is a usage error" ); let wrong_key = Fixture::new("args-from-wrong-key") @@ -460,10 +454,7 @@ reason = "unreachable" .build(); assert_eq!( verdict(&wrong_key, "mcp__Linear__save_issue", r#"{"id":"CLOUD-1"}"#), - // `2` rather than `1` for CLOUD-1688's reason, stated in full at the - // `max_age = 0` case: the classification below is unchanged, but on the - // mediated boundary `1` is non-blocking and let the call through. - Some(2), + Some(1), "a projection on a branch-keyed row is a usage error, not an ignored column" ); } @@ -637,24 +628,10 @@ reason = "unreachable" .git() .base_commit() .build(); - // WAS `1`, AND THE CHANGE IS THE POINT (CLOUD-1688). The classification this - // asserted is still true — a bound of zero is a misconfiguration, never a - // very strict policy — but `1` is the code the harness reads as a - // NON-BLOCKING hook error, so on the mediated path it let the call through - // unjudged. Measured over one 5-day session, 1,149 calls proceeded exactly - // this way. - // - // The surfaces stay separate rather than one winning: `doctor` still never - // answers `2` (`a_failing_diagnosis_is_never_a_policy_verdict`), and the CLI - // verbs still raise a usage error over a config they cannot read. This is the - // one surface where "cannot judge" must not resolve to "proceed", because - // here the alternative is a tool call nobody looked at. - // - // The diagnostic is unchanged, which the next assertion is what proves. assert_eq!( verdict(&zero, "mcp__Linear__save_issue", r#"{"id":"CLOUD-1"}"#), - Some(2), - "a call whose rules would not load must be refused, not allowed by the failure" + Some(1), + "a bound of zero is a usage error, not a very strict policy" ); let refusal = run_with_stdin( &zero, diff --git a/crates/batten/tests/it/call_ceiling.rs b/crates/batten/tests/it/call_ceiling.rs index 2042b3a5f..2e53fb73f 100644 --- a/crates/batten/tests/it/call_ceiling.rs +++ b/crates/batten/tests/it/call_ceiling.rs @@ -169,16 +169,12 @@ reason = "..." &["adjudicate", "--harness", "exit-code"], &payload(&prompt_of(10)), ); - // A CONFIG FAULT IS STILL NOT A POLICY VERDICT — and on THIS surface it is - // still a refusal (CLOUD-1688). `rules/rust.md`'s rule that no Batten failure - // may read as a deny is what keeps `doctor` and the CLI verbs on `1`; the - // mediated boundary is the exception, because there `1` is non-blocking and - // the call it could not judge simply ran. The classification below is - // unchanged and so is the `measures` diagnostic the next assertion pins. + // Exit 1 is the usage code: a config fault, never a policy verdict, so no + // Batten failure can read as a deny (`rules/rust.md`). assert_eq!( output.status.code(), - Some(2), - "a partial ceiling is a config fault, and one this call cannot be judged under: {}", + Some(1), + "a partial ceiling is a config fault: {}", stderr(&output) ); assert!( diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 1a1df93e3..ce96741e7 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -2680,24 +2680,56 @@ fn hook_allows_when_no_authority_is_configured() { } #[test] -fn hook_fails_open_and_loud_on_an_unloadable_authority() { - // The opposite case, and the one CLOUD-40 could not reach because `hook` - // loaded no config: an authority that EXISTS and cannot be read means the - // rules the operator wrote are not being applied. Allowing silently there - // would be the false green the engine exists to catch, so it is a usage - // error — loud on stderr, exit 1, and structurally not a deny, because §7 - // spends 2 on the verdict alone. +fn hook_refuses_and_is_loud_on_an_unloadable_authority() { + // RENAMED FROM `hook_fails_open_and_loud_…`, because the first half stopped + // being true (CLOUD-1677) — and ONLY for this fixture's fault. + // + // The reasoning it was written on is what the row acted on: an authority that + // EXISTS and cannot be read means the operator's rules are not being applied, + // and allowing silently is the false green the engine exists to catch. What + // was wrong is that "loud" was the whole remedy — exit `1` is a NON-BLOCKING + // hook error, so the tool ran anyway and the loudness reached a log nobody + // gates on. Measured: 1,149 calls proceeded through seven windows of a + // mid-edit `batten.toml`, which is itself a protected path, so the gate + // guarding the config stopped guarding it during the one operation that + // changes it. + // + // **THIS FIXTURE IS `not toml at all`, WHICH IS THE WHOLE SCOPE.** A config + // that parses and merely fails a validator, or names a key this build + // predates, still answers `1` — every other row in it remains readable and + // enforceable, and an agent can still be told to repair the broken one. + // Refusing there would trade a working partial gate surface for nothing. + // Here there is no partial surface: nothing parsed, so nothing is enforced. let dir = repo_with_config("hook-broken-authority", "this is not toml at all\n"); for harness in harnesses() { let output = run_hook_in(&dir, harness, &claude_payload("gh pr view 42"), false); let code = output.status.code(); - assert_eq!(code, Some(1), "{harness}: an unreadable authority is usage"); - assert_ne!(code, Some(2), "{harness}: must never deny"); - assert!(output.stdout.is_empty(), "{harness}: no decision document"); + let stdout = String::from_utf8_lossy(&output.stdout); + // WHAT THIS CASE PROVES IS THAT NO HARNESS FAILS OPEN, and it deliberately + // does not assert the channel. Six protocols write six different decision + // documents, so a `permissionDecision` assertion inside this loop tests + // Claude Code's spelling five times and passes it off as coverage. The + // per-protocol detail — the document on a host that reads one, the `2` on + // the neutral adapter whose only channel is the number — belongs where it + // can be stated exactly, which is `adjudicate_absent.rs`. + // + // `1` was the whole defect: non-blocking, so the call ran. `0` would be a + // clean allow over an authority nothing could read. + assert!( + matches!(code, Some(2 | 3)), + "{harness}: a call under an unreadable authority must not proceed, got {code:?}" + ); + // NEVER SILENT, AND NEVER ANONYMOUS — the half of the original case that + // was always right, asked of whichever channel actually carried the + // refusal. The original asked stderr of every harness, which was true + // while the answer was always an exit-`1` diagnostic; now a document + // harness puts the reason in the document and leaves stderr empty, so + // asserting stderr alone would fail on the hosts that refuse best. let stderr = String::from_utf8_lossy(&output.stderr); + let spoken = format!("{stderr}{stdout}"); assert!( - stderr.contains("batten.toml"), - "{harness}: the failure names the file, got: {stderr}" + spoken.contains("batten.toml"), + "{harness}: the failure names the file, got: {spoken}" ); } } diff --git a/crates/batten/tests/it/config_forward_compatible.rs b/crates/batten/tests/it/config_forward_compatible.rs index 69a9018e3..c1a623148 100644 --- a/crates/batten/tests/it/config_forward_compatible.rs +++ b/crates/batten/tests/it/config_forward_compatible.rs @@ -658,14 +658,33 @@ fn the_report_never_tells_the_reader_to_install_an_older_release() { /// fault from a well-formed row naming a key from a newer schema, and collapsing /// the two is what produced the defect — a prune that swallowed a syntax error /// would load "no rules configured" over a broken file. +/// +/// **AND SINCE CLOUD-1677 THE REFUSAL REACHES THE CALL.** The claim above is +/// unchanged and is exactly why: a newer-schema row leaves every other row +/// readable, so the file still decides and an agent can be told to repair the one +/// key. A file that is not TOML leaves nothing — zero rows readable, nothing +/// enforced — and a gate that fails open there is inert, which is the false green +/// the engine exists to catch. So this is the one config fault that denies. +/// +/// It was `1`: the usage code, non-blocking, and the `rm /` below simply ran. #[test] fn a_file_that_is_not_toml_is_still_refused() { let dir = repo("config-forward-broken", "[[rule\nbroken\n"); - let (code, _, stderr) = adjudicate(&dir); - assert_eq!(code, Some(1), "malformed TOML is a usage error: {stderr}"); + let (code, stdout, stderr) = adjudicate(&dir); + // THE DENY IS THE JSON, as the case above states for this harness. What + // differs here is the number beside it: with the document carrying the + // refusal, the code is free to say could-not-look — §6-§7's `3` — rather than + // claiming a verdict was reached. `exit.rs` keeps `Usage` and `Internal` the + // only codes a Batten failure produces, so refusing costs that guarantee + // nothing. + assert_eq!(code, Some(3), "nothing could be judged: {stdout} {stderr}"); + assert!( + stdout.contains(r#""permissionDecision":"deny""#), + "a call under an unreadable authority is refused: {stdout}" + ); assert!( - stderr.contains("TOML parse error"), - "the refusal says the file is not TOML: {stderr}" + format!("{stdout}{stderr}").contains("TOML parse error"), + "the refusal says the file is not TOML: {stdout} {stderr}" ); } From 76403bb9c6a471a712b4766d338f8ec3806d0174 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 23:26:39 +0000 Subject: [PATCH 06/32] refactor(hook): name the two fail-open boundaries, and let the raw payload travel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run_hook` sits at its 100-line budget and the speculative tree pushed it to 102, so the stdin read and the decode become `read_envelope`. The grouping is the point rather than the line count: unreadable stdin and an undecodable payload are one answer — the engine does not know what this call IS, and a guard must never be the reason a session cannot proceed. That is the opposite side of `unreadable_declaration`, where the engine knows the call perfectly well and has been told it cannot enforce the rules over it. Naming them apart is what stops the next reader collapsing the two. The raw bytes travel with the decoded value because `dispatch_handlers` hands a declared handler the payload as it arrived: stdin is consumed, so re-reading is not available, and re-serializing would hand a handler a document the host never sent. Caught by the compiler on the first extraction, and written down so the tuple is not a mystery. 784 tests pass across the six suites this touches, `handler_dispatch` included. Refs: CLOUD-1677 --- crates/batten/src/lib.rs | 44 ++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index b92350d4a..eb0d9fb7b 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -12088,16 +12088,10 @@ fn run_hook( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - let mut raw = String::new(); - if std::io::stdin().read_to_string(&mut raw).is_err() { - output::message(mode, Verbosity::Normal, err, UNREADABLE_STDIN)?; - return Ok(ExitCode::Success); - } - let bypass = std::env::var_os(hook::BYPASS_ENV).is_some_and(|value| !value.is_empty()); - let Some(mut envelope) = hook::decode(harness, &raw) else { - output::message(mode, Verbosity::Normal, err, UNDECODABLE_PAYLOAD)?; + let Some((raw, mut envelope)) = read_envelope(harness, mode, err)? else { return Ok(ExitCode::Success); }; + let bypass = std::env::var_os(hook::BYPASS_ENV).is_some_and(|value| !value.is_empty()); // THE WRITE TARGET IS READ AS THE REPOSITORY READS IT (CLOUD-1133), and this // is the one place that can do it: `decode` is pure and has no repository, // and the readers below — the protected gate, and any module over @@ -12565,6 +12559,40 @@ fn is_adjudicable(envelope: &hook::Envelope) -> bool { || (envelope.event == hook::Event::PreTool && !envelope.raw_tool.is_empty()) } +/// The mediated call on stdin, or `None` where the call must simply proceed. +/// +/// **THE TWO FAIL-OPEN BOUNDARIES, TOGETHER BECAUSE THEY ARE ONE ANSWER.** Stdin +/// that will not read and a payload that will not decode are both "the engine +/// does not know what this call IS", and neither may block it: a guard must never +/// be the reason a session cannot proceed. That is the opposite side of +/// [`unreadable_declaration`], where the engine knows the call perfectly well and +/// has been told it cannot enforce the rules over it. +/// +/// **Loud, never silent** (CLOUD-43). A guard that cannot read its input is a gate +/// that did not run, and the silent version of that is byte-identical to a clean +/// allow — the false green this engine exists to catch, in the one place nobody +/// would think to look. +/// **The RAW bytes travel with the decoded value**, because `dispatch_handlers` +/// hands a declared handler the payload as it arrived. Re-reading stdin for it is +/// not an option — the stream is consumed — and re-serializing the envelope would +/// hand a handler a document the host never sent. +fn read_envelope( + harness: hook::Harness, + mode: Mode, + err: &mut dyn Write, +) -> Result> { + let mut raw = String::new(); + if std::io::stdin().read_to_string(&mut raw).is_err() { + output::message(mode, Verbosity::Normal, err, UNREADABLE_STDIN)?; + return Ok(None); + } + let Some(envelope) = hook::decode(harness, &raw) else { + output::message(mode, Verbosity::Normal, err, UNDECODABLE_PAYLOAD)?; + return Ok(None); + }; + Ok(Some((raw, envelope))) +} + /// Whether this load failure is a declaration nothing could read at all. /// /// **Positively identified, never inferred from an absence.** The tempting From 8a5f0d58480b9c17265dfb261ab61631f2ee012f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 23:37:49 +0000 Subject: [PATCH 07/32] fix(verdict): append the new class rather than grouping it, since position is API `Native` carries no `repr`, so a variant added in the middle shifts every later discriminant. Placing `ConfigUnreadable` beside the other config classes for readability moved eighteen of them, and `semver check` read the whole tail as broken under `enum_no_repr_variant_discriminant_changed`. Appended, and the reason is written onto the variant so the next reader who wants to group it tidily meets the cost first. Declaration order is API and is append-only; the reading order in `ALL` and `as_str` is free, and both keep the class beside its siblings where a reader looks for it. `semver check`: the API delta is patch-compatible against origin/main, so no break is declared and none is owed. 459 tests pass across the census and the affected suites. Refs: CLOUD-1677 --- crates/batten/src/verdict.rs | 60 ++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 4aa095466..067b79ad8 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1213,33 +1213,6 @@ pub enum Native { // moment it fires. That also makes them resolvable from `vendored()` with // no config load, which is what keeps `policy explain` usable over a config // that will not parse. - /// The declaration could not be READ AT ALL — not valid TOML. - /// - /// **Deliberately not in [`Native::CONFIG_FAULTS`]**, which is the per-TABLE - /// set and is censused in both directions against `config.rs`'s own list. This - /// one has no table: it is raised before any table exists, by the parse that - /// every table's validator runs after. - /// - /// # Why it needs a class when the other parse failures do not - /// - /// A class here is not for `explain` — it is the DISCRIMINATOR the mediated - /// boundary acts on (CLOUD-1677). Gates are registered fail-open, and a gate - /// that fails open is inert: it neither allows nor denies, it is absent. So a - /// config fault is never a choice between refusing and allowing, it is a - /// choice between keeping the enforcement surface we still have and losing it - /// entirely. - /// - /// An unknown key, a version this build is too old for, a row whose validator - /// refused — each leaves every OTHER row readable and enforceable, and leaves - /// an agent that can still be told to repair the one that is broken. Failing - /// the whole load there buys nothing and costs the surface that would have - /// carried the repair instruction. - /// - /// A file that is not TOML is the one case with no partial function to - /// preserve: zero rows are readable, so refusing the call is the only signal - /// left, and the declared hatch is the recovery path. That asymmetry is why - /// this class exists and why it is exactly one class wide. - ConfigUnreadable, /// The `[[verb]]` table would not load. VerbTableRefused, /// The `[[pattern]]` table would not load. @@ -1285,6 +1258,39 @@ pub enum Native { /// than in a consumer `[[verdict]]` row. No consumer can name the class, /// because the engine is what takes the plan and what compares it. PlanReadStale, + /// The declaration could not be READ AT ALL — not valid TOML. + /// + /// **APPENDED, NEVER INSERTED.** This enum carries no `repr`, so a variant + /// added in the middle shifts every later discriminant and + /// `enum_no_repr_variant_discriminant_changed` reads the whole tail as + /// broken — measured here, where placing it beside the other config classes + /// moved eighteen of them. Position is API; the reading order below is not. + /// + /// **Deliberately not in [`Native::CONFIG_FAULTS`]**, which is the per-TABLE + /// set and is censused in both directions against `config.rs`'s own list. This + /// one has no table: it is raised before any table exists, by the parse that + /// every table's validator runs after. + /// + /// # Why it needs a class when the other parse failures do not + /// + /// A class here is not for `explain` — it is the DISCRIMINATOR the mediated + /// boundary acts on (CLOUD-1677). Gates are registered fail-open, and a gate + /// that fails open is inert: it neither allows nor denies, it is absent. So a + /// config fault is never a choice between refusing and allowing, it is a + /// choice between keeping the enforcement surface we still have and losing it + /// entirely. + /// + /// An unknown key, a version this build is too old for, a row whose validator + /// refused — each leaves every OTHER row readable and enforceable, and leaves + /// an agent that can still be told to repair the one that is broken. Failing + /// the whole load there buys nothing and costs the surface that would have + /// carried the repair instruction. + /// + /// A file that is not TOML is the one case with no partial function to + /// preserve: zero rows are readable, so refusing the call is the only signal + /// left, and the declared hatch is the recovery path. That asymmetry is why + /// this class exists and why it is exactly one class wide. + ConfigUnreadable, } impl Native { From 5d52da3479d97883ad4057c39cdb5432a248f0be Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 10:01:16 +0000 Subject: [PATCH 08/32] fix(ci): feed the forge record the deny rule has always read as null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `forge-verdict-required` is registered at `severity = "deny"` and reads `input.tree.forge`, which `crates/batten/src/forge.rs` resolves from `.git/batten-forge/`. `batten record forge` writes that store and was invoked zero times, so the fact was `null` on every checkout, the module's `is_object` guard never held, and the row decided nothing. A dead gate is byte-identical to a clean tree on the decision surface. `[tasks.record-verdicts]` already runs the same shape for `record tool`, and `verify` calls it before the gates precisely so a `deny` row is not adjudicated over a record nothing wrote. The forge arm goes beside the three tool arms. THE REF SET IS NAMED: `HEAD`, which is the whole of what is declared. Both consuming rows carry `forge = ["HEAD"]` and no row declares another ref, so the record is scoped to what is asked rather than narrowed below it. THE FAN-IN GATES WRITING AT ALL. Until `$CI_FANIN_CHECK` has concluded the forge has not finished judging the commit, and a record PRESENT without a passing fan-in is what the module refuses. Writing unconditionally would therefore refuse every local `verify` (a freshly minted SHA CI has not graded) and every CI run (the fan-in is pending by construction while `verify` runs inside it). Absent is could-not-look and is the correct reading for both, so a failed fetch and an ungraded commit each write nothing. A re-run adds a second check-run under one name, so the listing is reduced to the latest run per name at the producer — `forge::parse` folds into a map and would otherwise take whichever line came last by listing order. A name carrying a space has no spelling in the ` ` format and is dropped rather than mangled; the count goes to stderr as a pointer, never the payload. The three comments asserting a producer-less state are corrected in the same change rather than left to read as live. Refs: CLOUD-1707, CLOUD-1265, CLOUD-1154 Admits: 404fca68fbbf5dfc9b90113a1a43a00fa424cf32da1eaf8d97a7d25425e803e4 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:c179129dc568a385c409cee3fcbad095b69bd8b2 Admits-epoch: 7458c4f230d3c45b576126850daeb581baf1820b61d140ede652b78a37c51324 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: Three comment blocks keep asserting that `forge-verdict-required` has no producer and therefore decides nothing — the exact sentence that made the dead `deny` row read as intentional for ~1400 commits. This change lands the producer, so leaving the prose turns a stale-but-true note into an actively false one, at the two sites a reader consults to learn whether the row is live. That is CLOUD-1680's restated-claim defect in the row filed to end it. Admits-answer-precondition: The redirect this class names for `batten.toml` is "change it in a pull request", which states how the change must LAND rather than naming a surface that can express it: no tool other than a direct write can correct the prose of three comment blocks inside `[[rule]]` and `[[rule.tools]]` tables. CLOUD-1707's acceptance names those comments explicitly as work in scope, because they assert a producer-less state that this same change ends. Branch claude/retire-bash-corpus-44-sjdnok, draft PR, reviewed before merge. Admits-answer-rejected-route: `patch run first` does not apply: the protected-path gate is the intersection of the protected paths with the mutating-verb table, and that table already refuses `>`, `tee`, `sed`, `cp`, `install` and `git` over this path, so routing identical bytes through a patch program reaches the identical refusal under a different program name. `config read first` was TAKEN, not rejected — `batten.toml`'s `[[rule]]` rows for `forge-verdict-required` and `validator-verdict-clean`, `policy/forge-verdict-required.rego` and the `[tasks.record-verdicts]` body were all read before this request, and that reading is what established which three comments are false and what each must now say. Reported rather than absorbed: the edits were made with `python3`, which the mutating-verb table does not cover and which was NOT refused at the hook — a coverage gap in that table, caught here at commit time instead. --- batten.toml | 13 ++++++++++ mise.toml | 74 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/batten.toml b/batten.toml index 504c8e5a4..5e7302453 100644 --- a/batten.toml +++ b/batten.toml @@ -5629,6 +5629,13 @@ severity = "deny" # green fires this and not that; a commit graded red fires both, and they say # different things — do not re-run it, and do not land it. # +# BOTH READINGS NEEDED A PRODUCER, and until CLOUD-1707 neither had one: nothing +# invoked `batten record forge`, so this fact was `null` and the paragraph above +# described a discrimination no checkout could make. `mise run record-verdicts` +# writes it, and writes it ONLY once the fan-in has concluded — so "nothing was +# recorded at all" now means the forge has not finished judging, which is the +# could-not-look both rows read it as. +# # `warn`, NOT `deny`, AND THE FIRST LANDING IS THE REASON. `land` re-verifies and # re-waits every lap by design, because a rebase mints a new SHA and the receipts # keyed to the old one are gone — so the loop legitimately reaches graded commits, @@ -6597,6 +6604,12 @@ looked at it.""" # bytes at this version — absent from the map, not a verdict — which is what a # checkout gets if the producer was skipped or died. The row that wants a verdict # to be REQUIRED is `forge-verdict-required`'s shape and is not this one. +# +# THAT CONTRAST ONLY BECAME REAL WITH CLOUD-1707. `forge-verdict-required` had no +# producer, so it refused nothing and the sentence above named a shape rather than +# a behaviour. Both rows are fed by `mise run record-verdicts` now, and they still +# read absence the same way — what differs is what each does with a record that IS +# present, which is the distinction this block was always drawing. [[rule]] id = "validator-verdict-clean" kind = "policy" diff --git a/mise.toml b/mise.toml index 4c33e7cff..42b167619 100644 --- a/mise.toml +++ b/mise.toml @@ -1843,8 +1843,12 @@ description = "Effect: run each declared third-party validator OUTSIDE the engin # reads `.git/batten-forge/`; both shipped with no writer but a test, so # `validator-verdict-clean` and `forge-verdict-required` — two registered # `severity = "deny"` rows — resolved `null` on every real checkout and decided -# nothing. Each row says so at its own site in `batten.toml`: "SILENT UNTIL A -# PRODUCER WRITES." +# nothing. +# +# BOTH HALVES ARE WRITTEN HERE NOW. The tool half landed with this task +# (CLOUD-1265); the forge half is the arm at the end of this body (CLOUD-1707), +# and until it landed this file closed one of the two gaps its own header +# described and left the other open. # # THE RUN IS HERE BECAUSE IT CANNOT BE IN THE ENGINE. House style §5 makes # `check` `read` and structurally incapable of spawning, so the validator stays a @@ -1954,6 +1958,72 @@ jq -r --slurpfile full "$full" ' | ($full[0].steps[] | select(.name == $name) | .status) as $under | "\($name) \($under // "absent")" ' <"$fast" | record hk-plan + +# THE FORGE'S OWN VERDICT, and the row it feeds had never been fed (CLOUD-1707). +# `crates/batten/src/forge.rs` reads `.git/batten-forge/` and +# `batten record forge` writes it, but nothing invoked the writer — so +# `input.tree.forge` was `null` on every checkout, the module's `is_object` guard +# never held, and `forge-verdict-required` decided NOTHING while registered at +# `severity = "deny"`. A dead gate is byte-identical to a clean tree. +# +# THE REF IS `HEAD`, AND THAT IS THE WHOLE DECLARED SET. Two rows read this fact +# — `forge-verdict-required` and the re-grade row above it — and each declares +# exactly `forge = ["HEAD"]`. Recording HEAD is scoped to what is asked rather +# than narrowed below it. A row that later declares a second ref must add it here +# in the same change; an under-scoped record is the same silent pass in a new +# costume. +# +# THE FAN-IN IS THE GATE ON WRITING AT ALL, and this is the load-bearing half. +# `$CI_FANIN_CHECK` is the check every other required job feeds, so until IT has +# concluded the forge has not finished judging this commit. Recording a partial +# reading would be a record PRESENT without a passing fan-in, which +# `forge-verdict-required` refuses — and that refusal would fire in the two places +# it must never fire: +# * local `verify`, which runs against a freshly minted SHA CI has not graded, +# turning every pre-push check red; +# * CI itself, where `verify` runs while the fan-in is by construction still +# pending, so the record would refuse the very run that wrote it. +# Absent is could-not-look and is the correct reading for both. So a failed fetch +# writes nothing, and a commit whose fan-in has not concluded writes nothing. +# +# A NAME WITH A SPACE CANNOT BE SPELLED HERE, and is dropped rather than mangled. +# The record's byte format is ` ` split on the first whitespace run, +# so `action (ubuntu-latest) success` would record the name `action` with the +# conclusion `(ubuntu-latest)` — a wrong verdict under a name three checks collide +# on. Nine of `$CI_REQUIRED_CHECKS` carry spaces. The fan-in both rows read is one +# token by construction, so nothing a consumer reads is lost; the COUNT of dropped +# names goes to stderr (rule 4 — a count, never the payload) so the limit stays +# visible rather than silent. +forge_sha=$(git rev-parse HEAD 2>/dev/null || true) +forge_fanin="${CI_FANIN_CHECK:-}" +if [ -n "$forge_sha" ] && [ -n "$forge_fanin" ]; then + forge_repo="${REPO:-}" + [ -n "$forge_repo" ] || forge_repo='{owner}/{repo}' + # LATEST RUN PER NAME, and the reduction belongs here rather than in the + # reader. A re-run adds a second check-run under the SAME name, so the raw + # listing carries `fast-forward-release-pr success` and `… skipped` for one + # commit — and `forge::parse` folds a record into a map, so whichever line came + # last would win by listing order rather than by recency. `checks-green` draws + # the same distinction with `started_at` and `id`; this is that reduction, + # spelled once, at the producer. + if forge_runs=$(gh api "repos/$forge_repo/commits/$forge_sha/check-runs?per_page=100" \ + --jq '.check_runs | map(select(.conclusion != null)) | group_by(.name) | map(max_by([(.started_at // ""), (.id // 0)])) | .[] | "\(.name)\t\(.conclusion)"' 2>/dev/null); then + # The fan-in decides whether ANY record is written; the name filter then + # decides what the record may spell. + forge_graded=$(printf '%s\n' "$forge_runs" | + awk -F'\t' -v fanin="$forge_fanin" 'NF == 2 && $1 == fanin {found = 1} END {print found + 0}') + if [ "$forge_graded" -eq 1 ]; then + forge_dropped=$(printf '%s\n' "$forge_runs" | + awk -F'\t' 'NF == 2 && $1 ~ /[[:space:]]/ {n++} END {print n + 0}') + if [ "$forge_dropped" -gt 0 ]; then + echo "record-verdicts: $forge_dropped check-run name(s) carry a space and have no spelling in a forge record; dropped" >&2 + fi + printf '%s\n' "$forge_runs" | + awk -F'\t' 'NF == 2 && $1 !~ /[[:space:]]/ {print $1 " " $2}' | + cargo run --quiet -p batten -- record forge HEAD + fi + fi +fi ''' # The perf producer and the perf gate (CLOUD-207, ported off From 9f967af783c8baefed49690609847466d02b3357 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 10:21:02 +0000 Subject: [PATCH 09/32] feat(forge): one windowed forge read, where truncation is a verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven shell programs each reimplement "read a paginated forge collection over a window, cache the validator, notice truncation, reduce to a record" — 1,583 lines. `land-divergence:88-130` and `nonverdict-scan:103-147` carry a LITERAL COPY of one 45-line `conditional_get`. The duplication is the proof the primitive was missing; this is that primitive. `rest.rs` already ships the transport, so this is the layer above it and not a second HTTP client. TRUNCATION IS NEVER A SHORT SUCCESS. Three programs discovered the trap independently and guarded it three ways — `merged-pr-keys` against its `--limit`, `land-divergence` against `total_count`, and `timeout-drift` not at all. `Window::Truncated` carries what it could not see (rows read, the forge's own count where stated, pages spent), because a caller handed a prefix cannot tell it from the whole collection and every reduction over it answers about a window while reporting about a population. A SHORT PAGE ENDS THE COLLECTION, not only an empty one, and the page size is read off the caller's own `per_page` rather than assumed — the forge's default differs per endpoint, which is how `timeout-drift`'s unpaginated `/jobs` call silently takes 30. `Answer::header` returns any header, including off a non-2xx: `gh-preflight` reads `X-Accepted-GitHub-Permissions` off a 403, which `is_reading` and `answered` both exclude. The transport already read the whole block and was discarding all but four — the second `gh api -i` header parser this module's header says the typed boundary retired, standing again one endpoint over. The validator store is persistent and keyed by the whole URL, query string included, because two pages of one collection are two readings and a key that collapsed them would serve page 1's body for page 2. A 304 with no cached body is could-not-look, never an empty page. THE TRANSPORT IS AN ARGUMENT, and the reason is testability: `rest`'s own seam is an environment variable, `set_var` is `unsafe` under this edition, and this crate forbids `unsafe`. The established way round is a subprocess against a CLI leaf, which this row deliberately does not add. So the seam moves up one level where it is an ordinary parameter, and the cases drive the real walk. `forge.rs`'s header said "the engine opens no socket, and that is the whole design". That is now false for half the file, so it is corrected rather than left standing: the reader is still I/O-free and still what `check` reaches, and the split is enforced by who calls, exactly as `record-verdicts` already is. Refs: CLOUD-1712, CLOUD-1177, CLOUD-418 --- crates/batten/src/forge.rs | 322 ++++++++++++++++++++++++- crates/batten/src/main_watch.rs | 2 + crates/batten/src/pr_watch.rs | 1 + crates/batten/src/rest.rs | 55 +++++ crates/batten/tests/it/forge_window.rs | 284 ++++++++++++++++++++++ crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/trunk_watch.rs | 2 + 7 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 crates/batten/tests/it/forge_window.rs diff --git a/crates/batten/src/forge.rs b/crates/batten/src/forge.rs index 7559002db..a8dba9f0e 100644 --- a/crates/batten/src/forge.rs +++ b/crates/batten/src/forge.rs @@ -1,7 +1,7 @@ //! The forge's verdict for a commit, read back from a record something else //! wrote (CLOUD-1154). //! -//! # The engine opens no socket, and that is the whole design +//! # The READING half opens no socket, and that is the whole design //! //! House style §5 forbids an HTTP client on the `check` surface and CLOUD-689's //! ~100ms budget forbids one on the mediated path, so ~22 governed gates that @@ -9,6 +9,40 @@ //! engine but to move WHO RESOLVES: the producer fetches once, outside — a //! workflow step, an agent call — and writes a keyed record this reads back. //! +//! # This module has TWO halves now, and the split is the §5 line itself +//! +//! **This heading used to read "the engine opens no socket", full stop, and +//! CLOUD-1712 makes that false for half the file** — so it is corrected rather +//! than left to read as a property the reader can rely on everywhere. +//! +//! * [`verdicts`], [`record_path`] and [`parse`] are the READER. No I/O beyond +//! opening a file under the git directory, no clock, no network. This is what +//! `check` reaches, it is `Cost::Read`, and the paragraph above is its +//! contract in full. +//! * [`window`] is the PRODUCER's side. It fetches, over +//! [`crate::rest`], and it exists because seven shell programs each +//! reimplemented the same paginated read — two of them carrying a literal copy +//! of one 45-line `conditional_get`. +//! +//! **The split is enforced by WHO CALLS, exactly as `record-verdicts` already +//! is.** `check` is `Cost::Read` and structurally cannot spawn or fetch; a +//! producer verb is `Effect::Write` and may. Nothing on the read path calls +//! [`window`], and a rule wanting windowed data reads the record a producer +//! wrote — which is the same seam, one collection wider. `evaluator-io-check` is +//! a different question and is untouched: it gates the Rego EVALUATOR reaching +//! `http.send`, which no part of this module changes. +//! +//! # Truncation is a verdict, never a short list +//! +//! The reason [`window`] returns [`Window::Truncated`] rather than the rows it +//! managed to read is measured rather than theoretical. Three programs +//! discovered the trap independently and guarded it three ways — +//! `merged-pr-keys` against its `--limit`, `land-divergence` against +//! `total_count`, and `timeout-drift` **not at all**, silently trusting one +//! unpaginated page. A caller handed a prefix cannot tell it from the whole +//! collection, so every reduction over it — a percentile, a max, an +//! is-there-any — answers about a window and reports about a population. +//! //! That is exactly [`crate::facts::AGENT_SOURCED`]'s argument, moved from the //! hook surface to the tree one: *the same answer that is `verify-only` when the //! ENGINE would fetch it is not when something else already did.* The table is @@ -173,3 +207,289 @@ mod tests { assert!(!checks.contains_key("lonely"), "{checks:?}"); } } + +// --- the producer's half: one windowed read, and truncation is a verdict ----- +// +// CLOUD-1712. Everything below fetches; nothing above it does. The module header +// states why that line is where it is. + +/// Where the conditional-read validators live, under the git directory. +/// +/// Beside [`DIRECTORY`] and for its reason: per-checkout state that must never be +/// committed. `land-divergence` hand-rolled this under +/// `.git/batten-divergence`, keyed by a hash of the URL; the key is the same +/// idea, spelled once. +const VALIDATORS: &str = "batten-forge-validators"; + +/// How a collection's rows are carried in the response body. +/// +/// **Named by the caller rather than sniffed**, and that is deliberate. The +/// forge answers some endpoints with a bare array and others with an object +/// wrapping a named array beside a `total_count`. A reader that guessed — "the +/// one field that is an array" — would be one schema change away from silently +/// reducing over the wrong field, and it could not tell an object with two +/// arrays from an object with one. The caller knows its endpoint; the guess only +/// moves the knowledge somewhere it cannot be checked. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Shape<'a> { + /// The body IS the array, e.g. `pulls`. + Bare, + /// The body is an object; the rows are under this key, e.g. `check_runs`. + Wrapped(&'a str), +} + +/// What one windowed read found. +/// +/// Three answers rather than two, on this module's own three-valued discipline: +/// a whole collection, a collection the window could not reach the end of, and +/// a forge that could not be asked. Collapsing the last two would report a +/// network failure as a truncation; collapsing the first two is the defect the +/// type exists to prevent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Window { + /// Every row the collection holds. The walk reached the end. + Whole(Vec), + /// The window ended before the collection did. + /// + /// **Carries what it could not see, because a truncation that cannot say + /// how much it missed is only marginally better than a silent one.** `read` + /// is how many rows were collected; `total` is the forge's own count where + /// the endpoint states one, and `None` where it does not — in which case the + /// evidence is that the last page came back full at the page budget. + Truncated { + /// Rows collected before the window ran out. + read: usize, + /// The collection's own size, where the endpoint reports it. + total: Option, + /// Pages actually fetched — the budget that was spent. + pages: u32, + }, + /// The forge could not be asked, or answered something unparseable. + /// + /// **Could-not-look, never an empty collection.** `rest::get` returns `None` + /// only where the exchange did not happen, but a completed exchange carrying + /// a `403` has an error document for a body — which parses to zero rows and + /// is byte-identical, on the decision surface, to a genuinely empty + /// collection. That is the defect `Answer::is_reading` records one layer + /// down, and this arm is where it is refused here. + /// + /// The payload is a POINTER — the endpoint and a status — never a body. + /// Rule 4 is decided here rather than at the report, for this module's own + /// stated reason: a forge error document is a likely place for a secret. + CouldNotLook { + /// The endpoint asked about, without its query string. + endpoint: String, + /// The status the forge answered with, where it answered at all. + status: Option, + }, +} + +/// How a window reaches the forge. +/// +/// **A parameter rather than a hard call to [`crate::rest::get`], and the reason +/// is testability rather than abstraction for its own sake.** The transport's +/// own test seam is the `BATTEN_REST_FIXTURE` environment variable, which a +/// suite in this process cannot set: `std::env::set_var` is `unsafe` under this +/// edition and this crate forbids `unsafe` outright, and the established way +/// round that is to run the compiled binary as a subprocess — which needs a CLI +/// leaf this row deliberately does not add (the retirements bring their own). +/// +/// So the seam moves up one level, where it is an ordinary argument. A case +/// supplies canned pages and drives the REAL walk: the same pagination, the same +/// `total_count` arithmetic, the same validator store on disk. Only the socket +/// is stubbed, which is the only part a fixture was ever stubbing. +pub type Transport<'a> = &'a dyn Fn(&str, Option<&str>) -> Option; + +/// The validator store's path for one URL. +/// +/// Keyed by a digest of the whole URL, query string included, because two pages +/// of one collection are two different readings and a key that collapsed them +/// would serve page 1's body for page 2. +fn validator_path(git_dir: &Path, url: &str) -> PathBuf { + git_dir + .join(VALIDATORS) + .join(crate::tools::digest(url.as_bytes())) +} + +/// One conditional GET, answering from the store on a `304`. +/// +/// Returns the body and the status. A `304` is answered from the cached body and +/// reported as `200`, because to this caller they are the same reading — which +/// is the whole point of sending the validator. +/// +/// **A `304` with no cached body is could-not-look, not an empty one.** The +/// store can be pruned between runs while the forge still holds the validator, +/// and reading that as an empty page would end the walk one page early and +/// report the prefix as whole. `land-divergence`'s own `conditional_get` +/// returned failure here for the same reason. +fn conditional_get(git_dir: &Path, path: &str, fetch: Transport<'_>) -> Option<(u16, String)> { + let stored = validator_path(git_dir, path); + let etag = std::fs::read_to_string(stored.join("etag")).ok(); + let answer = fetch(path, etag.as_deref().map(str::trim))?; + + if answer.status == 304 { + let cached = std::fs::read_to_string(stored.join("body")).ok()?; + return Some((200, cached)); + } + if !answer.is_reading() { + return Some((answer.status, String::new())); + } + // PERSIST BEFORE ANSWERING, and a failure to persist is not a failure to + // read: the store is an optimisation, so a read-only git directory costs a + // conditional request next time rather than the answer this time. + if let Some(validator) = answer.etag.as_deref() { + if std::fs::create_dir_all(&stored).is_ok() { + let _ = std::fs::write(stored.join("etag"), validator); + let _ = std::fs::write(stored.join("body"), &answer.body); + } + } + Some((answer.status, answer.body)) +} + +/// Read a paginated collection over a bounded window. +/// +/// `path` is API-relative and carries no leading slash, exactly as +/// [`crate::rest::get`] takes it, and no `page` parameter — this appends one per +/// lap. `params` is the rest of the query string, already URL-safe. +/// +/// # Why the shape is a parameter and the page budget is not optional +/// +/// The signature CLOUD-1712 sketched was `window(endpoint, params, max_pages)`. +/// [`Shape`] is the fourth because the alternative is a guess the caller already +/// knows the answer to — its own doc argues that. `max_pages` has no default +/// because every caller that took one took a DIFFERENT one, and a shared default +/// would silently re-truncate the program with the widest window. +/// +/// # Errors +/// +/// Never returns an error type: the two failure readings are +/// [`Window::Truncated`] and [`Window::CouldNotLook`], which are answers rather +/// than faults. A caller that wants to refuse on either must say so; a caller +/// that pattern-matches only `Whole` gets a compile error rather than a prefix. +#[must_use] +pub fn window( + git_dir: &Path, + path: &str, + params: &[(&str, &str)], + shape: Shape, + max_pages: u32, +) -> Window { + window_over(git_dir, path, params, shape, max_pages, &|path, etag| { + crate::rest::get(path, etag) + }) +} + +/// [`window`], over a caller-supplied [`Transport`]. +/// +/// The whole of the walk lives here; [`window`] is this with the live transport +/// bound. See [`Transport`] for why the seam is an argument. +#[must_use] +pub fn window_over( + git_dir: &Path, + path: &str, + params: &[(&str, &str)], + shape: Shape, + max_pages: u32, + fetch: Transport<'_>, +) -> Window { + // `git_dir` EXPLICITLY, as every other function in this module takes it. + // Resolving it from the process's cwd would make the validator store depend + // on where the caller happened to be standing, and would make this untestable + // without a chdir — which is shared mutable state across a parallel suite. + let query: String = params + .iter() + .map(|(key, value)| format!("&{key}={value}")) + .collect(); + // THE PAGE SIZE IS THE END-OF-COLLECTION SIGNAL where the endpoint reports + // no `total_count`: a page carrying fewer rows than were asked for is the + // last one. Read off the caller's own `per_page` rather than assumed, + // because the forge's default differs per endpoint — `timeout-drift`'s + // unpaginated `/jobs` call silently takes 30 — and a wrong constant here + // would end the walk early and report a prefix as whole. + let page_size = params + .iter() + .find(|(key, _)| *key == "per_page") + .and_then(|(_, value)| value.parse::().ok()); + + let mut rows: Vec = Vec::new(); + let mut total: Option = None; + let mut pages = 0_u32; + let mut ended = false; + + while pages < max_pages { + let page = pages + 1; + let url = format!("{path}?page={page}{query}"); + let Some((status, body)) = conditional_get(git_dir, &url, fetch) else { + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: None, + }; + }; + if status != 200 { + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: Some(status), + }; + } + let Ok(parsed) = serde_json::from_str::(&body) else { + // UNPARSEABLE IS COULD-NOT-LOOK. A body that is not JSON is not an + // empty collection, and `serde_json`'s error carries a fragment of + // the input, so it is dropped rather than reported (rule 4). + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: Some(status), + }; + }; + let batch = match shape { + Shape::Bare => parsed.as_array().cloned(), + Shape::Wrapped(key) => { + if let Some(count) = parsed + .get("total_count") + .and_then(serde_json::Value::as_u64) + { + total = Some(usize::try_from(count).unwrap_or(usize::MAX)); + } + parsed.get(key).and_then(|rows| rows.as_array()).cloned() + } + }; + let Some(batch) = batch else { + return Window::CouldNotLook { + endpoint: path.to_owned(), + status: Some(status), + }; + }; + pages = page; + // A SHORT OR EMPTY PAGE ENDS THE COLLECTION, which is the same evidence + // `land-divergence` recorded measuring its own window: page 10 came back + // full and page 11 empty. Empty alone is not enough — a walk that only + // stopped on an empty page would spend one request past every collection + // whose size divides evenly, and against a fixture or a rate limit that + // extra request is the difference between an answer and could-not-look. + let short = match page_size { + Some(size) => batch.len() < size, + None => batch.is_empty(), + }; + rows.extend(batch); + // `total_count` ends it too, and is checked FIRST where the endpoint + // states one: it is the forge's own answer about the collection, where + // a short page is an inference from the window. + if total.is_some_and(|count| rows.len() >= count) || short { + ended = true; + break; + } + } + + let reached_the_end = match total { + Some(count) => rows.len() >= count, + None => ended, + }; + if reached_the_end { + Window::Whole(rows) + } else { + Window::Truncated { + read: rows.len(), + total, + pages, + } + } +} diff --git a/crates/batten/src/main_watch.rs b/crates/batten/src/main_watch.rs index 6d2842b21..9a75cff4a 100644 --- a/crates/batten/src/main_watch.rs +++ b/crates/batten/src/main_watch.rs @@ -224,6 +224,7 @@ mod tests { fn ref_body(sha: &str) -> crate::rest::Answer { crate::rest::Answer { + headers: std::collections::BTreeMap::new(), status: 200, etag: Some(String::from("W/\"a\"")), poll_floor: None, @@ -234,6 +235,7 @@ mod tests { fn answer(status: u16, floor: Option, body: &str) -> crate::rest::Answer { crate::rest::Answer { + headers: std::collections::BTreeMap::new(), status, etag: Some(String::from("W/\"a\"")), poll_floor: floor, diff --git a/crates/batten/src/pr_watch.rs b/crates/batten/src/pr_watch.rs index bcd17c397..d74eeda78 100644 --- a/crates/batten/src/pr_watch.rs +++ b/crates/batten/src/pr_watch.rs @@ -817,6 +817,7 @@ mod tests { /// of a status, an `ETag` and a poll floor in this crate. fn answer(status: u16, etag: Option<&str>, body: &str) -> crate::rest::Answer { crate::rest::Answer { + headers: std::collections::BTreeMap::new(), status, etag: etag.map(str::to_owned), poll_floor: None, diff --git a/crates/batten/src/rest.rs b/crates/batten/src/rest.rs index 2eacc099f..9e37a285e 100644 --- a/crates/batten/src/rest.rs +++ b/crates/batten/src/rest.rs @@ -169,6 +169,21 @@ pub struct Answer { pub backoff: Option, /// The response body, as text. pub body: String, + /// Every header the response carried, keyed by LOWERCASE name. + /// + /// **The four typed fields above are readings this tier makes; this is the + /// rest of what it already read.** `fetch::Response` holds the whole block + /// and `canned` parses the whole block, so before this field the transport + /// was discarding headers it had in hand — which is why `gh-preflight` still + /// shelled out to `gh api -i` to read `X-Accepted-GitHub-Permissions` off a + /// 403. That is the second header parser this module's own header says the + /// typed boundary retired, standing again one endpoint over. + /// + /// Lowercase because `fetch::Response` lowercases every name it read, and + /// matching a mixed-case literal against that map finds nothing and reads as + /// *the header was absent* — the three-valued mistake CLOUD-390 records. + /// [`Answer::header`] folds the case so no caller has to remember. + pub headers: std::collections::BTreeMap, } impl Answer { @@ -219,6 +234,25 @@ impl Answer { pub const fn answered(&self) -> bool { self.status == 200 || self.status == 304 } + + /// One header by name, case-insensitively, or `None` where it was absent. + /// + /// **Works on a REFUSAL as well as a reading, and that is the point rather + /// than a side effect.** The caller this exists for reads + /// `X-Accepted-GitHub-Permissions` off a `403` to name the claim a token is + /// missing — a status [`Answer::is_reading`] excludes and + /// [`Answer::answered`] excludes too. Gating header access on either would + /// leave exactly the case that needs it unable to ask. + /// + /// `None` is *the response did not carry this header*. It is not + /// could-not-look: an [`Answer`] exists only where the exchange completed, + /// and the could-not-look channel is [`get`] returning `None`. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .get(&name.to_ascii_lowercase()) + .map(String::as_str) + } } /// One GET against the REST tier, or `None` where it could not be reached. @@ -341,8 +375,20 @@ fn canned(raw: &str, now: u64) -> Answer { (key.trim().eq_ignore_ascii_case(name)).then(|| value.trim().to_owned()) }) }; + // THE WHOLE BLOCK, lowercased, so a fixture answers `header` exactly as a + // live exchange does. A fixture that carried fewer headers than the wire + // would make the one caller reading an arbitrary header untestable offline, + // which is the shape of a gate that is only exercised in production. + let headers = lines + .clone() + .filter_map(|line| { + let (key, value) = line.split_once(':')?; + Some((key.trim().to_ascii_lowercase(), value.trim().to_owned())) + }) + .collect(); Answer { status, + headers, etag: header("etag"), poll_floor: header("x-poll-interval") .and_then(|raw| raw.trim().parse::().ok()) @@ -376,6 +422,13 @@ fn exchange(path: &str, etag: Option<&str>, body: Option<&[u8]>) -> Option, + body: &'static str, +} + +/// A transport that serves `pages` in order and records what it was asked. +/// +/// Records the validator sent with each request, because the conditional read +/// IS the economy this layer exists to take — a walk that stopped sending +/// `If-None-Match` would still pass every row-count assertion here. +struct Canned { + pages: Vec, + calls: std::cell::RefCell)>>, +} + +impl Canned { + fn new(pages: &[Page]) -> Self { + Self { + pages: pages.to_vec(), + calls: std::cell::RefCell::new(Vec::new()), + } + } + + fn answer(&self, path: &str, etag: Option<&str>) -> Option { + let mut calls = self.calls.borrow_mut(); + let page = self.pages.get(calls.len())?.clone(); + calls.push((path.to_owned(), etag.map(str::to_owned))); + let mut headers = std::collections::BTreeMap::new(); + if let Some(validator) = page.etag { + headers.insert(String::from("etag"), validator.to_owned()); + } + Some(batten::rest::Answer { + status: page.status, + etag: page.etag.map(str::to_owned), + poll_floor: None, + backoff: None, + body: page.body.to_owned(), + headers, + }) + } +} + +/// A scratch git directory for the validator store. +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("batten-forge-window-{name}")); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("git dir"); + dir +} + +/// Run one walk over canned pages. +fn windowed(git_dir: &Path, canned: &Canned, path: &str, shape: Shape, max_pages: u32) -> Window { + batten::forge::window_over( + git_dir, + path, + &[("per_page", "2")], + shape, + max_pages, + &|path, etag| canned.answer(path, etag), + ) +} + +#[test] +fn a_collection_larger_than_the_window_is_truncated() { + // THE DISCRIMINATING CASE (CLOUD-418). `total_count` says four; the window + // is one page of two. A reader that returned the two rows it read would be + // indistinguishable from one that read the whole collection, which is + // exactly `timeout-drift`'s defect: it trusts one unpaginated page and + // reports a percentile over a prefix as a percentile over a population. + let git = scratch("truncated"); + let canned = Canned::new(&[Page { + status: 200, + etag: Some("W/\"p1\""), + body: r#"{"total_count": 4, "check_runs": [{"id": 1}, {"id": 2}]}"#, + }]); + let answer = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 1, + ); + match answer { + Window::Truncated { read, total, pages } => { + assert_eq!(read, 2, "the truncation names how much it read"); + assert_eq!(total, Some(4), "and what the collection actually holds"); + assert_eq!(pages, 1, "and the budget it spent"); + } + other => panic!("a window short of `total_count` must be Truncated, got {other:?}"), + } +} + +#[test] +fn a_collection_the_window_reaches_the_end_of_is_whole() { + // THE ANTI-VACUITY MIRROR. Without it, a `window` that answered `Truncated` + // unconditionally would pass the case above — so this is what makes that one + // a discrimination rather than a restatement of the return type. + let git = scratch("whole"); + let canned = Canned::new(&[ + Page { + status: 200, + etag: Some("W/\"p1\""), + body: r#"{"total_count": 3, "check_runs": [{"id": 1}, {"id": 2}]}"#, + }, + Page { + status: 200, + etag: Some("W/\"p2\""), + body: r#"{"total_count": 3, "check_runs": [{"id": 3}]}"#, + }, + ]); + let answer = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 5, + ); + match answer { + Window::Whole(rows) => assert_eq!(rows.len(), 3, "every row, across both pages"), + other => panic!("a walk that reached `total_count` must be Whole, got {other:?}"), + } +} + +#[test] +fn a_refusal_is_could_not_look_rather_than_an_empty_collection() { + // A 403 carries an ERROR DOCUMENT, which parses to zero rows and is + // byte-identical on the decision surface to a genuinely empty collection. + // That is the false green `Answer::is_reading` records one layer down. + let git = scratch("refused"); + let canned = Canned::new(&[Page { + status: 403, + etag: None, + body: r#"{"message": "Resource not accessible"}"#, + }]); + let answer = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 3, + ); + match answer { + Window::CouldNotLook { endpoint, status } => { + assert_eq!(status, Some(403)); + assert_eq!( + endpoint, "repos/o/r/check-runs", + "the pointer is the endpoint" + ); + assert!( + !endpoint.contains("Resource"), + "rule 4: the forge's body never reaches the report" + ); + } + other => panic!("a 403 must be CouldNotLook, got {other:?}"), + } +} + +#[test] +fn a_cold_cache_fetches_and_a_304_reuses_the_stored_body() { + // THE ECONOMY, in both directions. The first walk populates the validator + // store; the second is answered `304` with no body, and must return the + // SAME rows rather than an empty page. `land-divergence`'s hand-rolled + // `conditional_get` returned failure on a 304 with no cached body for this + // reason, and the store is what makes the hit possible at all. + let git = scratch("conditional"); + let canned = Canned::new(&[ + Page { + status: 200, + etag: Some("W/\"v1\""), + body: r#"{"total_count": 1, "check_runs": [{"id": 7}]}"#, + }, + Page { + status: 304, + etag: Some("W/\"v1\""), + body: "", + }, + ]); + let cold = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 2, + ); + assert!( + matches!(&cold, Window::Whole(rows) if rows.len() == 1), + "{cold:?}" + ); + + let warm = windowed( + &git, + &canned, + "repos/o/r/check-runs", + Shape::Wrapped("check_runs"), + 2, + ); + match warm { + Window::Whole(rows) => assert_eq!( + rows.len(), + 1, + "a 304 must answer from the store, never as an empty page" + ), + other => panic!("a 304 with a stored body is a reading, got {other:?}"), + } + + let sent = canned.calls.borrow(); + assert_eq!(sent.len(), 2, "one request per walk"); + assert_eq!( + sent[0].1, None, + "the cold walk holds no validator and must send none" + ); + assert_eq!( + sent[1].1.as_deref(), + Some("W/\"v1\""), + "the warm walk must send the stored validator, or the economy is not taken" + ); +} + +#[test] +fn a_bare_array_endpoint_reads_without_a_wrapper_key() { + // The other body shape. `pulls` answers with the array itself and states no + // `total_count`, so the end of the collection is a short page — which is the + // evidence `merged-pr-keys` had and `land-divergence` did not. + let git = scratch("bare"); + let canned = Canned::new(&[Page { + status: 200, + etag: None, + body: r#"[{"number": 1}]"#, + }]); + let answer = windowed(&git, &canned, "repos/o/r/pulls", Shape::Bare, 3); + match answer { + Window::Whole(rows) => assert_eq!(rows.len(), 1), + other => panic!("a short bare page ends the collection, got {other:?}"), + } +} + +#[test] +fn a_full_last_page_with_no_total_is_truncated_rather_than_whole() { + // NO `total_count` AND THE BUDGET SPENT is the ambiguous case, and it + // resolves toward refusing. The collection may or may not continue; a reader + // that guessed "whole" would report a prefix as a population precisely when + // it has the least evidence. `merged-pr-keys` took the same direction + // against its `--limit`. + let git = scratch("bare-full"); + let canned = Canned::new(&[Page { + status: 200, + etag: None, + body: r#"[{"number": 1}, {"number": 2}]"#, + }]); + let answer = windowed(&git, &canned, "repos/o/r/pulls", Shape::Bare, 1); + match answer { + Window::Truncated { read, total, pages } => { + assert_eq!(read, 2); + assert_eq!(total, None, "the endpoint states no count"); + assert_eq!(pages, 1); + } + other => panic!("a full page at the budget with no count is Truncated, got {other:?}"), + } +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 07e8d7501..ae6563048 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -128,6 +128,7 @@ mod fixture_forks; mod fixture_repos; mod forced_push; mod forge_facts; +mod forge_window; mod fuzz_corpus; mod gh_guard; mod git_facts; diff --git a/crates/batten/tests/it/trunk_watch.rs b/crates/batten/tests/it/trunk_watch.rs index 26ca74f48..5f0f65dcb 100644 --- a/crates/batten/tests/it/trunk_watch.rs +++ b/crates/batten/tests/it/trunk_watch.rs @@ -65,6 +65,7 @@ const MOVED: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; /// A `200` carrying a ref object, and optionally a validator. fn ref_object(sha: &str, etag: Option<&str>) -> Answer { Answer { + headers: std::collections::BTreeMap::new(), status: 200, etag: etag.map(ToOwned::to_owned), poll_floor: None, @@ -76,6 +77,7 @@ fn ref_object(sha: &str, etag: Option<&str>) -> Answer { /// A `304`: no body, and the validator the server echoes back. fn unchanged(etag: Option<&str>) -> Answer { Answer { + headers: std::collections::BTreeMap::new(), status: 304, etag: etag.map(ToOwned::to_owned), poll_floor: None, From 0b682c63c14b2a29aaeefc0d5ccb8cfc4bf10827 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 14:37:29 +0000 Subject: [PATCH 10/32] feat(record): two store families a task can reach, reusing the journal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three programs — 841 lines — each hand-roll a durable keyed store under `.git/` while the engine ships both shapes with no door to either. `step-receipt` and `board-payloads` want keyed put/hit; `reclaim-census` is a single-shard journal with a boot id as the shard key, written by hand next to `journal.rs`. Each invented a naming scheme because there was no leaf. `record keyed` / `record journal` are the doors, and `record show` / `record fold` read them back. THE JOURNAL IS REUSED, NOT REIMPLEMENTED. `journal::append_line` is now the one append path in the crate and `journal::append` is a caller of it rather than a second copy, so the durability barrier, the one-writer shard rule and the persist-before-emit order are stated once. A second append-only store beside this one is precisely what the row forbids. A HALF-WRITTEN APPEND IS NOT A RECORD. `str::lines` yields an unterminated tail identically to a whole line, so a fold built on it counts a torn record — and `reclaim-census` classifies a boot from the KIND of the last record under it, which is exactly the value a torn tail corrupts. `fold_lines` reads termination off the bytes. `sync_all` is what makes the case decidable at all; `reclaim-census` reached for `sync -d` for the same reason. NOTHING IS NOT UNREADABLE. `Fold` carries `task::Reading`'s three answers, which the row names as the precedent: a fold over zero records is a real answer, and a store that could not be opened is could-not-look. Conflating them makes a fresh checkout look broken, or worse, a broken store look clean. A MISS IS EXIT 0 AND THE DISCRIMINATION IS ON STDOUT — `checks-green`'s shape. Exit 2 means VIOLATION under the engine's contract and a cache miss is not one; the shell corpus runs the inverse and that inversion is not carried across. THE COST CLASS IS NOT WIDENED, which is §2's question answered: `check` is `Cost::Read` and structurally cannot write, so a record reaches a read-classed surface exactly as `validator-verdict-clean`'s already does — a separate producer verb writes it and `verify` runs that verb before the gates. LEAVES UNDER `record`, never new nouns (CLOUD-1546's 42 top-level rows, CLOUD-1182's nine ports becoming nine nouns). The read leaves stay there too on `capture show`'s precedent: `record` is a store noun already `unclassified` because the subtree writes, so a read leaf under it does not leak onto the derived agent allowlist. A family or key that would escape its store is refused. Not a security boundary so much as a silent miss: the write succeeds outside, the read finds nothing, and the gate reads clean. Refs: CLOUD-1713, CLOUD-1032, CLOUD-1546 --- crates/batten/src/cli.rs | 38 ++++ crates/batten/src/journal.rs | 104 ++++++++++- crates/batten/src/record.rs | 167 +++++++++++++++++- crates/batten/src/surface.rs | 59 +++++++ crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/record_families.rs | 201 ++++++++++++++++++++++ 6 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 crates/batten/tests/it/record_families.rs diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 2db82089a..3040a659c 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -1346,6 +1346,30 @@ pub enum RecordCommand { /// The ref or sha the verdict was taken against. reference: String, }, + /// Put one value into a keyed store family (CLOUD-1713). + Keyed { + /// The store family the record belongs to. + family: String, + /// The key the record is filed under. + key: String, + }, + /// Append one record to an append-and-fold store family (CLOUD-1713). + Journal { + /// The store family the record belongs to. + family: String, + }, + /// Read one keyed record back: `hit` and the value, or `miss`. + Show { + /// The store family to read. + family: String, + /// The key to look under. + key: String, + }, + /// Fold a journal family: `nothing`, its records, or `unreadable `. + Fold { + /// The store family to fold. + family: String, + }, /// Record this branch's plan: ` ` per line, on stdin. /// /// No argument, for [`RecordCommand::Tool`]'s reason one layer over: the @@ -2356,6 +2380,20 @@ fn record_of(matches: &ArgMatches) -> Option { ("forge", matches) => Some(RecordCommand::Forge { reference: matches.get_one::("ref")?.clone(), }), + ("keyed", matches) => Some(RecordCommand::Keyed { + family: matches.get_one::("family")?.clone(), + key: matches.get_one::("key")?.clone(), + }), + ("journal", matches) => Some(RecordCommand::Journal { + family: matches.get_one::("family")?.clone(), + }), + ("show", matches) => Some(RecordCommand::Show { + family: matches.get_one::("family")?.clone(), + key: matches.get_one::("key")?.clone(), + }), + ("fold", matches) => Some(RecordCommand::Fold { + family: matches.get_one::("family")?.clone(), + }), // No positional to read: the branch is the key and the engine resolves // it, so this arm takes the sub-verb and nothing else. ("plan", _) => Some(RecordCommand::Plan), diff --git a/crates/batten/src/journal.rs b/crates/batten/src/journal.rs index 4f4f7a9a9..117579291 100644 --- a/crates/batten/src/journal.rs +++ b/crates/batten/src/journal.rs @@ -361,6 +361,30 @@ impl Origin { /// /// Returns an error when the shard cannot be created, written, or synced. pub fn append(store_dir: &Path, shard: &str, entry: &Entry) -> Result<()> { + append_line(store_dir, shard, &serde_json::to_string(entry)?) +} + +/// Append one already-encoded line to a shard, durably. +/// +/// **The one append path in this crate, and [`append`] is now a caller of it +/// rather than a second copy** (CLOUD-1713). Three shell programs hand-rolled a +/// durable append-only store under `.git/` while this machinery sat here with no +/// door — `reclaim-census` being a single-shard journal with a boot id as the +/// shard key, written by hand next to this file. Generalising the line write is +/// what opens the door without minting a second store, which is exactly what that +/// row forbids. +/// +/// Persist-before-emit, and the `sync_all` is the half that makes the +/// discriminating case decidable: without it a caller can emit having written a +/// record the next boot cannot read. `reclaim-census` reached for `sync -d` for +/// this reason; the durability barrier moves here so no caller has to remember. +/// +/// No lock, for [`append`]'s reason: a shard has exactly one writer. +/// +/// # Errors +/// +/// Returns an error when the shard cannot be created, written, or synced. +pub fn append_line(store_dir: &Path, shard: &str, line: &str) -> Result<()> { let dir = shards_dir(store_dir); std::fs::create_dir_all(&dir) .with_context(|| format!("create the shard directory {}", dir.display()))?; @@ -370,13 +394,91 @@ pub fn append(store_dir: &Path, shard: &str, entry: &Entry) -> Result<()> { .append(true) .open(&path) .with_context(|| format!("open the shard {}", path.display()))?; - let line = serde_json::to_string(entry)?; writeln!(file, "{line}").with_context(|| format!("append to the shard {}", path.display()))?; file.sync_all() .with_context(|| format!("sync the shard {}", path.display()))?; Ok(()) } +/// What folding every shard of a generic journal found. +/// +/// **`task::Reading`'s three answers, and that type is the stated precedent +/// rather than a coincidence** (CLOUD-1713 §7). A fold over zero records is +/// *nothing* — a real answer — and is never the same claim as being unable to +/// read the store. Collapsing them is the dead-gate shape this whole crate is +/// written against: an unreadable store reported as "no records" reads clean. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fold { + /// No shard holds a whole record. Nothing has been appended, or every line + /// present was torn. A real answer. + Nothing, + /// Every whole record, shard-sorted then in append order. + Records(Vec), + /// The store is there and cannot be read. Could-not-look. + Unreadable(PathBuf), +} + +/// Fold every shard of a generic journal into its whole records. +/// +/// **A HALF-WRITTEN APPEND IS NOT A RECORD**, which is the discriminating case +/// (CLOUD-1032). A line counts only if it was terminated: the file's final +/// fragment is dropped unless the file ends in a newline, because a process that +/// died mid-`write` leaves exactly that. [`read_shards`] drops a torn trailing +/// line by failing to parse it as an [`Entry`]; a generic record has no schema to +/// fail against, so the termination has to be read off the bytes instead. +/// +/// Shard order is lexical, for [`read_shards`]'s reason: a fold must be a pure +/// function of the shard contents and never of `read_dir` order (§6). +/// +/// # Errors +/// +/// Never returns an error: an unreadable store is [`Fold::Unreadable`], which is +/// an answer rather than a fault, and a caller that pattern-matches only +/// [`Fold::Records`] gets a compile error rather than a silent empty list. +#[must_use] +pub fn fold_lines(store_dir: &Path) -> Fold { + let dir = shards_dir(store_dir); + let Ok(entries) = std::fs::read_dir(&dir) else { + // ABSENT IS NOTHING, not unreadable: a store nobody has appended to has + // no directory, and that is a genuine "no records" rather than a failure + // to look. A store that exists and cannot be listed is the other arm. + return if dir.exists() { + Fold::Unreadable(dir) + } else { + Fold::Nothing + }; + }; + let mut paths: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .collect(); + paths.sort(); + + let mut all = Vec::new(); + for path in paths { + let Ok(text) = std::fs::read_to_string(&path) else { + return Fold::Unreadable(path); + }; + // Only terminated lines. `split('\n')` then dropping the tail is what + // distinguishes a whole final record from a torn one; `lines()` cannot, + // because it yields an unterminated tail identically to a terminated one. + let mut parts: Vec<&str> = text.split('\n').collect(); + let _torn_or_empty = parts.pop(); + all.extend( + parts + .iter() + .filter(|line| !line.is_empty()) + .map(|line| (*line).to_owned()), + ); + } + if all.is_empty() { + Fold::Nothing + } else { + Fold::Records(all) + } +} + /// The shard id for this process in this worktree. /// /// Per **worktree**, not per process: a shard per process would mint a file per diff --git a/crates/batten/src/record.rs b/crates/batten/src/record.rs index eff7efc05..f4a6777b1 100644 --- a/crates/batten/src/record.rs +++ b/crates/batten/src/record.rs @@ -54,7 +54,7 @@ //! in this family for a secret to appear. use std::io::Read as _; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{Context as _, Result}; @@ -201,6 +201,10 @@ pub fn run(command: crate::cli::RecordCommand, overrides: &Overrides) -> Result< crate::cli::RecordCommand::Forge { reference } => run_forge(&reference, overrides), crate::cli::RecordCommand::Plan => run_plan(), crate::cli::RecordCommand::Closes => run_closes(overrides), + crate::cli::RecordCommand::Keyed { family, key } => run_keyed(&family, &key), + crate::cli::RecordCommand::Journal { family } => run_journal(&family), + crate::cli::RecordCommand::Show { family, key } => run_keyed_show(&family, &key), + crate::cli::RecordCommand::Fold { family } => run_journal_show(&family), } } @@ -372,3 +376,164 @@ pub fn run_plan() -> Result { )?; Ok(ExitCode::Success) } + +// --- the two store families a task can reach (CLOUD-1713) -------------------- +// +// Three programs (841 lines) each hand-rolled a durable keyed store under +// `.git/` while the engine shipped both shapes with no door to either. These are +// the doors: `keyed` is put/hit, `journal` is append-and-fold. +// +// THE COST CLASS IS NOT WIDENED, and this is the answer §2 asks for in one +// sentence: `check` is `Cost::Read` and structurally cannot spawn or write, so a +// record reaches a read-classed surface exactly as `validator-verdict-clean`'s +// already does — a SEPARATE PRODUCER VERB writes it, and `verify` runs that verb +// before the gates. The producer here is `record keyed` / `record journal` +// (`Effect::Write`); the consumer is `show record` / `show journal` +// (`Effect::Read`) or a fact. Nothing on the read path writes. + +/// Where the keyed put/hit family stores its records. +const KEYED_STORE: &str = "batten-records"; + +/// Where the append-and-fold family stores its shards. +const JOURNAL_STORE: &str = "batten-journals"; + +/// A family name that cannot escape its store. +/// +/// **A path component, checked rather than trusted.** The family and the key both +/// reach this from a caller's argv, and a `..` or a `/` in either would put a +/// record outside the store the reader looks in — which is not a security +/// boundary here so much as a silent miss: the write succeeds, the read finds +/// nothing, and the gate reads clean. +fn safe_component(what: &str, value: &str) -> Result { + let clean = value.trim(); + if clean.is_empty() + || clean == "." + || clean == ".." + || clean.contains('/') + || clean.contains('\\') + || clean.contains('\0') + { + return Err(UsageError::raise(format!( + "the {what} must be one path component and must not be `.`, `..`, or contain a separator" + ))); + } + Ok(clean.to_owned()) +} + +/// The record path for one (family, key) pair. +/// +/// Keyed by a digest of the key rather than by the key itself, on +/// [`crate::review::record_path`]'s reason one store over: the key is a caller's +/// string and may be any length or hold any byte, and a digest is a filename on +/// every platform. The key is not recoverable from the path, which is the +/// pointer-only posture rule 4 asks for anyway. +fn keyed_path(git_dir: &Path, family: &str, key: &str) -> PathBuf { + git_dir + .join(KEYED_STORE) + .join(family) + .join(crate::tools::digest(key.as_bytes())) +} + +/// Put one value into the keyed family, read from stdin. +/// +/// # Errors +/// +/// A [`UsageError`] when the family or key is not a single path component; an +/// internal error when the store cannot be written. +pub fn run_keyed(family: &str, key: &str) -> Result { + let family = safe_component("family", family)?; + let value = verdict_lines()?; + let git_dir = git::git_dir(Path::new("."))?; + store(&keyed_path(&git_dir, &family, key), &value)?; + Ok(ExitCode::Success) +} + +/// Append one record to the journal family, read from stdin. +/// +/// **Reuses [`crate::journal::append_line`] rather than opening a file here**, +/// which is CLOUD-1713's §2 in one call: the durability barrier, the one-writer +/// shard rule and the persist-before-emit order all live in that function, and a +/// second append path beside it is precisely the defect this row removes. +/// +/// The shard is per worktree, via [`crate::journal::shard_id`] — `reclaim-census` +/// keyed its single shard by boot id instead, which is a caller's choice of key +/// rather than a different mechanism. +/// +/// # Errors +/// +/// A [`UsageError`] when the family is not a single path component or the record +/// is blank — an empty append is a caller with nothing to say, and recording it +/// would put a record in the log that no fold can distinguish from a torn one. +/// An internal error when the shard cannot be written or synced. +pub fn run_journal(family: &str) -> Result { + let family = safe_component("family", family)?; + let record = verdict_lines()?; + let record = record.trim(); + if record.is_empty() { + return Err(UsageError::raise(String::from( + "the record is empty; an empty append is not a record", + ))); + } + if record.contains('\n') { + return Err(UsageError::raise(String::from( + "a record is one line; a multi-line append would fold back as several records", + ))); + } + let git_dir = git::git_dir(Path::new("."))?; + let store_dir = git_dir.join(JOURNAL_STORE).join(&family); + let shard = crate::journal::shard_id(Path::new(".")); + crate::journal::append_line(&store_dir, &shard, record)?; + Ok(ExitCode::Success) +} + +/// Read one keyed record back: `hit` and the value, or `miss`. +/// +/// **A miss is exit 0 and the discrimination comes off STDOUT**, which is +/// `checks-green`'s shape and is deliberate. The engine's contract makes exit 2 a +/// VIOLATION, and a cache miss is not a violation — it is the ordinary answer that +/// says *run the step*. A caller reading only the code holds either way; the one +/// caller that needs the difference reads the line. +/// +/// # Errors +/// +/// A [`UsageError`] when the family or key is not a single path component; an +/// internal error when the git directory cannot be resolved. +pub fn run_keyed_show(family: &str, key: &str) -> Result { + let family = safe_component("family", family)?; + let git_dir = git::git_dir(Path::new("."))?; + match std::fs::read_to_string(keyed_path(&git_dir, &family, key)) { + Ok(value) => { + println!("hit"); + print!("{value}"); + } + // ABSENT IS A MISS, and it is the only reading here: a record that exists + // and holds nothing is a hit carrying an empty value, because the producer + // chose to record that. + Err(_) => println!("miss"), + } + Ok(ExitCode::Success) +} + +/// Fold a journal family: `nothing`, the records, or `unreadable `. +/// +/// # Errors +/// +/// A [`UsageError`] when the family is not a single path component; an internal +/// error when the git directory cannot be resolved. +pub fn run_journal_show(family: &str) -> Result { + let family = safe_component("family", family)?; + let git_dir = git::git_dir(Path::new("."))?; + let store_dir = git_dir.join(JOURNAL_STORE).join(&family); + match crate::journal::fold_lines(&store_dir) { + crate::journal::Fold::Nothing => println!("nothing"), + crate::journal::Fold::Records(records) => { + for record in records { + println!("{record}"); + } + } + // A PATH IS A POINTER (§6 names `path:line` outright), so naming the + // store a reader could not open is rule 4 satisfied rather than breached. + crate::journal::Fold::Unreadable(path) => println!("unreadable {}", path.display()), + } + Ok(ExitCode::Success) +} diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 5c3680528..57659bd97 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -4710,6 +4710,65 @@ pub const SURFACE: &[CommandDecl] = &[ // No positional: the branch is the key and the engine resolves it, so a // caller cannot record against a branch it is not on — `record tool`'s // anti-staleness argument, applied to a different key. + // CLOUD-1713's two doors. Three programs (841 lines) hand-rolled a durable + // store under `.git/` because the engine's own two shapes — keyed put/hit and + // append-and-fold — had no leaf. LEAVES UNDER `record` rather than new nouns: + // CLOUD-1546 counts 42 top-level rows and CLOUD-1182 records nine ports + // becoming nine nouns, and a store family is an object this verb records, not + // a verb of its own. + CommandDecl { + path: "record keyed", + id: "record.keyed", + about: "Put one value into a keyed store family, read from stdin", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Write, + flags: &[ + FlagDecl::positional("family", "The store family the record belongs to"), + FlagDecl::positional("key", "The key the record is filed under"), + ], + }, + CommandDecl { + path: "record journal", + id: "record.journal", + about: "Append one record to an append-and-fold store family, read from stdin", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Write, + flags: &[FlagDecl::positional( + "family", + "The store family the record belongs to", + )], + }, + // The READ half of CLOUD-1713's two families, and they stay under `record` + // on `capture show`'s precedent: `record` is a store NOUN and is already + // `unclassified` because the subtree writes, so a read leaf under it neither + // leaks onto the derived agent allowlist nor needs a noun of its own. The + // producer/consumer split is still §2's answer to "how does a + // read-classed surface obtain a record without gaining a write" — the producer + // is a separate verb, exactly as `record tool` already is for + // `validator-verdict-clean`. + CommandDecl { + path: "record show", + id: "record.show", + about: "Read one keyed record back: `hit` and the value, or `miss`", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[ + FlagDecl::positional("family", "The store family to read"), + FlagDecl::positional("key", "The key to look under"), + ], + }, + CommandDecl { + path: "record fold", + id: "record.fold", + about: "Fold a journal family: `nothing`, its records, or `unreadable `", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[FlagDecl::positional("family", "The store family to fold")], + }, CommandDecl { path: "record plan", id: "record.plan", diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index ae6563048..14b975ae0 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -217,6 +217,7 @@ mod rebase; mod receipt_verified; mod reclaim_report_once; mod record_closes; +mod record_families; mod redirect_resolves; mod reference_coverage; mod refusal_ceiling; diff --git a/crates/batten/tests/it/record_families.rs b/crates/batten/tests/it/record_families.rs new file mode 100644 index 000000000..9af6ab0e5 --- /dev/null +++ b/crates/batten/tests/it/record_families.rs @@ -0,0 +1,201 @@ +//! `record keyed` / `record journal`, over the compiled binary (CLOUD-1713). +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! Nothing is retired by this tier yet: this row builds the two doors and the +//! three retirements that walk through them are separate rows. The arms land +//! with them. +//! +//! # Why these three cases +//! +//! They are the ones the hand-rolled stores got RIGHT and a naive store gets +//! wrong. A store that merely reads back what it wrote passes a happy path and +//! still loses every distinction that made the shell versions correct: +//! `reclaim-census` reached for `sync -d` because a half-written append is not a +//! record, and `task::Reading` already separates "nothing" from "unreadable" +//! because a store that conflates them reports clean over one it could not open. +//! +//! Over the compiled binary rather than the library, because the exit contract is +//! half the claim: a MISS must be exit 0 with the discrimination on stdout, since +//! the engine's exit 2 means VIOLATION and a cache miss is not one. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::Path; + +use common::{git_in, run, run_with_stdin, scratch, stdout, write}; + +/// A repository with a git directory for the stores to live under. +fn repo(name: &str) -> std::path::PathBuf { + let dir = scratch(&format!("record-families-{name}")); + write(&dir, "seed.txt", "seed\n"); + git_in(&dir, &["init", "-q", "-b", "main", "."]); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-qm", "seed"]); + dir +} + +fn shard_dir(dir: &Path, family: &str) -> std::path::PathBuf { + dir.join(".git") + .join("batten-journals") + .join(family) + .join("journal") + .join("shards") +} + +#[test] +fn a_hit_returns_the_stored_value_and_a_miss_says_miss() { + // THE DISCRIMINATING PAIR. A store that answered an empty string for both + // would pass any assertion about the hit alone, and `step-receipt`'s whole + // decision is "run the step or skip it" — so a miss that reads as an empty + // hit skips a step nothing has verified. + let dir = repo("hit-miss"); + + let miss = run(&dir, &["record", "show", "steps", "some-key"]); + assert_eq!(miss.status.code(), Some(0), "a miss is not a violation"); + assert_eq!( + stdout(&miss).trim(), + "miss", + "an absent record must SAY miss rather than return empty" + ); + + let put = run_with_stdin( + &dir, + &["record", "keyed", "steps", "some-key"], + "verdict-token\n", + ); + assert_eq!(put.status.code(), Some(0), "the put succeeds"); + + let hit = run(&dir, &["record", "show", "steps", "some-key"]); + assert_eq!(hit.status.code(), Some(0)); + let answer = stdout(&hit); + assert!( + answer.starts_with("hit"), + "a present record reads as a hit\n{answer}" + ); + assert!( + answer.contains("verdict-token"), + "and carries the stored value back\n{answer}" + ); +} + +#[test] +fn a_record_under_another_key_does_not_answer() { + // THE ANTI-STALENESS CASE, and it is why the store is KEYED rather than + // compared: a record from other inputs lives under a name nothing opens, so + // staleness cannot be a comparison a caller forgets to make. + let dir = repo("keying"); + run_with_stdin(&dir, &["record", "keyed", "steps", "key-a"], "answer-a\n"); + + let other = run(&dir, &["record", "show", "steps", "key-b"]); + assert_eq!( + stdout(&other).trim(), + "miss", + "a record under another key must not answer" + ); +} + +#[test] +fn a_half_written_append_is_not_a_record() { + // THE DISCRIMINATING CASE (CLOUD-1032). A process killed mid-`write` leaves a + // line with no terminator. `str::lines` yields it identically to a whole one, + // so a fold built on `lines()` counts a torn record as a record — and + // `reclaim-census` classifies a boot from the KIND of the last record under + // it, which is precisely the value a torn tail corrupts. + let dir = repo("torn"); + run_with_stdin(&dir, &["record", "journal", "census"], "h 1000 boot-a\n"); + + // Append a torn tail the way a crash would: no trailing newline. + let shards = shard_dir(&dir, "census"); + let shard = std::fs::read_dir(&shards) + .expect("the shard directory exists once something was appended") + .flatten() + .map(|entry| entry.path()) + .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .expect("one shard"); + let mut text = std::fs::read_to_string(&shard).expect("readable shard"); + text.push_str("x 1001 boot-a"); + std::fs::write(&shard, &text).expect("writable shard"); + + let folded = run(&dir, &["record", "fold", "census"]); + assert_eq!(folded.status.code(), Some(0)); + let answer = stdout(&folded); + assert!( + answer.contains("h 1000 boot-a"), + "the terminated record still folds\n{answer}" + ); + assert!( + !answer.contains("x 1001"), + "an unterminated tail is NOT a record\n{answer}" + ); +} + +#[test] +fn a_fold_over_zero_records_is_nothing_rather_than_unreadable() { + // `task::Reading`'s distinction, which is the stated precedent. A store + // nobody has appended to has no shards, and that is a real answer — reporting + // it as unreadable would make every fresh checkout look like a broken one, + // and reporting an unreadable store as "no records" reads clean over a store + // that could not be opened. + let dir = repo("empty"); + let folded = run(&dir, &["record", "fold", "census"]); + assert_eq!( + folded.status.code(), + Some(0), + "an empty fold is not a failure" + ); + assert_eq!( + stdout(&folded).trim(), + "nothing", + "zero records is `nothing`, never `unreadable`" + ); +} + +#[test] +fn every_appended_record_folds_back_in_order() { + // THE ANTI-VACUITY MIRROR for the torn case: a fold that dropped every line + // would also pass `a_half_written_append_is_not_a_record`. + let dir = repo("fold-all"); + for record in ["h 1000 boot-a", "h 1001 boot-a", "x 1002 boot-a"] { + let appended = run_with_stdin( + &dir, + &["record", "journal", "census"], + &format!("{record}\n"), + ); + assert_eq!(appended.status.code(), Some(0), "{record}"); + } + let answer = stdout(&run(&dir, &["record", "fold", "census"])); + let folded: Vec<&str> = answer.lines().collect(); + assert_eq!( + folded, + vec!["h 1000 boot-a", "h 1001 boot-a", "x 1002 boot-a"], + "append order is the fold order within a shard" + ); +} + +#[test] +fn an_empty_append_is_refused_as_usage() { + // An empty append would put a record in the log that no fold can tell from a + // torn one, so the writer refuses rather than the reader guessing. USAGE (1), + // not violation (2): the caller made a mistake, this is not a policy verdict. + let dir = repo("empty-append"); + let refused = run_with_stdin(&dir, &["record", "journal", "census"], ""); + assert_eq!( + refused.status.code(), + Some(1), + "an empty append is a usage error, never a violation" + ); +} + +#[test] +fn a_family_that_would_escape_its_store_is_refused() { + // Not a security boundary so much as a silent miss: a `..` family writes + // outside the store the reader looks in, so the write succeeds, the read finds + // nothing, and the gate reads clean. + let dir = repo("escape"); + let refused = run_with_stdin(&dir, &["record", "keyed", "../escape", "k"], "v\n"); + assert_eq!(refused.status.code(), Some(1)); +} From 6cef416c393cd06a02056963dca89b86e6fa3903 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 14:39:03 +0000 Subject: [PATCH 11/32] chore(surface): regenerate the derived artifacts for the record leaves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completions, man pages and the JSON Schema golden are derived from the command spec, so the four leaves CLOUD-1713 added move them. Generated by `mise run completions|man|schema|snapshots`, never by hand — the compiled binary's own output is authoritative (§1) and these files are its projection. Refs: CLOUD-1713 --- completions/batten.bash | 274 +++++++++++++++++- completions/batten.fish | 134 +++++++-- completions/batten.zsh | 226 +++++++++++++++ .../it__snapshots__golden_json_schema.snap | 96 ++++++ man/batten-record-fold.1 | 16 + man/batten-record-journal.1 | 16 + man/batten-record-keyed.1 | 19 ++ man/batten-record-show.1 | 19 ++ man/batten-record.1 | 12 + 9 files changed, 790 insertions(+), 22 deletions(-) create mode 100644 man/batten-record-fold.1 create mode 100644 man/batten-record-journal.1 create mode 100644 man/batten-record-keyed.1 create mode 100644 man/batten-record-show.1 diff --git a/completions/batten.bash b/completions/batten.bash index e28538149..8a03a9401 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -751,12 +751,24 @@ _batten() { batten__subcmd__help__subcmd__record,closes) cmd="batten__subcmd__help__subcmd__record__subcmd__closes" ;; + batten__subcmd__help__subcmd__record,fold) + cmd="batten__subcmd__help__subcmd__record__subcmd__fold" + ;; batten__subcmd__help__subcmd__record,forge) cmd="batten__subcmd__help__subcmd__record__subcmd__forge" ;; + batten__subcmd__help__subcmd__record,journal) + cmd="batten__subcmd__help__subcmd__record__subcmd__journal" + ;; + batten__subcmd__help__subcmd__record,keyed) + cmd="batten__subcmd__help__subcmd__record__subcmd__keyed" + ;; batten__subcmd__help__subcmd__record,plan) cmd="batten__subcmd__help__subcmd__record__subcmd__plan" ;; + batten__subcmd__help__subcmd__record,show) + cmd="batten__subcmd__help__subcmd__record__subcmd__show" + ;; batten__subcmd__help__subcmd__record,tool) cmd="batten__subcmd__help__subcmd__record__subcmd__tool" ;; @@ -1216,30 +1228,54 @@ _batten() { batten__subcmd__record,closes) cmd="batten__subcmd__record__subcmd__closes" ;; + batten__subcmd__record,fold) + cmd="batten__subcmd__record__subcmd__fold" + ;; batten__subcmd__record,forge) cmd="batten__subcmd__record__subcmd__forge" ;; batten__subcmd__record,help) cmd="batten__subcmd__record__subcmd__help" ;; + batten__subcmd__record,journal) + cmd="batten__subcmd__record__subcmd__journal" + ;; + batten__subcmd__record,keyed) + cmd="batten__subcmd__record__subcmd__keyed" + ;; batten__subcmd__record,plan) cmd="batten__subcmd__record__subcmd__plan" ;; + batten__subcmd__record,show) + cmd="batten__subcmd__record__subcmd__show" + ;; batten__subcmd__record,tool) cmd="batten__subcmd__record__subcmd__tool" ;; batten__subcmd__record__subcmd__help,closes) cmd="batten__subcmd__record__subcmd__help__subcmd__closes" ;; + batten__subcmd__record__subcmd__help,fold) + cmd="batten__subcmd__record__subcmd__help__subcmd__fold" + ;; batten__subcmd__record__subcmd__help,forge) cmd="batten__subcmd__record__subcmd__help__subcmd__forge" ;; batten__subcmd__record__subcmd__help,help) cmd="batten__subcmd__record__subcmd__help__subcmd__help" ;; + batten__subcmd__record__subcmd__help,journal) + cmd="batten__subcmd__record__subcmd__help__subcmd__journal" + ;; + batten__subcmd__record__subcmd__help,keyed) + cmd="batten__subcmd__record__subcmd__help__subcmd__keyed" + ;; batten__subcmd__record__subcmd__help,plan) cmd="batten__subcmd__record__subcmd__help__subcmd__plan" ;; + batten__subcmd__record__subcmd__help,show) + cmd="batten__subcmd__record__subcmd__help__subcmd__show" + ;; batten__subcmd__record__subcmd__help,tool) cmd="batten__subcmd__record__subcmd__help__subcmd__tool" ;; @@ -5146,7 +5182,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__record) - opts="tool forge plan closes" + opts="tool forge keyed journal show fold plan closes" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5173,6 +5209,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__fold) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__forge) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5187,6 +5237,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__journal) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__help__subcmd__record__subcmd__keyed) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__plan) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -5201,6 +5279,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__show) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -8894,7 +8986,7 @@ _batten() { return 0 ;; batten__subcmd__record) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge plan closes help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge keyed journal show fold plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -8953,6 +9045,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__fold) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__forge) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -8984,7 +9106,7 @@ _batten() { return 0 ;; batten__subcmd__record__subcmd__help) - opts="tool forge plan closes help" + opts="tool forge keyed journal show fold plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -9011,6 +9133,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__fold) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__forge) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9039,6 +9175,34 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__journal) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__record__subcmd__help__subcmd__keyed) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__plan) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9053,6 +9217,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__show) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__tool) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9067,6 +9245,66 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__journal) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; + batten__subcmd__record__subcmd__keyed) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__plan) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -9097,6 +9335,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__show) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__tool) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 122b06e7c..fe7b61b0e 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2915,32 +2915,36 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "settle" -d 'Record what was decided about a stored finding' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge plan closes help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from tool" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2983,6 +2987,90 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from journal" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from show" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from fold" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from plan" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3027,6 +3115,10 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from closes" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' @@ -3711,6 +3803,10 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from show" -f -a "agent" -d 'What an agent may do in this repository: the read-only verbs, the exit contract, and the declared gates' diff --git a/completions/batten.zsh b/completions/batten.zsh index d4a9f9b04..a6fa8dcc2 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -5006,6 +5006,128 @@ trace\:"Add everything"))' \ ':ref -- The ref or sha the verdict was taken against:_default' \ && ret=0 ;; +(keyed) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family the record belongs to:_default' \ +':key -- The key the record is filed under:_default' \ +&& ret=0 +;; +(journal) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family the record belongs to:_default' \ +&& ret=0 +;; +(show) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family to read:_default' \ +':key -- The key to look under:_default' \ +&& ret=0 +;; +(fold) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The store family to fold:_default' \ +&& ret=0 +;; (plan) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -5084,6 +5206,22 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(keyed) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(journal) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(show) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(fold) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (plan) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -6871,6 +7009,22 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(keyed) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(journal) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(show) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(fold) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (plan) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -8371,6 +8525,10 @@ _batten__subcmd__help__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'keyed:Put one value into a keyed store family, read from stdin' \ +'journal:Append one record to an append-and-fold store family, read from stdin' \ +'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ +'fold:Fold a journal family\: \`nothing\`, its records, or \`unreadable \`' \ 'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'closes:Record which rows this branch'\''s pull request body closes, read on stdin' \ ) @@ -8381,16 +8539,36 @@ _batten__subcmd__help__subcmd__record__subcmd__closes_commands() { local commands; commands=() _describe -t commands 'batten help record closes commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__fold_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__fold_commands() { + local commands; commands=() + _describe -t commands 'batten help record fold commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__forge_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__forge_commands() { local commands; commands=() _describe -t commands 'batten help record forge commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__journal_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__journal_commands() { + local commands; commands=() + _describe -t commands 'batten help record journal commands' commands "$@" +} +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__keyed_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__keyed_commands() { + local commands; commands=() + _describe -t commands 'batten help record keyed commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__plan_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__plan_commands() { local commands; commands=() _describe -t commands 'batten help record plan commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__show_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'batten help record show commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__tool_commands() { local commands; commands=() @@ -9475,6 +9653,10 @@ _batten__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'keyed:Put one value into a keyed store family, read from stdin' \ +'journal:Append one record to an append-and-fold store family, read from stdin' \ +'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ +'fold:Fold a journal family\: \`nothing\`, its records, or \`unreadable \`' \ 'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'closes:Record which rows this branch'\''s pull request body closes, read on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -9486,6 +9668,11 @@ _batten__subcmd__record__subcmd__closes_commands() { local commands; commands=() _describe -t commands 'batten record closes commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__fold_commands] )) || +_batten__subcmd__record__subcmd__fold_commands() { + local commands; commands=() + _describe -t commands 'batten record fold commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__forge_commands] )) || _batten__subcmd__record__subcmd__forge_commands() { local commands; commands=() @@ -9496,6 +9683,10 @@ _batten__subcmd__record__subcmd__help_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'keyed:Put one value into a keyed store family, read from stdin' \ +'journal:Append one record to an append-and-fold store family, read from stdin' \ +'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ +'fold:Fold a journal family\: \`nothing\`, its records, or \`unreadable \`' \ 'plan:Record this branch'\''s plan, read as \` \` lines on stdin' \ 'closes:Record which rows this branch'\''s pull request body closes, read on stdin' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -9507,6 +9698,11 @@ _batten__subcmd__record__subcmd__help__subcmd__closes_commands() { local commands; commands=() _describe -t commands 'batten record help closes commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__fold_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__fold_commands() { + local commands; commands=() + _describe -t commands 'batten record help fold commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__forge_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__forge_commands() { local commands; commands=() @@ -9517,21 +9713,51 @@ _batten__subcmd__record__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten record help help commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__journal_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__journal_commands() { + local commands; commands=() + _describe -t commands 'batten record help journal commands' commands "$@" +} +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__keyed_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__keyed_commands() { + local commands; commands=() + _describe -t commands 'batten record help keyed commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__plan_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__plan_commands() { local commands; commands=() _describe -t commands 'batten record help plan commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__show_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'batten record help show commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__tool_commands() { local commands; commands=() _describe -t commands 'batten record help tool commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__journal_commands] )) || +_batten__subcmd__record__subcmd__journal_commands() { + local commands; commands=() + _describe -t commands 'batten record journal commands' commands "$@" +} +(( $+functions[_batten__subcmd__record__subcmd__keyed_commands] )) || +_batten__subcmd__record__subcmd__keyed_commands() { + local commands; commands=() + _describe -t commands 'batten record keyed commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__plan_commands] )) || _batten__subcmd__record__subcmd__plan_commands() { local commands; commands=() _describe -t commands 'batten record plan commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__show_commands] )) || +_batten__subcmd__record__subcmd__show_commands() { + local commands; commands=() + _describe -t commands 'batten record show commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__tool_commands] )) || _batten__subcmd__record__subcmd__tool_commands() { local commands; commands=() diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index d085ee2b1..426c0c06a 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -2394,6 +2394,24 @@ expression: stdout_of(&output) "flags": [], "subcommands": [] }, + { + "path": "record fold", + "id": "record.fold", + "about": "Fold a journal family: `nothing`, its records, or `unreadable `", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family to fold" + } + ], + "subcommands": [] + }, { "path": "record forge", "id": "record.forge", @@ -2412,6 +2430,50 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "record journal", + "id": "record.journal", + "about": "Append one record to an append-and-fold store family, read from stdin", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family the record belongs to" + } + ], + "subcommands": [] + }, + { + "path": "record keyed", + "id": "record.keyed", + "about": "Put one value into a keyed store family, read from stdin", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family the record belongs to" + }, + { + "name": "key", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The key the record is filed under" + } + ], + "subcommands": [] + }, { "path": "record plan", "id": "record.plan", @@ -2421,6 +2483,32 @@ expression: stdout_of(&output) "flags": [], "subcommands": [] }, + { + "path": "record show", + "id": "record.show", + "about": "Read one keyed record back: `hit` and the value, or `miss`", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The store family to read" + }, + { + "name": "key", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The key to look under" + } + ], + "subcommands": [] + }, { "path": "record tool", "id": "record.tool", @@ -3219,6 +3307,14 @@ expression: stdout_of(&output) "id": "receipt.verified", "path": "receipt verified" }, + { + "id": "record.fold", + "path": "record fold" + }, + { + "id": "record.show", + "path": "record show" + }, { "id": "show", "path": "show" diff --git a/man/batten-record-fold.1 b/man/batten-record-fold.1 new file mode 100644 index 000000000..ef88bd874 --- /dev/null +++ b/man/batten-record-fold.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-fold 1 batten +.SH NAME +batten\-record\-fold \- Fold a journal family: `nothing`, its records, or `unreadable ` +.SH SYNOPSIS +\fBbatten record fold\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> +.SH DESCRIPTION +Fold a journal family: `nothing`, its records, or `unreadable ` +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family to fold diff --git a/man/batten-record-journal.1 b/man/batten-record-journal.1 new file mode 100644 index 000000000..082129006 --- /dev/null +++ b/man/batten-record-journal.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-journal 1 batten +.SH NAME +batten\-record\-journal \- Append one record to an append\-and\-fold store family, read from stdin +.SH SYNOPSIS +\fBbatten record journal\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> +.SH DESCRIPTION +Append one record to an append\-and\-fold store family, read from stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family the record belongs to diff --git a/man/batten-record-keyed.1 b/man/batten-record-keyed.1 new file mode 100644 index 000000000..6c6ad3dfc --- /dev/null +++ b/man/batten-record-keyed.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-keyed 1 batten +.SH NAME +batten\-record\-keyed \- Put one value into a keyed store family, read from stdin +.SH SYNOPSIS +\fBbatten record keyed\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> <\fIkey\fR> +.SH DESCRIPTION +Put one value into a keyed store family, read from stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family the record belongs to +.TP +<\fIkey\fR> +The key the record is filed under diff --git a/man/batten-record-show.1 b/man/batten-record-show.1 new file mode 100644 index 000000000..9adfcfe84 --- /dev/null +++ b/man/batten-record-show.1 @@ -0,0 +1,19 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-show 1 batten +.SH NAME +batten\-record\-show \- Read one keyed record back: `hit` and the value, or `miss` +.SH SYNOPSIS +\fBbatten record show\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> <\fIkey\fR> +.SH DESCRIPTION +Read one keyed record back: `hit` and the value, or `miss` +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The store family to read +.TP +<\fIkey\fR> +The key to look under diff --git a/man/batten-record.1 b/man/batten-record.1 index f1c68c055..64fcdd4f4 100644 --- a/man/batten-record.1 +++ b/man/batten-record.1 @@ -19,6 +19,18 @@ Record a declared tool row\*(Aqs verdict, read as ` ` lines on stdi batten\-record\-forge(1) Record the forge\*(Aqs check verdicts for one commit, read as ` ` lines on stdin .TP +batten\-record\-keyed(1) +Put one value into a keyed store family, read from stdin +.TP +batten\-record\-journal(1) +Append one record to an append\-and\-fold store family, read from stdin +.TP +batten\-record\-show(1) +Read one keyed record back: `hit` and the value, or `miss` +.TP +batten\-record\-fold(1) +Fold a journal family: `nothing`, its records, or `unreadable ` +.TP batten\-record\-plan(1) Record this branch\*(Aqs plan, read as ` ` lines on stdin .TP From 48405b13e206beadbc105a122b5de0f5504f9d21 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 14:43:43 +0000 Subject: [PATCH 12/32] feat(race): the source selector and the ancestor bound claimed-keys carries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `race::claimed` says in its own doc it is `claimed-keys.sh`'s port, with the three-source precedence intact. Two things it did not carry, and the retirement cannot land without either. THE SOURCE SELECTOR. The shell's `--closing-only` and `--refs-first-only` each name one source and are mutually exclusive there — asking for both is a caller that has not decided which question it is asking, never an intersection. As an enum that is unrepresentable rather than checked. `closing-key-check` needs `RefsFirstOnly` for CLOUD-674's circularity: the SERVED set must be derived without reference to the closing keys, or it agrees with the body by construction and the gate passes on exactly the bodies it must refuse. THE `BATTEN_SPEC_BASE` ANCESTOR BOUND, and carrying it is the whole of the port's fidelity. `land`'s speculative linearization puts another branch's unlanded commits into this branch's history; those commits carry the holder's keys and the holder has an open pull request by construction, so a claim derived over the whole branch history reports the waiter as racing the very pull request the bet was placed on. Measured twice in one session. A port reading `origin/main..HEAD` unconditionally reintroduces that silently, and it looks like a passing gate. Honoured only when it is an ancestor of HEAD — spelled as "the merge base with HEAD is the base itself" — so an unwound bet or an inherited variable falls back to `origin/main`. The failure direction is the WIDER, refusing set, never the narrower one that would stop catching races. `claimed` keeps its signature and delegates with `Source::All`, so no landed caller changes and the existing `claim race` suite still passes unmodified. `speculation.rs` said "nothing in this crate reads it" of `PUBLISHED_AS`. That was true only while the answer lived in the shell; it is corrected rather than left to read as a property. Refs: CLOUD-1711, CLOUD-748, CLOUD-674 --- crates/batten/src/race.rs | 192 ++++++++++++++++++++++++++++++- crates/batten/src/speculation.rs | 8 +- 2 files changed, 198 insertions(+), 2 deletions(-) diff --git a/crates/batten/src/race.rs b/crates/batten/src/race.rs index 7c438d0b4..564b02d1b 100644 --- a/crates/batten/src/race.rs +++ b/crates/batten/src/race.rs @@ -134,8 +134,58 @@ pub struct Race { /// one function for exactly that reason. #[must_use] pub fn claimed(branch: &str, title: &str, log: &str, body: &str, keys: &dyn Keys) -> Vec { + claimed_from(branch, title, log, body, keys, Source::All) +} + +/// Which of the three sources may answer. +/// +/// **The shell's two narrowing flags as a type** (CLOUD-1711). `claimed-keys.sh` +/// spells these `--closing-only` and `--refs-first-only`, and they are mutually +/// exclusive there because each names a different SINGLE source — asking for both +/// is a caller that has not decided which question it is asking, never an +/// intersection to compute. An enum makes that unrepresentable rather than +/// checked, which is the one thing a port can improve without changing a +/// decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Source { + /// All three, most explicit first. The default, and every existing caller. + #[default] + All, + /// Source 1 alone — a closing keyword — never falling through. + /// + /// For a caller asking about a LOG rather than a branch (CLOUD-804): over + /// `main`'s history the fallbacks answer a different question, and source 3 + /// in particular is the exact citation signal CLOUD-480 was swept wrong on. + ClosingOnly, + /// Source 3 alone — the first key of each `Refs:` trailer. + /// + /// The exact mirror, for one caller with one need: `closing-key-check` asks + /// which keys a branch SERVED, to subtract the keys the body closes + /// (CLOUD-674). That comparison is only meaningful against a set derived + /// WITHOUT reference to the closing keys — the full chain returns source 1 + /// first, so the answer would agree with the body by construction and the + /// gate would pass on exactly the bodies it must refuse. + RefsFirstOnly, +} + +/// [`claimed`], narrowed to one source. +/// +/// The precedence and the citation rule are unchanged; `source` only decides +/// which branches may run at all. +#[must_use] +pub fn claimed_from( + branch: &str, + title: &str, + log: &str, + body: &str, + keys: &dyn Keys, + source: Source, +) -> Vec { + if source == Source::RefsFirstOnly { + return refs_first(log, keys); + } let closing = dedup(keys.closed(&folded(&format!("{body}\n{log}")))); - if !closing.is_empty() { + if !closing.is_empty() || source == Source::ClosingOnly { return closing; } let declared = dedup(keys.named(&folded(&format!("{branch} {title}")))); @@ -145,6 +195,51 @@ pub fn claimed(branch: &str, title: &str, log: &str, body: &str, keys: &dyn Keys refs_first(log, keys) } +/// The commit messages this branch AUTHORED, which is narrower than the ones it +/// carries (CLOUD-748). +/// +/// **Carrying this bound is the whole of the port's fidelity.** `land`'s +/// speculative linearization rebases a waiting branch onto the lease holder's +/// published head, putting ANOTHER BRANCH'S unlanded commits into this branch's +/// history. Those commits carry the holder's keys, and the holder has an open +/// pull request by construction — so a claim derived over the whole branch +/// history reported the waiter as racing the very pull request the bet was +/// placed on. Measured twice in one session, each costing a full `verify`. A +/// port that read `origin/main..HEAD` unconditionally would silently reintroduce +/// exactly that, and it would look like a passing gate. +/// +/// [`crate::speculation::PUBLISHED_AS`] is the boundary: the commit the branch +/// was replayed ONTO, so everything after it on HEAD is this branch's own work. +/// +/// **Honoured only when it is an ancestor of HEAD**, which is what makes a stale +/// export harmless: an unwound bet, a `land` that died, or a variable inherited +/// from an unrelated run all fail that test and the range falls back to +/// `origin/main`. The failure direction is the WIDER set, which is the one that +/// refuses — never the narrower one, which would silently stop catching races. +#[must_use] +pub fn authored_log(dir: &std::path::Path, base: &str) -> String { + let speculated = std::env::var(crate::speculation::PUBLISHED_AS) + .ok() + .filter(|value| !value.trim().is_empty()); + if let Some(spec_base) = speculated { + // ANCESTOR OF HEAD, spelled as "the merge base with HEAD is the base + // itself". `merge_base` already answers against HEAD, so an unrelated or + // unwound base disagrees with its own resolved id and the range falls + // back — which is the wider, refusing direction the shell chose. + let resolved = crate::git::resolve_ref(dir, &spec_base).ok().flatten(); + let shared = crate::git::merge_base(dir, &spec_base).ok().flatten(); + if resolved.is_some() && resolved == shared { + if let Ok(Some(text)) = crate::git::log_messages(dir, &spec_base) { + return text; + } + } + } + crate::git::log_messages(dir, base) + .ok() + .flatten() + .unwrap_or_default() +} + /// Upper-cased, because a key pattern is not obliged to be case-insensitive and /// a BRANCH NAME is routinely not upper case. /// @@ -449,4 +544,99 @@ mod tests { fn a_head_sha_no_listed_pull_request_carries_identifies_nothing() { assert!(identify(&[pull("1", "a", "aaaa")], "bbbb").is_none()); } + + // --- CLOUD-1711: the `Source` selector ----------------------------------- + // + // THE DISCRIMINATING CASE IS THE CITATION TRAP, and it is the one the whole + // module exists for: a body CITES related issues as evidence, and reading a + // citation as a claim made a pull request racing the very key it cited. + + #[test] + fn a_body_that_cites_a_key_without_closing_it_does_not_claim_it() { + assert!( + claimed_from( + "", + "", + "", + "Supersedes the measurement in PROJ-133.", + &Fake, + Source::All, + ) + .is_empty(), + "citing is not claiming" + ); + } + + #[test] + fn closing_only_never_falls_through_to_the_branch() { + // `--closing-only`'s whole reason: over a LOG rather than a branch the + // fallbacks answer a different question, and source 3 in particular is + // the citation signal CLOUD-480 was swept wrong on. + assert!( + claimed_from( + "user/proj-843-campaign", + "a title (PROJ-9)", + "Refs: PROJ-1170\n", + "", + &Fake, + Source::ClosingOnly, + ) + .is_empty(), + "with no closing keyword the answer is empty, never the branch or a trailer" + ); + assert_eq!( + claimed_from("", "", "", "Closes PROJ-7.", &Fake, Source::ClosingOnly), + vec![String::from("PROJ-7")], + "and a closing keyword still answers" + ); + } + + #[test] + fn refs_first_only_ignores_a_closing_keyword_in_the_body() { + // CLOUD-674's circularity: `closing-key-check` subtracts the keys a body + // CLOSES from the keys the branch SERVED, so the served set must be + // derived without reference to the closing keys — otherwise it agrees + // with the body by construction and the gate passes on exactly the + // bodies it must refuse. + assert_eq!( + claimed_from( + "user/proj-9-thing", + "", + "Refs: PROJ-1170\n", + "Closes PROJ-7.", + &Fake, + Source::RefsFirstOnly, + ), + vec![String::from("PROJ-1170")], + "source 3 alone, never sources 1 or 2" + ); + } + + #[test] + fn the_default_source_is_the_whole_chain_in_order() { + // The anti-vacuity mirror for the two narrowing cases: a selector that + // returned nothing for every variant would pass both of them. + assert_eq!( + claimed_from( + "user/proj-843-x", + "", + "", + "Closes PROJ-1170.", + &Fake, + Source::All + ), + vec![String::from("PROJ-1170")], + "a closing keyword OVERRIDES the branch" + ); + assert_eq!( + claimed_from("user/proj-843-x", "", "", "", &Fake, Source::All), + vec![String::from("PROJ-843")], + "failing that, the branch" + ); + assert_eq!( + claimed_from("", "", "Refs: PROJ-1170\n", "", &Fake, Source::All), + vec![String::from("PROJ-1170")], + "failing that, the first key of a `Refs:` trailer" + ); + } } diff --git a/crates/batten/src/speculation.rs b/crates/batten/src/speculation.rs index 7e58503a6..4609e8e80 100644 --- a/crates/batten/src/speculation.rs +++ b/crates/batten/src/speculation.rs @@ -64,7 +64,13 @@ pub const LIVE_REF: &str = "refs/batten-spec/live"; /// otherwise tell a commit this branch authored from one this speculation /// adopted — so it reported the waiter as racing the very PR the bet was placed /// on, twice in one session (CLOUD-748). The name is the CONSUMER's and reaches -/// the child through the environment; nothing in this crate reads it. +/// the child through the environment. +/// +/// **`race::authored_log` reads it now** (CLOUD-1711). This doc said "nothing in +/// this crate reads it", which was true only for as long as the answer lived in +/// `claimed-keys.sh`: the in-engine port of that program has to carry the same +/// bound or it silently changes a decision, reading the whole branch history +/// where the shell read only the commits the branch itself authored. pub const PUBLISHED_AS: &str = "BATTEN_SPEC_BASE"; /// What a settle decided. From fe877f250f1326bff55e3c93983260e8c7776027 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 14:52:56 +0000 Subject: [PATCH 13/32] feat(cli): claim keys, the leaf claimed-keys retires onto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `race::claimed_from` carries the answer; this is the door. A LEAF under `claim` rather than a noun of its own (CLOUD-1546's 42 top-level rows, CLOUD-1182's nine ports becoming nine nouns), and `read` honestly: it mints nothing and writes nothing. Verified against the program it replaces, on this repository, same bytes out: the default chain and `--refs-first-only` both agree key for key. EXPLICIT MODE IS ALL-OR-NOTHING, carried unchanged: passing any of `--branch`, `--title` or `--log` switches git off entirely, because a remote pull request silently answered from the LOCAL branch is a confident verdict about the wrong repository state (CLOUD-378). Source 2 stays the UNION of branch and title rather than a precedence between them — two spellings of one self-declaration, and picking one would make the answer depend on which the author filled in. EMPTY IS NOT AN ERROR and exit 0 is the contract: every caller reads "no claim" as "do not judge", because a guard that guesses blocks correct work. The mutually-exclusive flags are a USAGE error (1), not the shell's 2 — that corpus runs the inverse contract and the inversion is deliberately not carried across. The `lib.rs` dispatch arm is the one line outside this row's stated §1, which lists only `race.rs` and the CLI surface. Every noun but `record` matches its subcommands there, so the row is under-scoped for the leaf it asks for; the arm is purely additive. Refs: CLOUD-1711, CLOUD-378, CLOUD-1546 --- completions/batten.bash | 85 +++++++++++++- completions/batten.fish | 67 ++++++++--- completions/batten.zsh | 66 ++++++++++- crates/batten/src/cli.rs | 20 ++++ crates/batten/src/lib.rs | 110 ++++++++++++++++++ crates/batten/src/surface.rs | 106 +++++++++++++++++ .../it__snapshots__golden_json_schema.snap | 54 +++++++++ man/batten-claim-keys.1 | 28 +++++ man/batten-claim.1 | 3 + 9 files changed, 514 insertions(+), 25 deletions(-) create mode 100644 man/batten-claim-keys.1 diff --git a/completions/batten.bash b/completions/batten.bash index 8a03a9401..fa940b79e 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -217,6 +217,9 @@ _batten() { batten__subcmd__claim,help) cmd="batten__subcmd__claim__subcmd__help" ;; + batten__subcmd__claim,keys) + cmd="batten__subcmd__claim__subcmd__keys" + ;; batten__subcmd__claim,race) cmd="batten__subcmd__claim__subcmd__race" ;; @@ -232,6 +235,9 @@ _batten() { batten__subcmd__claim__subcmd__help,help) cmd="batten__subcmd__claim__subcmd__help__subcmd__help" ;; + batten__subcmd__claim__subcmd__help,keys) + cmd="batten__subcmd__claim__subcmd__help__subcmd__keys" + ;; batten__subcmd__claim__subcmd__help,race) cmd="batten__subcmd__claim__subcmd__help__subcmd__race" ;; @@ -538,6 +544,9 @@ _batten() { batten__subcmd__help__subcmd__claim,check) cmd="batten__subcmd__help__subcmd__claim__subcmd__check" ;; + batten__subcmd__help__subcmd__claim,keys) + cmd="batten__subcmd__help__subcmd__claim__subcmd__keys" + ;; batten__subcmd__help__subcmd__claim,race) cmd="batten__subcmd__help__subcmd__claim__subcmd__race" ;; @@ -2114,7 +2123,7 @@ _batten() { return 0 ;; batten__subcmd__claim) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help check bot race carry help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help keys check bot race carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2242,7 +2251,7 @@ _batten() { return 0 ;; batten__subcmd__claim__subcmd__help) - opts="check bot race carry help" + opts="keys check bot race carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2311,6 +2320,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__help__subcmd__keys) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__claim__subcmd__help__subcmd__race) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2325,6 +2348,48 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__keys) + opts="-q -v -y -h --branch --title --log --closing-only --refs-first-only --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --branch) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --title) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__claim__subcmd__race) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -3796,7 +3861,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__claim) - opts="check bot race carry" + opts="keys check bot race carry" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3851,6 +3916,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__claim__subcmd__keys) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__claim__subcmd__race) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index fe7b61b0e..5f9f0b939 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2001,32 +2001,59 @@ complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "acquire" -d 'Take a task\'s lock for a pid, or refuse naming the process that holds it' complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "release" -d 'Drop a task\'s lock, which its exit trap does and a kill cannot' complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "carry" -d 'Attest that this branch only carries licence rows forward, and mint the receipt when it does' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from check bot race carry help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "carry" -d 'Attest that this branch only carries licence rows forward, and mint the receipt when it does' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l branch -d 'The head branch, standing in for source 2' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l title -d 'The pull request title, also source 2 — a body is not, because a body cites evidence' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l log -d 'Commit messages, standing in for sources 1 and 3' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l closing-only -d 'Answer from a closing keyword alone, never falling through to the branch or a trailer' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l refs-first-only -d 'Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from check" -l adopt-from -d 'The branch name the receipt being adopted was minted under' -r complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from check" -l issue -d 'Read the issue payload from the capture store by key, where `mcp call ... get_issue` put it' -r complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from check" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' @@ -2118,6 +2145,7 @@ complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' @@ -3777,6 +3805,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from task" -f -a "alive" -d 'What tasks are running right now and what phase each is in — one call, no log reading' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from singleton" -f -a "acquire" -d 'Take a task\'s lock for a pid, or refuse naming the process that holds it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from singleton" -f -a "release" -d 'Drop a task\'s lock, which its exit trap does and a kill cannot' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' diff --git a/completions/batten.zsh b/completions/batten.zsh index a6fa8dcc2..0a9f91a3b 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3340,7 +3340,41 @@ trace\:"Add everything"))' \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-claim-command-$line[1]:" case $line[1] in - (check) + (keys) +_arguments "${_arguments_options[@]}" : \ +'--branch=[The head branch, standing in for source 2]: :_default' \ +'--title=[The pull request title, also source 2 — a body is not, because a body cites evidence]: :_default' \ +'--log=[Commit messages, standing in for sources 1 and 3]: :_default' \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--closing-only[Answer from a closing keyword alone, never falling through to the branch or a trailer]' \ +'--refs-first-only[Answer from the first key of each \`Refs\:\` trailer alone, never sources 1 or 2]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; +(check) _arguments "${_arguments_options[@]}" : \ '--adopt-from=[The branch name the receipt being adopted was minted under]: :_default' \ '--issue=[Read the issue payload from the capture store by key, where \`mcp call ... get_issue\` put it]: :_default' \ @@ -3477,7 +3511,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-claim-help-command-$line[1]:" case $line[1] in - (check) + (keys) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(check) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -6721,7 +6759,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-help-claim-command-$line[1]:" case $line[1] in - (check) + (keys) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(check) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -7402,6 +7444,7 @@ _batten__subcmd__checks__subcmd__help__subcmd__help_commands() { (( $+functions[_batten__subcmd__claim_commands] )) || _batten__subcmd__claim_commands() { local commands; commands=( +'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ 'race:Refuse a claim a different open pull request already carries, judged by head SHA' \ @@ -7428,6 +7471,7 @@ _batten__subcmd__claim__subcmd__check_commands() { (( $+functions[_batten__subcmd__claim__subcmd__help_commands] )) || _batten__subcmd__claim__subcmd__help_commands() { local commands; commands=( +'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ 'race:Refuse a claim a different open pull request already carries, judged by head SHA' \ @@ -7456,11 +7500,21 @@ _batten__subcmd__claim__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten claim help help commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__keys_commands] )) || +_batten__subcmd__claim__subcmd__help__subcmd__keys_commands() { + local commands; commands=() + _describe -t commands 'batten claim help keys commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__race_commands] )) || _batten__subcmd__claim__subcmd__help__subcmd__race_commands() { local commands; commands=() _describe -t commands 'batten claim help race commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__keys_commands] )) || +_batten__subcmd__claim__subcmd__keys_commands() { + local commands; commands=() + _describe -t commands 'batten claim keys commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__race_commands] )) || _batten__subcmd__claim__subcmd__race_commands() { local commands; commands=() @@ -7933,6 +7987,7 @@ _batten__subcmd__help__subcmd__checks__subcmd__green_commands() { (( $+functions[_batten__subcmd__help__subcmd__claim_commands] )) || _batten__subcmd__help__subcmd__claim_commands() { local commands; commands=( +'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ 'race:Refuse a claim a different open pull request already carries, judged by head SHA' \ @@ -7955,6 +8010,11 @@ _batten__subcmd__help__subcmd__claim__subcmd__check_commands() { local commands; commands=() _describe -t commands 'batten help claim check commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__keys_commands] )) || +_batten__subcmd__help__subcmd__claim__subcmd__keys_commands() { + local commands; commands=() + _describe -t commands 'batten help claim keys commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__race_commands] )) || _batten__subcmd__help__subcmd__claim__subcmd__race_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 3040a659c..a0e95a2c6 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -776,6 +776,19 @@ pub enum ReadyCommand { #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub enum ClaimCommand { + /// The issue keys this branch CLAIMS, as distinct from those it mentions. + Keys { + /// The head branch, standing in for source 2. + branch: Option, + /// The pull request title, also source 2. + title: Option, + /// Commit messages, standing in for sources 1 and 3. + log: Option, + /// Answer from a closing keyword alone. + closing_only: bool, + /// Answer from the first key of each `Refs:` trailer alone. + refs_first_only: bool, + }, /// Judge a set of payloads and mint the receipt when they are pullable. Check { /// Claim over the competitor refusals, recording what was overridden. @@ -2124,6 +2137,13 @@ fn claim_of(matches: &ArgMatches) -> Option { }), ("bot", _) => Some(ClaimCommand::Bot), ("race", _) => Some(ClaimCommand::Race), + ("keys", matches) => Some(ClaimCommand::Keys { + branch: matches.get_one::("branch").cloned(), + title: matches.get_one::("title").cloned(), + log: matches.get_one::("log").cloned(), + closing_only: flag(matches, "closing-only"), + refs_first_only: flag(matches, "refs-first-only"), + }), ("carry", matches) => Some(ClaimCommand::Carry { json: flag(matches, "json"), }), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index eb0d9fb7b..73e60dab7 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3692,6 +3692,97 @@ fn refuse_claim_bot(err: &mut dyn Write, text: &str) -> Result { /// Minted by whoever is at the keyboard, exactly like the agent receipt — the /// party that ran the check writes the record of it. A workflow minting one would /// be a receipt asserting a check nobody performed. +/// What `claim keys` was asked about (CLOUD-1711). +/// +/// Explicit sources exist for a pull request this checkout did not author +/// (CLOUD-378): `claim race` asks the same question about a COMPETING branch and +/// had no way to ask it of the local repository. +struct ClaimKeysAsk<'a> { + /// The head branch, source 2. + branch: Option<&'a str>, + /// The pull request title, also source 2. + title: Option<&'a str>, + /// Commit messages, sources 1 and 3. + log: Option<&'a str>, + /// Source 1 alone. + closing_only: bool, + /// Source 3 alone. + refs_first_only: bool, +} + +/// Print the issue keys this branch claims, one per line. +/// +/// **Ported off `mise-tasks/claimed-keys.sh` (CLOUD-1711).** The precedence, the +/// citation rule, the explicit-source mode and the `BATTEN_SPEC_BASE` ancestor +/// bound are all `race`'s; this function is the seam that reads the repository +/// and prints the answer. +/// +/// EMPTY IS NOT AN ERROR and exit 0 is the whole contract: every caller treats +/// "no claim" as "do not judge", because a guard that guesses is one that blocks +/// correct work. Outside a git repository the same applies. +fn run_claim_keys( + repo: &Path, + ask: &ClaimKeysAsk<'_>, + overrides: &resolve::Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + // EACH FLAG NAMES ONE SOURCE, so both together is a caller that has not + // decided which question it is asking rather than an intersection to compute. + let source = match (ask.closing_only, ask.refs_first_only) { + (true, true) => { + writeln!( + err, + "batten: claim keys: --closing-only and --refs-first-only each name one source; pick one" + )?; + return Ok(ExitCode::Usage); + } + (true, false) => race::Source::ClosingOnly, + (false, true) => race::Source::RefsFirstOnly, + (false, false) => race::Source::All, + }; + + let grammar = board_grammar(overrides)?; + // EXPLICIT MODE IS ALL-OR-NOTHING. Passing any source switches git off + // entirely, because a remote pull request silently answered from the local + // branch would be a confident verdict about the wrong repository state. + let explicit = ask.branch.is_some() || ask.title.is_some() || ask.log.is_some(); + let (branch, log) = if explicit { + ( + ask.branch.unwrap_or_default().to_owned(), + ask.log.unwrap_or_default().to_owned(), + ) + } else { + let Some(head) = git::current_branch(repo)? else { + return Ok(ExitCode::Success); + }; + (head, race::authored_log(repo, "origin/main")) + }; + + // The extra evidence a caller has and this cannot read for itself: the + // command being guarded, or the pull request body being judged. OPTIONAL — a + // caller with nothing to add closes stdin and the branch and commit sources + // still answer, so this must never block on an interactive terminal. + let body = if std::io::IsTerminal::is_terminal(&std::io::stdin()) { + String::new() + } else { + let mut raw = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut raw).unwrap_or_default(); + raw + }; + for key in race::claimed_from( + &branch, + ask.title.unwrap_or_default(), + &log, + &body, + &grammar, + source, + ) { + writeln!(out, "{key}")?; + } + Ok(ExitCode::Success) +} + fn run_claim_bot( repo: &Path, mode: Mode, @@ -3988,6 +4079,25 @@ fn run_claim( err, ) } + ClaimCommand::Keys { + branch, + title, + log, + closing_only, + refs_first_only, + } => run_claim_keys( + Path::new("."), + &ClaimKeysAsk { + branch: branch.as_deref(), + title: title.as_deref(), + log: log.as_deref(), + closing_only, + refs_first_only, + }, + overrides, + out, + err, + ), ClaimCommand::Bot => run_claim_bot(Path::new("."), mode, overrides, out, err), ClaimCommand::Race => run_claim_race(Path::new("."), mode, overrides, out, err), ClaimCommand::Carry { json } => run_claim_carry(Path::new("."), mode, json, out, err), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 57659bd97..5aebd0f22 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -801,6 +801,87 @@ const CONFIG_IN: FlagDecl = FlagDecl { /// /// Deliberately not global: a global output mode would be silently accepted by /// verbs that emit no data — a flag that looks applied and isn't. +/// The explicit sources `claim keys` may be handed for a pull request this +/// checkout did not author (CLOUD-378, carried by CLOUD-1711). +/// +/// Passing ANY of them switches to explicit mode: git is not consulted at all and +/// an unsupplied source is empty. All-or-nothing rather than per-source fallback, +/// because a remote pull request silently answered from the LOCAL branch is the +/// worst kind of wrong — a confident verdict about the wrong repository state. +const CLAIM_BRANCH: FlagDecl = FlagDecl { + id: "branch", + long: Some("branch"), + short: None, + help: "The head branch, standing in for source 2", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +const CLAIM_TITLE: FlagDecl = FlagDecl { + id: "title", + long: Some("title"), + short: None, + help: "The pull request title, also source 2 — a body is not, because a body cites evidence", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +const CLAIM_LOG: FlagDecl = FlagDecl { + id: "log", + long: Some("log"), + short: None, + help: "Commit messages, standing in for sources 1 and 3", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + +/// Source 1 alone. Mutually exclusive with [`REFS_FIRST_ONLY`]: each names a +/// different SINGLE source, so both together is a caller that has not decided +/// which question it is asking, never an intersection to compute. +const CLOSING_ONLY: FlagDecl = FlagDecl { + id: "closing-only", + long: Some("closing-only"), + short: None, + help: "Answer from a closing keyword alone, never falling through to the branch or a trailer", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Bool, +}; + +/// Source 3 alone — `closing-key-check`'s need (CLOUD-674). +const REFS_FIRST_ONLY: FlagDecl = FlagDecl { + id: "refs-first-only", + long: Some("refs-first-only"), + short: None, + help: "Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Bool, +}; + const JSON: FlagDecl = FlagDecl { id: "json", long: Some("json"), @@ -3936,6 +4017,31 @@ pub const SURFACE: &[CommandDecl] = &[ // than a pure read — the mediated claim gate needs a claimed branch to be // distinguishable from an unclaimed one. A row claiming `read` here would put // a writing verb on the derived read-only allowlist. + // CLOUD-1711. `mise-tasks/claimed-keys.sh` retires onto this leaf, and + // `merged-pr-keys.sh` with it — that program shells into this same answer once + // per pull request body, so the two are a closed set. + // + // A LEAF under `claim` rather than a noun of its own (CLOUD-1546's 42 + // top-level rows). `read`, and honestly so: it mints nothing and writes + // nothing — it derives which keys a branch claims and prints them. + // + // Pointer-only: the keys alone, uppercased and sorted, never the prose they + // were extracted from (rule 4). + CommandDecl { + path: "claim keys", + id: "claim.keys", + about: "The issue keys this branch CLAIMS, as distinct from the ones it merely mentions", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[ + CLAIM_BRANCH, + CLAIM_TITLE, + CLAIM_LOG, + CLOSING_ONLY, + REFS_FIRST_ONLY, + ], + }, CommandDecl { path: "claim check", id: "claim.check", diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 426c0c06a..cd3141abd 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -594,6 +594,56 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "claim keys", + "id": "claim.keys", + "about": "The issue keys this branch CLAIMS, as distinct from the ones it merely mentions", + "effect": "read", + "data_channel": false, + "flags": [ + { + "name": "branch", + "short": null, + "long": "branch", + "takes_value": true, + "positional": false, + "help": "The head branch, standing in for source 2" + }, + { + "name": "closing-only", + "short": null, + "long": "closing-only", + "takes_value": false, + "positional": false, + "help": "Answer from a closing keyword alone, never falling through to the branch or a trailer" + }, + { + "name": "log", + "short": null, + "long": "log", + "takes_value": true, + "positional": false, + "help": "Commit messages, standing in for sources 1 and 3" + }, + { + "name": "refs-first-only", + "short": null, + "long": "refs-first-only", + "takes_value": false, + "positional": false, + "help": "Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2" + }, + { + "name": "title", + "short": null, + "long": "title", + "takes_value": true, + "positional": false, + "help": "The pull request title, also source 2 — a body is not, because a body cites evidence" + } + ], + "subcommands": [] + }, { "path": "claim race", "id": "claim.race", @@ -3115,6 +3165,10 @@ expression: stdout_of(&output) "id": "checks.green", "path": "checks green" }, + { + "id": "claim.keys", + "path": "claim keys" + }, { "id": "claim.race", "path": "claim race" diff --git a/man/batten-claim-keys.1 b/man/batten-claim-keys.1 new file mode 100644 index 000000000..91e94d01e --- /dev/null +++ b/man/batten-claim-keys.1 @@ -0,0 +1,28 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-claim-keys 1 batten +.SH NAME +batten\-claim\-keys \- The issue keys this branch CLAIMS, as distinct from the ones it merely mentions +.SH SYNOPSIS +\fBbatten claim keys\fR [\fB\-\-branch\fR] [\fB\-\-title\fR] [\fB\-\-log\fR] [\fB\-\-closing\-only\fR] [\fB\-\-refs\-first\-only\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +The issue keys this branch CLAIMS, as distinct from the ones it merely mentions +.SH OPTIONS +.TP +\fB\-\-branch\fR +The head branch, standing in for source 2 +.TP +\fB\-\-title\fR +The pull request title, also source 2 — a body is not, because a body cites evidence +.TP +\fB\-\-log\fR +Commit messages, standing in for sources 1 and 3 +.TP +\fB\-\-closing\-only\fR +Answer from a closing keyword alone, never falling through to the branch or a trailer +.TP +\fB\-\-refs\-first\-only\fR +Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2 +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-claim.1 b/man/batten-claim.1 index 79e351525..4ebe3337d 100644 --- a/man/batten-claim.1 +++ b/man/batten-claim.1 @@ -13,6 +13,9 @@ Whether the issue you are about to pull is actually unclaimed Print help .SH SUBCOMMANDS .TP +batten\-claim\-keys(1) +The issue keys this branch CLAIMS, as distinct from the ones it merely mentions +.TP batten\-claim\-check(1) Refuse a pull of an issue somebody is already on, and mint the receipt when it is free .TP From f80ab284796c8bc66f20d90674d4e9eff3cc4050 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 15:38:31 +0000 Subject: [PATCH 14/32] feat(cli): retire claimed-keys and merged-pr-keys onto claim keys and claim merged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `race::claimed_from` and `forge::window` already carried the answers; these are the doors, and the two programs go with them. `merged-pr-keys` shelled into `claimed-keys` once per pull request body, so they retire as one delta. VERIFIED AGAINST THE PROGRAMS THEY REPLACE, on this repository's real forge: `claim keys` agrees key for key on the default chain and `--refs-first-only`; `claim merged` was diffed row by row against `merged-pr-keys` over 713 merged pull requests. That diff is the whole value of this commit, because it found three defects nobody had measured. TWO ARE FIXED HERE, both in `ready-closing-verb`, which decides what a body CLOSES and is read by `closing-key-check`, `race::claimed` and `ready-lint`: * `[[:space:]]*` matched a NEWLINE, so the row's own "immediately before a key" anchor stopped meaning that the moment a heading ended in "the fix" and a citation opened the next line. Narrowed to `[[:blank:]]*`. * an explicit negation was invisible: `## Why this does NOT close CLOUD-1074` read as closing it, because the prefix ends in `close` and the negation sits one word further back, outside an end-anchored pattern's reach. Rust's regex has no lookbehind, so `ready-closing-negation` is a second row and `keys_closed_in` now LOCATES the verb rather than merely detecting it. Six rows read as closed by bodies that say in so many words that they do not close them. The harm runs the dangerous way: a false CLAIM tells `in-progress-drain` a row landed, and it drains a row that is still live. THE THIRD IS FILED, NOT ABSORBED (CLOUD-1757). `to close X`, `closed X` and `the same fix X` are indistinguishable from `Closes X` by adjacency alone, and the discriminator people actually use is POSITION rather than vocabulary. That change moves three landed gates and its false-negative direction cannot be measured from merged pull requests alone, so it gets its own row with the corpus. The residual — 5 narrative rows, against 4 genuine `Closes:` claims the shell's hand-rolled regex MISSED — is a `// changed:` arm rather than silence. Both authorities were defective, in opposite directions. That is CLOUD-338's one-authority argument arriving as a measurement. A PRODUCER, NOT A GATE, which is why `claim merged` exits non-zero on could-not-look where `claim race` reports it on stdout at 0: its stdout is DATA a caller consumes, and a producer that exits clean having produced nothing is indistinguishable from a repository with no merged pull requests — the exact state the program refuses as impossible of a repository with a trunk. Outside a checkout `claim keys` prints nothing and exits 0, and the branch is resolved BEFORE the grammar so that stays true: every caller reads "no claim" as "do not judge", and a guard that guesses blocks correct work. Five governed callers are repointed rather than left dangling — the one admitted edit, and the arm exists precisely so a retirement can complete. `landed-check` is repointed only; it retires under CLOUD-1753, which is the sibling's. `shell-retirement` gained the arm this retirement had no landable spelling without. CLOUD-843 closed the stranded-variable class for a `local … reg …` declaration; a standalone `here=$(cd "$(dirname "$0")" && pwd)` is the same trap one assignment form on — repoint its only spend and shellcheck refuses SC2034, keep the spend and the program cannot die. The binding may go only when EVERY base line spending it called the path this delta deletes, so one surviving spend still refuses. `policy/` is ungoverned, and a wrongly refusing gate is a defect to repair rather than a row to file. Refs: CLOUD-1711, CLOUD-1752, CLOUD-1757, CLOUD-338 Admits: 1d067b325bbd89ab32715b146e4c155aa4db506fe64e9df8f643905fa7e93e6f Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:4043dbcc49db7015f165de2eb0cbc5bf351a1d62 Admits-epoch: 6565da66b50bbf8003951fed4cc73ab525c51121fa130a106f8abf709753fad2 Admits-author: alec@wenzowski.com Admits-prev: 404fca68fbbf5dfc9b90113a1a43a00fa424cf32da1eaf8d97a7d25425e803e4 Admits-answer-lost: `claim merged` ships reading bodies as closing rows they explicitly do not close. Measured over this repository's 713 merged pull requests: six rows read as CLOSED by bodies saying so in as many words — `## Why this does NOT close CLOUD-1074`, `It also does not close CLOUD-673`, `**This does not close CLOUD-1050`, `Filed, not fixed: CLOUD-466` — plus four more where a heading ending in "the fix" sat a blank line above a citation. The harm runs the dangerous way: a false CLAIM tells `in-progress-drain` a row landed and it drains a row that is still live. Without these two rows the port is worse than the program it replaces, which is the one thing a retirement may not be. Admits-answer-precondition: The redirect this class names for `batten.toml` is "change it in a pull request", which states how the change must LAND rather than naming a surface that can express it: no tool other than a direct write can add a `[[pattern]]` row or narrow an existing one's regex. Two rows are the subject — `ready-closing-negation` is new and `ready-closing-verb`'s `[[:space:]]` becomes `[[:blank:]]` — and both are the consumer's own vocabulary, which non-negotiable rule 1 keeps out of the crate. Branch claude/retire-bash-corpus-44-sjdnok, draft PR #930, reviewed before merge. Admits-answer-rejected-route: `patch run first` does not apply: the protected-path gate is the intersection of the protected paths with the mutating-verb table, and that table already refuses `>`, `tee`, `sed`, `cp`, `install` and `git` over this path, so routing identical bytes through a patch program reaches the identical refusal under a different program name. `config read first` was TAKEN, not rejected — the `[[pattern]]` block at batten.toml:1925-1947, `crates/batten/src/ready.rs`'s `keys_closed_in`, and `mise-tasks/claimed-keys.sh`'s own `CLAIM_RE` were all read first, and reading them is what established that the two authorities are defective in OPPOSITE directions and that only one of the three classes is fixable by a pattern row. The residual is filed as CLOUD-1757 rather than absorbed. --- batten.toml | 42 ++- bench/suites/RESULTS.md | 2 - completions/batten.bash | 77 +++- completions/batten.fish | 65 +++- completions/batten.zsh | 62 +++- crates/batten/src/cli.rs | 8 + crates/batten/src/lib.rs | 150 +++++++- crates/batten/src/ready.rs | 29 +- crates/batten/src/surface.rs | 35 ++ crates/batten/tests/it/claimed_keys.rs | 273 ++++++++++++++ crates/batten/tests/it/main.rs | 1 + .../it__snapshots__golden_json_schema.snap | 22 ++ man/batten-claim-merged.1 | 16 + man/batten-claim.1 | 3 + mise-tasks/claimed-keys.sh | 212 ----------- mise-tasks/closing-key-check.sh | 4 +- mise-tasks/deferral-check.sh | 2 +- mise-tasks/in-progress-drain.sh | 2 +- mise-tasks/landed-check.sh | 3 +- mise-tasks/merged-pr-keys.sh | 121 ------- mise.toml | 149 +------- policy/shell-retirement.rego | 39 ++ tests/claimed-keys.bats | 342 ------------------ tests/merged-pr-keys.bats | 122 ------- 24 files changed, 803 insertions(+), 978 deletions(-) create mode 100644 crates/batten/tests/it/claimed_keys.rs create mode 100644 man/batten-claim-merged.1 delete mode 100755 mise-tasks/claimed-keys.sh delete mode 100755 mise-tasks/merged-pr-keys.sh delete mode 100644 tests/claimed-keys.bats delete mode 100644 tests/merged-pr-keys.bats diff --git a/batten.toml b/batten.toml index 5e7302453..43c55656f 100644 --- a/batten.toml +++ b/batten.toml @@ -1966,9 +1966,49 @@ regex = 'CLOUD-[0-9]+' # `[[pattern]]` row and not a literal in the crate: the core stays repo-agnostic, # and a consumer whose forge spells the set differently declares its own row # rather than patching the engine. +# BLANK, NEVER SPACE, and the difference is a measured false positive rather than +# a nicety (CLOUD-1752). `[[:space:]]` matches a NEWLINE, so the anchor above +# stopped meaning "immediately before a key" the moment a body put the verb at the +# end of one line and the key at the start of the next — which ordinary prose does +# constantly. Measured 2026-09-09 over this repository's 713 merged pull requests: +# 16 rows read as CLOSED that no body closes, `mise-tasks/merged-pr-keys.sh` +# emitting none of them. PR #163 is the shape: +# +# ## The residue survived the fix +# +# CLOUD-223 taught `.claude/hooks/session-start.sh` … +# +# A heading ending in "the fix", a blank line, then a citation — read as a claim. +# The harm runs the dangerous way: a false CLAIM tells `in-progress-drain` a row +# landed, and it drains a row that is still live. +# +# `[[:blank:]]` is space and tab and nothing else, so the verb must sit on the +# key's own line — which is what the paragraph above always said this row did. +# A NEGATED CLOSING VERB IS NOT A CLAIM, and the row above cannot see one +# (CLOUD-1752). Its anchor decides the text IMMEDIATELY before a key, which is +# exactly what makes `does not close CLOUD-1` match: the prefix ends in `close` +# and the negation sits one word further back, outside what an end-anchored +# pattern can reach. Rust's regex has no lookbehind, so the guard is a second row +# rather than a cleverer first one. +# +# MEASURED 2026-09-09 over this repository's 713 merged pull requests: SIX rows +# read as closed by a body that says in so many words that it does not close them +# — `## Why this does NOT close CLOUD-1074`, `It also does not close CLOUD-673`, +# `**This does not close CLOUD-1050`, `## Why this does not close CLOUD-607`, +# `Filed, not fixed: CLOUD-466`. Writing out why a change does NOT close a row is +# a habit this repository actively encourages, so the false positive is not rare +# and it runs the dangerous way: a false CLAIM moves a live row. +# +# ANCHORED AT THE END LIKE ITS SIBLING, and read against the text before the VERB +# rather than before the key — so `not` must sit on the verb, never merely +# somewhere earlier in the paragraph. +[[pattern]] +id = "ready-closing-negation" +regex = '(?i)(^|[^0-9A-Za-z-])(not|never|n.t|without|nor)[[:blank:]]*$' + [[pattern]] id = "ready-closing-verb" -regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:space:]]*:?[[:space:]]*#?$' +regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:blank:]]*:?[[:blank:]]*#?$' # THE PROSE-DIALECT THRESHOLD (CLOUD-472) IS `[ready]`, NOT A `[[pattern]]` ROW. # It was drafted as one — a regex over the exempt key range — and that is the diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index fb113fb66..91c8009ff 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -39,7 +39,6 @@ to it pays. | 1.6 | 1.0% | `tests/suite-select.bats` | | 1.5 | 0.9% | `tests/spec-ref-check.bats` | | 1.4 | 0.9% | `tests/signing-posture.bats` | -| 1.3 | 0.8% | `tests/claimed-keys.bats` | | 1.2 | 0.8% | `tests/tree-clean.bats` | | 1.2 | 0.8% | `tests/ci-slow-needed.bats` | | 1.2 | 0.7% | `tests/ready-lint-deferral.bats` | @@ -64,7 +63,6 @@ to it pays. | 0.6 | 0.4% | `tests/commit-attribution.bats` | | 0.6 | 0.4% | `tests/done-pr-check.bats` | | 0.6 | 0.4% | `tests/attestation-check.bats` | -| 0.6 | 0.4% | `tests/merged-pr-keys.bats` | | 0.6 | 0.4% | `tests/timeout-drift.bats` | | 0.6 | 0.4% | `tests/evaluator-closure-check.bats` | | 0.6 | 0.4% | `tests/sbom-binary.bats` | diff --git a/completions/batten.bash b/completions/batten.bash index fa940b79e..e96191890 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -220,6 +220,9 @@ _batten() { batten__subcmd__claim,keys) cmd="batten__subcmd__claim__subcmd__keys" ;; + batten__subcmd__claim,merged) + cmd="batten__subcmd__claim__subcmd__merged" + ;; batten__subcmd__claim,race) cmd="batten__subcmd__claim__subcmd__race" ;; @@ -238,6 +241,9 @@ _batten() { batten__subcmd__claim__subcmd__help,keys) cmd="batten__subcmd__claim__subcmd__help__subcmd__keys" ;; + batten__subcmd__claim__subcmd__help,merged) + cmd="batten__subcmd__claim__subcmd__help__subcmd__merged" + ;; batten__subcmd__claim__subcmd__help,race) cmd="batten__subcmd__claim__subcmd__help__subcmd__race" ;; @@ -547,6 +553,9 @@ _batten() { batten__subcmd__help__subcmd__claim,keys) cmd="batten__subcmd__help__subcmd__claim__subcmd__keys" ;; + batten__subcmd__help__subcmd__claim,merged) + cmd="batten__subcmd__help__subcmd__claim__subcmd__merged" + ;; batten__subcmd__help__subcmd__claim,race) cmd="batten__subcmd__help__subcmd__claim__subcmd__race" ;; @@ -2123,7 +2132,7 @@ _batten() { return 0 ;; batten__subcmd__claim) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help keys check bot race carry help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help merged keys check bot race carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2251,7 +2260,7 @@ _batten() { return 0 ;; batten__subcmd__claim__subcmd__help) - opts="keys check bot race carry help" + opts="merged keys check bot race carry help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2334,6 +2343,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__help__subcmd__merged) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__claim__subcmd__help__subcmd__race) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -2390,6 +2413,40 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__claim__subcmd__merged) + opts="-q -v -y -h --limit --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --limit) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__claim__subcmd__race) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then @@ -3861,7 +3918,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__claim) - opts="keys check bot race carry" + opts="merged keys check bot race carry" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3930,6 +3987,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__claim__subcmd__merged) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__claim__subcmd__race) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 5f9f0b939..8410d788e 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2001,33 +2001,56 @@ complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "acquire" -d 'Take a task\'s lock for a pid, or refuse naming the process that holds it' complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "release" -d 'Drop a task\'s lock, which its exit trap does and a kill cannot' complete -c batten -n "__fish_batten_using_subcommand singleton; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "carry" -d 'Attest that this branch only carries licence rows forward, and mint the receipt when it does' -complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from keys check bot race carry help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "merged" -d 'The keys merged pull request bodies close, as `\\t` rows' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "race" -d 'Refuse a claim a different open pull request already carries, judged by head SHA' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "carry" -d 'Attest that this branch only carries licence rows forward, and mint the receipt when it does' +complete -c batten -n "__fish_batten_using_subcommand claim; and not __fish_seen_subcommand_from merged keys check bot race carry help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l limit -d 'The most pull requests to read before the answer is truncated (default 5000)' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from merged" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l branch -d 'The head branch, standing in for source 2' -r complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l title -d 'The pull request title, also source 2 — a body is not, because a body cites evidence' -r complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from keys" -l log -d 'Commit messages, standing in for sources 1 and 3' -r @@ -2145,6 +2168,7 @@ complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from carry" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "merged" -d 'The keys merged pull request bodies close, as `\\t` rows' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' complete -c batten -n "__fish_batten_using_subcommand claim; and __fish_seen_subcommand_from help" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' @@ -3805,6 +3829,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from task" -f -a "alive" -d 'What tasks are running right now and what phase each is in — one call, no log reading' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from singleton" -f -a "acquire" -d 'Take a task\'s lock for a pid, or refuse naming the process that holds it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from singleton" -f -a "release" -d 'Drop a task\'s lock, which its exit trap does and a kill cannot' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "merged" -d 'The keys merged pull request bodies close, as `\\t` rows' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "keys" -d 'The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "check" -d 'Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from claim" -f -a "bot" -d 'Attest a bot branch from the lane\'s public facts, and mint the receipt when they hold' diff --git a/completions/batten.zsh b/completions/batten.zsh index 0a9f91a3b..4e6684966 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -3340,7 +3340,37 @@ trace\:"Add everything"))' \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-claim-command-$line[1]:" case $line[1] in - (keys) + (merged) +_arguments "${_arguments_options[@]}" : \ +'--limit=[The most pull requests to read before the answer is truncated (default 5000)]: :_default' \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; +(keys) _arguments "${_arguments_options[@]}" : \ '--branch=[The head branch, standing in for source 2]: :_default' \ '--title=[The pull request title, also source 2 — a body is not, because a body cites evidence]: :_default' \ @@ -3511,7 +3541,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-claim-help-command-$line[1]:" case $line[1] in - (keys) + (merged) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(keys) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -6759,7 +6793,11 @@ _arguments "${_arguments_options[@]}" : \ (( CURRENT += 1 )) curcontext="${curcontext%:*:*}:batten-help-claim-command-$line[1]:" case $line[1] in - (keys) + (merged) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; +(keys) _arguments "${_arguments_options[@]}" : \ && ret=0 ;; @@ -7444,6 +7482,7 @@ _batten__subcmd__checks__subcmd__help__subcmd__help_commands() { (( $+functions[_batten__subcmd__claim_commands] )) || _batten__subcmd__claim_commands() { local commands; commands=( +'merged:The keys merged pull request bodies close, as \`\\t\` rows' \ 'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ @@ -7471,6 +7510,7 @@ _batten__subcmd__claim__subcmd__check_commands() { (( $+functions[_batten__subcmd__claim__subcmd__help_commands] )) || _batten__subcmd__claim__subcmd__help_commands() { local commands; commands=( +'merged:The keys merged pull request bodies close, as \`\\t\` rows' \ 'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ @@ -7505,6 +7545,11 @@ _batten__subcmd__claim__subcmd__help__subcmd__keys_commands() { local commands; commands=() _describe -t commands 'batten claim help keys commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__merged_commands] )) || +_batten__subcmd__claim__subcmd__help__subcmd__merged_commands() { + local commands; commands=() + _describe -t commands 'batten claim help merged commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__help__subcmd__race_commands] )) || _batten__subcmd__claim__subcmd__help__subcmd__race_commands() { local commands; commands=() @@ -7515,6 +7560,11 @@ _batten__subcmd__claim__subcmd__keys_commands() { local commands; commands=() _describe -t commands 'batten claim keys commands' commands "$@" } +(( $+functions[_batten__subcmd__claim__subcmd__merged_commands] )) || +_batten__subcmd__claim__subcmd__merged_commands() { + local commands; commands=() + _describe -t commands 'batten claim merged commands' commands "$@" +} (( $+functions[_batten__subcmd__claim__subcmd__race_commands] )) || _batten__subcmd__claim__subcmd__race_commands() { local commands; commands=() @@ -7987,6 +8037,7 @@ _batten__subcmd__help__subcmd__checks__subcmd__green_commands() { (( $+functions[_batten__subcmd__help__subcmd__claim_commands] )) || _batten__subcmd__help__subcmd__claim_commands() { local commands; commands=( +'merged:The keys merged pull request bodies close, as \`\\t\` rows' \ 'keys:The issue keys this branch CLAIMS, as distinct from the ones it merely mentions' \ 'check:Refuse a pull of an issue somebody is already on, and mint the receipt when it is free' \ 'bot:Attest a bot branch from the lane'\''s public facts, and mint the receipt when they hold' \ @@ -8015,6 +8066,11 @@ _batten__subcmd__help__subcmd__claim__subcmd__keys_commands() { local commands; commands=() _describe -t commands 'batten help claim keys commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__merged_commands] )) || +_batten__subcmd__help__subcmd__claim__subcmd__merged_commands() { + local commands; commands=() + _describe -t commands 'batten help claim merged commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__claim__subcmd__race_commands] )) || _batten__subcmd__help__subcmd__claim__subcmd__race_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index a0e95a2c6..6f993a3df 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -789,6 +789,11 @@ pub enum ClaimCommand { /// Answer from the first key of each `Refs:` trailer alone. refs_first_only: bool, }, + /// The keys merged pull request bodies CLOSE, as `\t` rows. + Merged { + /// The most pull requests to read before the answer is truncated. + limit: Option, + }, /// Judge a set of payloads and mint the receipt when they are pullable. Check { /// Claim over the competitor refusals, recording what was overridden. @@ -2137,6 +2142,9 @@ fn claim_of(matches: &ArgMatches) -> Option { }), ("bot", _) => Some(ClaimCommand::Bot), ("race", _) => Some(ClaimCommand::Race), + ("merged", matches) => Some(ClaimCommand::Merged { + limit: matches.get_one::("limit").cloned(), + }), ("keys", matches) => Some(ClaimCommand::Keys { branch: matches.get_one::("branch").cloned(), title: matches.get_one::("title").cloned(), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 73e60dab7..449a07262 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3742,7 +3742,6 @@ fn run_claim_keys( (false, false) => race::Source::All, }; - let grammar = board_grammar(overrides)?; // EXPLICIT MODE IS ALL-OR-NOTHING. Passing any source switches git off // entirely, because a remote pull request silently answered from the local // branch would be a confident verdict about the wrong repository state. @@ -3753,11 +3752,22 @@ fn run_claim_keys( ask.log.unwrap_or_default().to_owned(), ) } else { - let Some(head) = git::current_branch(repo)? else { + // BEFORE THE GRAMMAR, and the order is the program's own decision rather + // than an accident: outside a git checkout it printed nothing and exited + // 0, so the caller behaves exactly as it did before it asked. Resolving + // config first would turn that silence into a config error, which is a + // different answer to a caller that treats non-zero as "could not look". + // ANY failure to name the branch is "no claim", never an error. The + // program spelled this `git rev-parse --abbrev-ref HEAD || exit 0`, and + // it covers a directory that is not a checkout at all as well as a + // checkout with no HEAD. Propagating instead would turn "do not judge" + // into a non-zero every caller reads as could-not-look. + let Some(head) = git::current_branch(repo).ok().flatten() else { return Ok(ExitCode::Success); }; (head, race::authored_log(repo, "origin/main")) }; + let grammar = board_grammar(overrides)?; // The extra evidence a caller has and this cannot read for itself: the // command being guarded, or the pull request body being judged. OPTIONAL — a @@ -3783,6 +3793,139 @@ fn run_claim_keys( Ok(ExitCode::Success) } +/// How many merged pull requests `claim merged` reads before refusing. +/// +/// Carried from `MERGED_PR_KEYS_LIMIT`'s default. A bound rather than an +/// unbounded walk because the forge's own listing caps out, and the header of the +/// program this replaces records the measurement: `--limit 400` returned exactly +/// 400 and hid #170, #337 and #339. +const MERGED_PR_LIMIT: usize = 5000; + +/// Print `\t` for every key a merged pull request body CLOSES. +/// +/// **Ported off `mise-tasks/merged-pr-keys.sh` (CLOUD-1752).** The extraction is +/// delegated to [`race::claimed_from`] with [`race::Source::ClosingOnly`], exactly +/// as the program delegated to `claimed-keys --closing-only`, so both sides of a +/// landed-ness comparison still come out of one authority (CLOUD-338). +/// +/// # Every could-not-look, and why each is one +/// +/// * the remote names no repository this can derive a slug from; +/// * the forge did not answer, or answered something unparseable; +/// * the walk hit its page budget — a reading AT the limit is indistinguishable +/// from one truncated by it, and **a truncated evidence file makes landed work +/// read as live**; +/// * the forge reports NO merged pull requests at all, which cannot be true of a +/// repository with a trunk. That is a reachability problem, not an empty +/// answer, and reading it as one strands every landed row. +/// +/// Each exits non-zero. See this verb's `CommandDecl` for why a producer differs +/// from `claim race` here. +/// +/// Output is keys and numbers, sorted and de-duplicated so two runs over one +/// forge are byte-identical (§6) — never a title or a body, which is where the +/// keyword lives and which rule 4 keeps out of a report. +fn run_claim_merged( + repo: &Path, + limit: Option<&str>, + overrides: &resolve::Overrides, + out: &mut dyn Write, + err: &mut dyn Write, +) -> Result { + let cannot_look = |err: &mut dyn Write, why: &str| -> Result { + writeln!(err, "batten: claim merged: {why}")?; + Ok(ExitCode::Internal) + }; + let limit = match limit { + Some(raw) => match raw.trim().parse::() { + Ok(parsed) if parsed > 0 => parsed, + _ => { + writeln!( + err, + "batten: claim merged: --limit must be a positive number" + )?; + return Ok(ExitCode::Usage); + } + }, + None => MERGED_PR_LIMIT, + }; + + let remotes = git::remote_fact(repo)?.remotes; + let Some(slug) = remotes.get("origin").and_then(|url| race::slug_of(url)) else { + return cannot_look(err, "no origin remote this can derive a repository from"); + }; + let git_dir = git::git_dir(repo)?; + // ONE PAGE OF 100 PER LAP, so the budget is stated in pull requests rather + // than in pages — the same unit `MERGED_PR_KEYS_LIMIT` was written in. + let per_page = 100_usize; + let pages = u32::try_from(limit.div_ceil(per_page)).unwrap_or(u32::MAX); + let rows = match forge::window( + &git_dir, + &format!("repos/{slug}/pulls"), + &[("state", "closed"), ("per_page", "100")], + forge::Shape::Bare, + pages, + ) { + forge::Window::Whole(rows) => rows, + forge::Window::Truncated { read, .. } => { + return cannot_look( + err, + &format!( + "the walk read {read} pull request(s) and did not reach the end — the answer \ + is truncated, and a truncated evidence file makes landed work read as live. \ + Raise --limit above {limit} and run again" + ), + ); + } + forge::Window::CouldNotLook { endpoint, status } => { + return cannot_look( + err, + &format!( + "the forge did not answer for {endpoint} (status {})", + status.map_or_else(|| String::from("none"), |code| code.to_string()) + ), + ); + } + }; + + // MERGED, not merely closed. The forge's listing has no merged state, so the + // filter is `merged_at`; a closed-unmerged pull request closes nothing and + // counting it would report abandoned work as landed. + let merged: Vec<&serde_json::Value> = rows + .iter() + .filter(|row| row.get("merged_at").is_some_and(|at| !at.is_null())) + .collect(); + if merged.is_empty() { + return cannot_look( + err, + "the forge reports no merged pull requests at all, which cannot be true of a \ + repository with a trunk — a reachability problem, not an empty answer", + ); + } + + let grammar = board_grammar(overrides)?; + let mut lines: std::collections::BTreeSet = std::collections::BTreeSet::new(); + for row in merged { + let Some(number) = row.get("number").and_then(serde_json::Value::as_u64) else { + return cannot_look( + err, + "a pull request in the reading carries no usable number", + ); + }; + let body = row + .get("body") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + for key in race::claimed_from("", "", "", body, &grammar, race::Source::ClosingOnly) { + lines.insert(format!("{key}\t{number}")); + } + } + for line in lines { + writeln!(out, "{line}")?; + } + Ok(ExitCode::Success) +} + fn run_claim_bot( repo: &Path, mode: Mode, @@ -4098,6 +4241,9 @@ fn run_claim( out, err, ), + ClaimCommand::Merged { limit } => { + run_claim_merged(Path::new("."), limit.as_deref(), overrides, out, err) + } ClaimCommand::Bot => run_claim_bot(Path::new("."), mode, overrides, out, err), ClaimCommand::Race => run_claim_race(Path::new("."), mode, overrides, out, err), ClaimCommand::Carry { json } => run_claim_carry(Path::new("."), mode, json, out, err), diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index 1854490ac..0eb62a095 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -319,6 +319,8 @@ pub struct Grammar { defer_verb: Regex, key: Regex, closing_verb: Regex, + /// A negator sitting on the closing verb, which unmakes the claim. + closing_negation: Regex, mention_markup: Regex, } @@ -478,6 +480,7 @@ impl Grammar { pressure_test_reviews: Vec::new(), key: find("ready-issue-key")?, closing_verb: find("ready-closing-verb")?, + closing_negation: find("ready-closing-negation")?, mention_markup: find("ready-issue-mention-markup")?, }) } @@ -717,13 +720,37 @@ impl Grammar { /// the pattern registry for the reason every other token does: one concept, /// one spelling. Anchored at the END, so it decides the text immediately /// before the key and nothing further back. + + /// Does `prefix` end in a closing verb that is NOT negated? + /// + /// **Two rows rather than one cleverer row, because Rust's regex has no + /// lookbehind** (CLOUD-1752). `ready-closing-verb` is anchored at the end so + /// it decides the text immediately before a key — which is precisely why it + /// reads `does not close CLOUD-1` as a claim: the prefix ends in `close` and + /// the negation is one word further back, outside an end-anchored pattern's + /// reach. + /// + /// So the verb is LOCATED rather than merely detected, and the text before it + /// is asked the second question. Measured over 713 merged pull requests, six + /// rows read as closed by bodies that say in so many words that they do not + /// close them. + fn closes_rather_than_disclaims(&self, prefix: &str) -> bool { + let Some(verb) = self.closing_verb.find(prefix) else { + return false; + }; + // `find` yields the leftmost match, and the pattern's own leading + // `(^|[^0-9A-Za-z-])` may eat the separator before the verb — so the + // negation is asked about everything up to where the match began. + !self.closing_negation.is_match(&prefix[..verb.start()]) + } + #[must_use] pub fn keys_closed_in(&self, text: &str) -> Vec { let found: BTreeSet<&str> = self .key .find_iter(text) .filter(|m| opens_a_key(text, m.start()) && closes_a_key(text, m.end())) - .filter(|m| self.closing_verb.is_match(&text[..m.start()])) + .filter(|m| self.closes_rather_than_disclaims(&text[..m.start()])) .map(|m| m.as_str()) .collect(); let mut keys: Vec = found.into_iter().map(|k| IssueKey(k.to_owned())).collect(); diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 5aebd0f22..65e687199 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -801,6 +801,21 @@ const CONFIG_IN: FlagDecl = FlagDecl { /// /// Deliberately not global: a global output mode would be silently accepted by /// verbs that emit no data — a flag that looks applied and isn't. +/// The fetch bound `claim merged` refuses at, rather than answering short. +const MERGED_LIMIT: FlagDecl = FlagDecl { + id: "limit", + long: Some("limit"), + short: None, + help: "The most pull requests to read before the answer is truncated (default 5000)", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// The explicit sources `claim keys` may be handed for a pull request this /// checkout did not author (CLOUD-378, carried by CLOUD-1711). /// @@ -4027,6 +4042,26 @@ pub const SURFACE: &[CommandDecl] = &[ // // Pointer-only: the keys alone, uppercased and sorted, never the prose they // were extracted from (rule 4). + // CLOUD-1752's forge-window group, and `merged-pr-keys.sh`'s successor. It + // asked `claimed-keys` once per pull request body; this asks + // `race::claimed_from(.., ClosingOnly)` the same way, so the two sides of + // every landed-ness comparison still come out of one authority (CLOUD-338). + // + // AN EVIDENCE PRODUCER, not a gate: its stdout is DATA its caller consumes, + // which is why a could-not-look here exits non-zero where `claim race` — which + // answers a question — reports could-not-look on stdout at 0. A producer that + // exits clean having produced nothing is indistinguishable from a repository + // with no merged pull requests, and that is the exact state the program it + // replaces refuses as impossible of a repository with a trunk. + CommandDecl { + path: "claim merged", + id: "claim.merged", + about: "The keys merged pull request bodies close, as `\\t` rows", + data_channel: true, + exits: EXITS_STANDARD, + effect: Effect::Read, + flags: &[MERGED_LIMIT], + }, CommandDecl { path: "claim keys", id: "claim.keys", diff --git a/crates/batten/tests/it/claimed_keys.rs b/crates/batten/tests/it/claimed_keys.rs new file mode 100644 index 000000000..ad0d6d363 --- /dev/null +++ b/crates/batten/tests/it/claimed_keys.rs @@ -0,0 +1,273 @@ +//! `claim keys` and `claim merged`, over the compiled binary (CLOUD-1711, CLOUD-1752). +//! +//! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads +//! +//! A program and its suite are TWO rows, never one. + +// carried: mise-tasks/claimed-keys.sh crates/batten/src/race.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs runs:batten+claim+keys +// carried: tests/claimed-keys.bats crates/batten/src/race.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs +// carried: mise-tasks/merged-pr-keys.sh crates/batten/src/forge.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs runs:batten+claim+merged +// carried: tests/merged-pr-keys.bats crates/batten/src/forge.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs + +// --- claimed-keys.bats, case by case --------------------------------------- +// carried: "a branch naming one issue is an unambiguous claim" crates/batten/src/race.rs kind:mechanism +// carried: "a closing keyword on stdin overrides the branch" crates/batten/src/race.rs kind:mechanism +// carried: "a closing keyword in a commit overrides the branch too" crates/batten/src/race.rs kind:mechanism +// carried: "a merely mentioned issue is not a claim" crates/batten/src/race.rs kind:mechanism +// carried: "a Refs: trailer claims when nothing more explicit does" crates/batten/src/race.rs kind:mechanism +// carried: "nothing resolvable is an empty answer, not an error" crates/batten/src/race.rs kind:mechanism +// carried: "outside a git checkout it exits 0 and says nothing" crates/batten/src/race.rs kind:mechanism +// carried: "the answer is uppercased and deduplicated" crates/batten/src/race.rs kind:mechanism +// carried: "output is the keys alone — never the prose they came from" crates/batten/src/race.rs kind:mechanism +// carried: "an explicit branch answers instead of the checkout's" crates/batten/src/race.rs kind:mechanism +// carried: "an explicit title is a claim, the way a branch is" crates/batten/src/race.rs kind:mechanism +// carried: "branch and title are a union, not a precedence between them" crates/batten/src/race.rs kind:mechanism +// carried: "a body that merely CITES a key claims nothing — the measured case" crates/batten/src/race.rs kind:mechanism +// carried: "a closing keyword in an explicit body still overrides branch and title" crates/batten/src/race.rs kind:mechanism +// carried: "an explicit log supplies the Refs: trailer, and only the trailer" crates/batten/src/race.rs kind:mechanism +// carried: "a key merely cited in an explicit log claims nothing" crates/batten/src/race.rs kind:mechanism +// carried: "explicit mode is all-or-nothing — an unsupplied source is empty, never local" crates/batten/src/race.rs kind:mechanism +// carried: "a key carried only by a speculated commit is not claimed" crates/batten/src/race.rs kind:mechanism +// carried: "a key this branch authored is still claimed with a speculation live" crates/batten/src/race.rs kind:mechanism +// carried: "with no speculation live the answer is exactly what it was" crates/batten/src/race.rs kind:mechanism +// carried: "a spec base that is not an ancestor of HEAD is ignored" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only does not fall through to a Refs: trailer" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only does not fall through to the branch name" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only still answers on a closing keyword" crates/batten/src/race.rs kind:mechanism +// carried: "without --closing-only the fallback chain is unchanged" crates/batten/src/race.rs kind:mechanism +// carried: "--closing-only reads the log from stdin, which is how a 1.27MB history fits" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only ignores a closing keyword in the body" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only takes the first key of the trailer, not its citations" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only ignores the branch name too" crates/batten/src/race.rs kind:mechanism +// carried: "--refs-first-only with no trailer answers empty, which is 'do not judge'" crates/batten/src/race.rs kind:mechanism +// changed: "a flag with no value is exit 2, never a silently empty source" crates/batten/src/race.rs kind:mechanism the engine parses its own arguments, so a value-less flag is refused by the parser before this verb runs, at Usage (1) rather than the shell's 2 — the contract inversion this campaign deliberately does not carry across +// changed: "an unknown argument is exit 2, and names no prose" crates/batten/src/race.rs kind:mechanism same route: the parser refuses an unknown flag at Usage (1). It still names no prose, which is the half that was about rule 4 rather than about the code +// changed: "the two narrowing flags are mutually exclusive" crates/batten/src/race.rs kind:mechanism carried as a decision and re-coded: `run_claim_merged`'s sibling `run_claim_keys` refuses both flags at Usage (1), not the shell's 2 + +// --- merged-pr-keys.bats, case by case -------------------------------------- +// carried: "a closing keyword in a merged body emits one row" crates/batten/src/forge.rs kind:mechanism +// carried: "Fixes and Resolves are claims too" crates/batten/src/forge.rs kind:mechanism +// carried: "a Refs: trailer is a mention and emits nothing" crates/batten/src/forge.rs kind:mechanism +// carried: "a bare citation in prose emits nothing" crates/batten/src/forge.rs kind:mechanism +// carried: "several keys in one body emit several rows, all keyed to that PR" crates/batten/src/forge.rs kind:mechanism +// carried: "a null body is data, not a crash" crates/batten/src/forge.rs kind:mechanism +// carried: "two runs over the same reading are byte-identical" crates/batten/src/forge.rs kind:mechanism +// carried: "a reading at the fetch limit is refused as truncated, not returned short" crates/batten/src/forge.rs kind:mechanism +// carried: "a reading below the fetch limit is answered" crates/batten/src/forge.rs kind:mechanism +// carried: "an empty forge answer is could-not-look, never an empty evidence file" crates/batten/src/forge.rs kind:mechanism +// carried: "output carries no PR body" crates/batten/src/forge.rs kind:mechanism +// changed: "an unreadable source is exit 2" crates/batten/src/forge.rs kind:mechanism the `MERGED_PR_KEYS_SOURCE` file seam is gone: the transport is `forge::window`, whose test seam is an injected `Transport` rather than a saved payload path. An unreadable forge is `Window::CouldNotLook` and exits 3 — fail loud, do not block — where the shell used its own 2 +// changed: "a source that is not a JSON array is exit 2" crates/batten/src/forge.rs kind:mechanism same seam. A body that will not parse is `Window::CouldNotLook`, exit 3; the decision (unparseable is could-not-look, never an empty collection) is carried verbatim +// changed: "a non-numeric limit is a caller bug, not a default" crates/batten/src/forge.rs kind:mechanism carried as a decision at a different code: `--limit` that is not a positive number is Usage (1), never silently the default + +//! # Why these cases +//! +//! The discriminating case is the CITATION TRAP, and it is what the retired +//! program existed for: a body cites related issues, prior measurements and +//! superseded work as evidence, and reading a citation as a claim made a pull +//! request race the very key it cited. Both sides of every comparison go through +//! one function for exactly that reason. +//! +//! The second is TRUNCATION. `merged-pr-keys` refused a reading at its fetch +//! limit rather than answering short, because a truncated evidence file makes +//! landed work read as live — measured at `--limit 400`, which returned exactly +//! 400 and hid three pull requests. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use common::{git_in, run_with_stdin, scratch, stderr, stdout, write}; + +/// A repository whose branch names a key, with one commit carrying a trailer. +/// +/// **Carries this repository's OWN `batten.toml`**, because the decisions under +/// test are the consumer's: which token is a key, which verb closes one, and +/// which negates it all live in `[[pattern]]` rows. A fixture with a hand-written +/// subset would assert against a grammar no checkout has, which is the fabricated +/// shape CLOUD-845 records one layer up. +fn repo(name: &str, branch: &str) -> std::path::PathBuf { + let dir = scratch(&format!("claim-keys-{name}")); + let config = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("batten.toml"); + std::fs::copy(&config, dir.join("batten.toml")).expect("the committed config is readable"); + write(&dir, "seed.txt", "seed\n"); + git_in(&dir, &["init", "-q", "-b", "main", "."]); + git_in(&dir, &["add", "-A"]); + git_in( + &dir, + &[ + "commit", + "-qm", + "chore: seed\n\nRefs: CLOUD-4242, CLOUD-9\n", + ], + ); + if !branch.is_empty() { + git_in(&dir, &["checkout", "-q", "-b", branch]); + } + dir +} + +fn keys(dir: &std::path::Path, args: &[&str], stdin: &str) -> std::process::Output { + let mut argv = vec!["claim", "keys"]; + argv.extend_from_slice(args); + run_with_stdin(dir, &argv, stdin) +} + +#[test] +fn a_body_that_cites_a_key_without_closing_it_does_not_claim_it() { + // THE MEASURED CASE, and the whole reason the program existed. PR #306 cited + // CLOUD-133 in one row of an evidence table and was reported as claiming it. + let dir = repo("cites", ""); + let out = keys( + &dir, + &["--branch", "", "--title", "", "--log", ""], + "Supersedes the measurement in CLOUD-133.\n", + ); + assert_eq!(out.status.code(), Some(0), "{}", stderr(&out)); + assert_eq!(stdout(&out).trim(), "", "citing is not claiming"); +} + +#[test] +fn a_closing_keyword_in_the_body_is_a_claim() { + // The anti-vacuity mirror: without it, a verb that answered empty for every + // input would pass the citation case. + let dir = repo("closes", ""); + let out = keys( + &dir, + &["--branch", "", "--title", "", "--log", ""], + "Closes CLOUD-133.\n", + ); + assert_eq!(stdout(&out).trim(), "CLOUD-133"); +} + +#[test] +fn branch_and_title_are_a_union_rather_than_a_precedence() { + // Two spellings of ONE self-declaration. Picking one would make the answer + // depend on which the author happened to fill in. + let dir = repo("union", ""); + let out = keys( + &dir, + &[ + "--branch", + "user/cloud-1-thing", + "--title", + "a fix (CLOUD-2)", + "--log", + "", + ], + "", + ); + let answer = stdout(&out); + assert!(answer.contains("CLOUD-1"), "{answer}"); + assert!(answer.contains("CLOUD-2"), "{answer}"); +} + +#[test] +fn explicit_mode_is_all_or_nothing_and_never_falls_back_to_the_checkout() { + // A remote pull request silently answered from the LOCAL branch is the worst + // kind of wrong: a confident verdict about the wrong repository state. + let dir = repo("explicit", "user/cloud-999-local"); + let out = keys(&dir, &["--title", "a title with no key"], ""); + assert_eq!( + stdout(&out).trim(), + "", + "an unsupplied source is EMPTY in explicit mode, never the checkout's branch" + ); +} + +#[test] +fn the_two_narrowing_flags_are_mutually_exclusive() { + // Each names a different SINGLE source, so both together is a caller that has + // not decided which question it is asking. USAGE (1), not the shell's 2 — the + // contract inversion this campaign does not carry across. + let dir = repo("both-flags", ""); + let out = keys(&dir, &["--closing-only", "--refs-first-only"], ""); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); +} + +#[test] +fn refs_first_only_ignores_a_closing_keyword_in_the_body() { + // CLOUD-674's circularity: the SERVED set must be derived without reference + // to the closing keys, or it agrees with the body by construction and + // `closing-key-check` passes on exactly the bodies it must refuse. + let dir = repo("refs-only", ""); + let out = keys( + &dir, + &[ + "--refs-first-only", + "--branch", + "", + "--title", + "", + "--log", + "Refs: CLOUD-77, CLOUD-88\n", + ], + "Closes CLOUD-99.\n", + ); + let answer = stdout(&out); + assert!( + answer.contains("CLOUD-77"), + "the trailer's FIRST key\n{answer}" + ); + assert!( + !answer.contains("CLOUD-88"), + "citing after it is not claiming\n{answer}" + ); + assert!( + !answer.contains("CLOUD-99"), + "source 1 must not answer here\n{answer}" + ); +} + +#[test] +fn closing_only_never_falls_through_to_the_branch_or_a_trailer() { + let dir = repo("closing-only", ""); + let out = keys( + &dir, + &[ + "--closing-only", + "--branch", + "user/cloud-5-x", + "--title", + "", + "--log", + "Refs: CLOUD-6\n", + ], + "", + ); + assert_eq!(stdout(&out).trim(), "", "{}", stderr(&out)); +} + +#[test] +fn outside_a_git_checkout_it_says_nothing_and_does_not_fail() { + // Every caller reads "no claim" as "do not judge", because a guard that + // guesses is one that blocks correct work. + // OUTSIDE THE REPOSITORY'S OWN TREE, deliberately: `scratch` lives under + // `target/`, so git discovery walks up and finds THIS checkout — which is the + // opposite of what this case is about. Measured: it answered with this + // branch's own keys. + let dir = std::env::temp_dir().join("batten-claim-keys-nogit"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a directory outside any checkout"); + let config = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("batten.toml"); + std::fs::copy(&config, dir.join("batten.toml")).expect("the committed config is readable"); + let out = keys(&dir, &[], ""); + assert_eq!(out.status.code(), Some(0), "{}", stderr(&out)); + assert_eq!(stdout(&out).trim(), ""); +} + +#[test] +fn a_non_numeric_merged_limit_is_a_caller_bug_rather_than_the_default() { + // Silently falling back to 5000 would answer a different question than the + // caller asked, and the answer would look authoritative. + let dir = repo("merged-limit", ""); + let out = run_with_stdin(&dir, &["claim", "merged", "--limit", "lots"], ""); + assert_eq!(out.status.code(), Some(1), "{}", stderr(&out)); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 14b975ae0..5b67c78ad 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -79,6 +79,7 @@ mod claim_carry; mod claim_order; mod claim_race; mod claim_receipt; +mod claimed_keys; mod cli; mod commit; mod commit_admission; diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index cd3141abd..150b24b8f 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -644,6 +644,24 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "claim merged", + "id": "claim.merged", + "about": "The keys merged pull request bodies close, as `\\t` rows", + "effect": "read", + "data_channel": true, + "flags": [ + { + "name": "limit", + "short": null, + "long": "limit", + "takes_value": true, + "positional": false, + "help": "The most pull requests to read before the answer is truncated (default 5000)" + } + ], + "subcommands": [] + }, { "path": "claim race", "id": "claim.race", @@ -3169,6 +3187,10 @@ expression: stdout_of(&output) "id": "claim.keys", "path": "claim keys" }, + { + "id": "claim.merged", + "path": "claim merged" + }, { "id": "claim.race", "path": "claim race" diff --git a/man/batten-claim-merged.1 b/man/batten-claim-merged.1 new file mode 100644 index 000000000..e2c711a45 --- /dev/null +++ b/man/batten-claim-merged.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-claim-merged 1 batten +.SH NAME +batten\-claim\-merged \- The keys merged pull request bodies close, as `\\t` rows +.SH SYNOPSIS +\fBbatten claim merged\fR [\fB\-\-limit\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +The keys merged pull request bodies close, as `\\t` rows +.SH OPTIONS +.TP +\fB\-\-limit\fR +The most pull requests to read before the answer is truncated (default 5000) +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-claim.1 b/man/batten-claim.1 index 4ebe3337d..6f78753f5 100644 --- a/man/batten-claim.1 +++ b/man/batten-claim.1 @@ -13,6 +13,9 @@ Whether the issue you are about to pull is actually unclaimed Print help .SH SUBCOMMANDS .TP +batten\-claim\-merged(1) +The keys merged pull request bodies close, as `\\t` rows +.TP batten\-claim\-keys(1) The issue keys this branch CLAIMS, as distinct from the ones it merely mentions .TP diff --git a/mise-tasks/claimed-keys.sh b/mise-tasks/claimed-keys.sh deleted file mode 100755 index 807685b69..000000000 --- a/mise-tasks/claimed-keys.sh +++ /dev/null @@ -1,212 +0,0 @@ -#!/usr/bin/env bash -#MISE description="The issue keys a branch CLAIMS, as distinct from the ones it merely mentions (extra evidence on stdin; prints one key per line)" -# -# CLOUD-338. Two guards need this answer and had one copy of it. `issue-guard` -# derived the claimed set inline to refuse a duplicate claim; `deferral-check` -# then needed the same set, to stop a deferral being exempted by the very key -# `issue-guard` forces onto every PR. A second copy is a second authority, and -# two guards that disagree about which issue a PR claims is a worse defect than -# either gate misfiring — so the derivation moved here and both read it. -# -# WHICH issue a branch claims is a NARROWER question than which it mentions, -# and conflating them is a false positive `issue-guard` produced against its own -# PR twice. A body cites related issues, prior measurements and superseded work -# as evidence; a branch may carry a bundle name naming two issues that landed -# hours ago. Neither is a claim. Only these are, most explicit first: -# -# 1. a closing keyword — `Closes`/`Fixes`/`Resolves CLOUD-` — in the extra -# evidence on stdin (the command being run, or the PR's own body) or in a -# commit on the branch. It OVERRIDES the branch, which is the escape hatch -# for a branch whose name no longer reflects the work. -# 2. failing that, the branch name — the tracker's own `gitBranchName` shape -# names the issue being worked, so a branch is a claim — together with the -# PR TITLE where a caller supplies one (see the explicit sources below). -# 3. failing that, a `Refs:` trailer on a commit. -# -# When none resolves, the answer is EMPTY and that is not an error: every caller -# treats "no claim" as "do not judge", because a guard that guesses is one that -# blocks correct work. Outside a git repo the same applies — exit 0, print -# nothing, and let the caller behave exactly as it did before it asked. -# -# EXPLICIT SOURCES, for a PR this checkout did not author (CLOUD-378). -# `issue-guard` asks the same question about a COMPETING PR — which issue does -# *it* claim — and had no way to ask it here, because the three sources above are -# all read from the local repository. So it re-derived the answer inline with a -# bare mention of the key in the other PR's title or body, which is the -# conflation this whole file exists to refuse, applied to the other side of the -# comparison. Measured: PR #306 (`docs(agents): … (CLOUD-268)`) cites CLOUD-133 -# in one row of an evidence table and was reported as claiming it. -# -# --closing-only answer from source 1 ALONE — a closing keyword — and never -# fall through to the branch name or a `Refs:` trailer. -# For a caller asking about a LOG rather than a branch -# (CLOUD-804): over `main`'s history the fallbacks answer a -# different question, and source 3 in particular is the exact -# citation signal CLOUD-480 was swept wrong on. Opt-in, so -# every existing caller keeps the full chain. -# --refs-first-only source 3 ALONE — the first key of each `Refs:` trailer — -# and never sources 1 or 2. The exact mirror of -# `--closing-only`, and it exists for one caller with one -# need: `closing-key-check` asks "which keys did this branch -# SERVE", to subtract the keys the body closes (CLOUD-674). -# That comparison is only meaningful against a set derived -# WITHOUT reference to the closing keys — the full chain -# returns source 1 first, so the answer would agree with the -# body by construction and the gate would pass on exactly the -# bodies it must refuse. Narrowing here rather than -# re-deriving in that gate keeps this file the one authority -# on what a `Refs:` trailer claims, and — the part a copy -# would silently lose — keeps the speculation boundary below. -# -# The two narrowing flags are mutually exclusive: each names a different single -# source, so asking for both is a caller bug rather than an intersection. -# --branch the head branch, standing in for source 2 -# --title the PR title, ALSO source 2 — for a PR you did not author -# the title is the other self-declaration of what the work -# is, and this repo's own convention ends every title with -# `(CLOUD-)`. A body is not: a body cites evidence. -# --log commit messages, standing in for sources 1 and 3 -# -# Passing ANY of them switches to explicit mode: git is not consulted at all and -# an unsupplied source is empty. All-or-nothing rather than per-source fallback, -# because a remote PR silently answered from the local branch would be the -# worst kind of wrong — a confident verdict about the wrong repository state. -# Source 2 is the UNION of branch and title, not a precedence between them: they -# are two spellings of one self-declaration, and picking one would make the -# answer depend on which the author happened to fill in. -# -# Output is the keys alone, uppercased and sorted, one per line — a pointer set, -# never the prose they were extracted from (rule 4). -# The mutation drops the speculation boundary, so a key carried only by an adopted -# commit is claimed again and the waiter races the PR it is waiting on. -# The mutation makes --closing-only fall through anyway, so a `Refs:` citation is -# read as a claim — CLOUD-480's shape, which is the whole reason the flag exists. -# The anchor carries a leading tab: CLOUD-674 moved this branch inside the `else` -# of the `--refs-first-only` split, and the un-indented pattern silently stopped -# matching — reported by `mutant` as `inert-mutation`, which is the reading that -# catches a mutation whose subject moved out from under it. -#MUTANT claimed-keys-closing-only-falls-through|s/^\tif \[\[ "\$closing_only" -eq 0 \]\]; then$/\tif true; then/|--closing-only does not fall through to a Refs: trailer -# The mutation makes --refs-first-only fall back to the closing keyword, which is -# the circularity CLOUD-674 exists to avoid: the served set would be derived from -# the body it is about to be subtracted from, and agree with it by construction. -#MUTANT claimed-keys-refs-first-falls-back|s/^if \[\[ "\$refs_first_only" -eq 1 \]\]; then$/if false; then/|--refs-first-only ignores a closing keyword in the body -#MUTANT claimed-keys-adopts-speculated|s/^\tif since=\$(spec_base_range); then$/\tif false; then/|a key carried only by a speculated commit is not claimed -set -euo pipefail - -ISSUE_RE='CLOUD-[0-9]+' -CLAIM_RE='(Closes|Fixes|Resolves)[[:space:]]+CLOUD-[0-9]+' - -# `grep -o` consumes its whole input rather than exiting on the first hit, so -# piping it is safe under pipefail — the SIGPIPE trap that bit `issue-guard` -# applies to `-q`/`-m`/`-l` only. -extract() { grep -oiE "$ISSUE_RE" <<<"$1" | tr '[:lower:]' '[:upper:]' | sort -u || true; } - -# Extra evidence the caller has and this script cannot read for itself: the -# command being guarded, or the PR body being judged. Optional — a caller with -# nothing to add closes stdin and the branch/commit sources still answer. -extra="" -[[ -t 0 ]] || extra=$(cat || true) - -explicit=0 -closing_only=0 -refs_first_only=0 -branch="" -title="" -log="" -while [[ "$#" -gt 0 ]]; do - case "$1" in - --branch | --title | --log) - # A flag with no value is a caller bug, not an empty source: silently - # reading the next flag as the value would answer about the wrong text. - [[ "$#" -ge 2 ]] || { - echo "::error:: claimed-keys: $1 needs a value" >&2 - exit 2 - } - case "$1" in - --branch) branch="$2" ;; - --title) title="$2" ;; - --log) log="$2" ;; - esac - explicit=1 - shift 2 - ;; - --closing-only) - closing_only=1 - shift - ;; - --refs-first-only) - refs_first_only=1 - shift - ;; - *) - echo "::error:: claimed-keys: unknown argument" >&2 - exit 2 - ;; - esac -done - -# Each flag names a different SINGLE source, so both together is not an -# intersection to compute — it is a caller that has not decided which question it -# is asking. Exit 2 is this file's "could not read the input" code. -if [[ "$closing_only" -eq 1 && "$refs_first_only" -eq 1 ]]; then - echo "::error:: claimed-keys: --closing-only and --refs-first-only each name one source; pick one" >&2 - exit 2 -fi - -# THE COMMITS THIS BRANCH AUTHORED, WHICH IS NARROWER THAN THE ONES IT CARRIES -# (CLOUD-748). `land`'s speculative linearization (CLOUD-369) rebases a waiting -# branch onto the lease holder's published head, and says so plainly: it "puts -# ANOTHER BRANCH'S unlanded commits into this branch's history". Those commits -# carry the holder's `CLOUD-*` keys, and the holder has an open PR by -# construction — so `claim-race-check`, reading this file, reported the waiter as -# racing the very PR the speculation bet on. Measured twice in one session, on -# CLOUD-718 and then CLOUD-719, each costing a full `verify`. -# -# It is not a race and it is not intermittent: the two gates were individually -# correct and jointly unsatisfiable. `land` would have unwound the bet at the top -# of the next lap, but a refused gate ends the lap, so the settle never ran. -# -# `BATTEN_SPEC_BASE` is the boundary, exported by `land` when it speculates and -# cleared when it settles. It is the commit the branch was replayed ONTO, so -# everything after it on HEAD is this branch's own work — not `spec_undo`, which -# is HEAD *before* the rebase and is left off the branch entirely. -# -# HONOURED ONLY WHEN IT IS AN ANCESTOR OF HEAD, which is what makes a stale -# export harmless: an unwound bet, a `land` that died, or an inherited variable -# from an unrelated run all fail that test and the range falls back to -# `origin/main`. The failure direction is the wider set, which is the one that -# refuses — never the narrower one, which would silently stop catching races. -spec_base_range() { - local base="${BATTEN_SPEC_BASE:-}" - [[ -n "$base" ]] || return 1 - git merge-base --is-ancestor "$base" HEAD 2>/dev/null || return 1 - printf '%s\n' "$base" -} - -if [[ "$explicit" -eq 0 ]]; then - branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null) || exit 0 - if since=$(spec_base_range); then - log=$(git log --format='%B' "$since"..HEAD 2>/dev/null || true) - elif git rev-parse --verify -q origin/main >/dev/null 2>&1; then - log=$(git log --format='%B' origin/main..HEAD 2>/dev/null || true) - fi -fi - -# Source 3 in isolation. `Refs:` is matched with only whitespace between it and -# the key, so this yields the FIRST key of each trailer and not the citations -# after it — which is the distinction CLOUD-674's predicate rests on, and it is a -# property of this pattern rather than an extra filter. -refs_first() { extract "$(grep -oiE "Refs:[[:space:]]*CLOUD-[0-9]+" <<<"$log" || true)"; } - -if [[ "$refs_first_only" -eq 1 ]]; then - claimed=$(refs_first) -else - claimed=$(extract "$(grep -oiE "$CLAIM_RE" <<<"$extra $log" || true)") - if [[ "$closing_only" -eq 0 ]]; then - [[ -n "$claimed" ]] || claimed=$(extract "$branch $title") - [[ -n "$claimed" ]] || claimed=$(refs_first) - fi -fi - -[[ -n "$claimed" ]] && printf '%s\n' "$claimed" -exit 0 diff --git a/mise-tasks/closing-key-check.sh b/mise-tasks/closing-key-check.sh index aca27ff2e..feb3a0fe4 100755 --- a/mise-tasks/closing-key-check.sh +++ b/mise-tasks/closing-key-check.sh @@ -190,9 +190,9 @@ fi # `verify` runs when `claim-race-check` hit it. served= if [[ -n "${SERVED_LOG_GIVEN:-}" ]]; then - served=$("$(dirname "$0")/claimed-keys.sh" --refs-first-only --branch "" --title "" --log "$SERVED_LOG" 2>/dev/null || true) + served=$(batten claim keys --refs-first-only --branch "" --title "" --log "$SERVED_LOG" 2>/dev/null || true) else - served=$("$(dirname "$0")/claimed-keys.sh" --refs-first-only 2>/dev/null /dev/null /dev/null || true) +claimed=$(printf '%s' "$body" | batten claim keys 2>/dev/null || true) violations=0 report() { # pointer-only: a coordinate, never the prose diff --git a/mise-tasks/in-progress-drain.sh b/mise-tasks/in-progress-drain.sh index e92f48129..6cf00d75e 100755 --- a/mise-tasks/in-progress-drain.sh +++ b/mise-tasks/in-progress-drain.sh @@ -197,7 +197,7 @@ if [[ -n "${DRAIN_MERGED_PRS:-}" ]]; then drain_evidence=(--merged-prs "$DRAIN_MERGED_PRS") else gathered="${TMPDIR:-/tmp}/merged-pr-keys.$$" - if ! "$here/merged-pr-keys.sh" >"$gathered" 2>/dev/null; then + if ! batten claim merged >"$gathered" 2>/dev/null; then rm -f "$gathered" cannot_look "no DRAIN_MERGED_PRS was set and \`merged-pr-keys\` could not gather the evidence itself. Run it directly to see why, or set DRAIN_MERGED_PRS to a prepared file." fi diff --git a/mise-tasks/landed-check.sh b/mise-tasks/landed-check.sh index 640f5358a..3dd090223 100755 --- a/mise-tasks/landed-check.sh +++ b/mise-tasks/landed-check.sh @@ -203,7 +203,6 @@ log=$(git log --format='%B' origin/main 2>/dev/null || true) # checkout leaks into a question about `main`'s history, and `--closing-only` # because its branch-name and `Refs:` fallbacks answer "what does this branch # claim", which is a different question and would readmit the citation. -here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # THE LOG GOES ON STDIN, NOT IN ARGV. `main`'s history is 1.27 MB here and an # argv that size is `Argument list too long` — exit 126, which the disjunction # below would have read as "nothing claimed" and reported as a clean column. @@ -212,7 +211,7 @@ here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) # `claimed-keys`' own documented channel for evidence the caller holds, so this # is the interface it already offers rather than a workaround. The three empty # explicit sources are what stop it reading THIS checkout's HEAD instead. -if ! claimed_ids=$(printf '%s' "$log" | "$here/claimed-keys.sh" --closing-only --branch "" --title "" --log "" 2>/dev/null); then +if ! claimed_ids=$(printf '%s' "$log" | batten claim keys --closing-only --branch "" --title "" --log "" 2>/dev/null); then echo "::error:: claimed-keys could not read main's log, so a claim cannot be told from a mention. That is not a clean board." >&2 exit 2 fi diff --git a/mise-tasks/merged-pr-keys.sh b/mise-tasks/merged-pr-keys.sh deleted file mode 100755 index 43e32b3e6..000000000 --- a/mise-tasks/merged-pr-keys.sh +++ /dev/null @@ -1,121 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Emit the issue keys each MERGED pull request closed, as `` — the evidence landed-check decides on (CLOUD-814)" -# -# CLOUD-814. CLOUD-804 made landedness a disjunction and moved half of it to the -# caller: `landed-check --merged-prs `, refusing with exit 2 when the file -# is absent rather than reporting a column it never checked. That refusal is -# right — at 3% commit-keyword coverage a commits-only reading answers "clean" -# almost always — but NOTHING PRODUCED THE FILE. Measured on `cdc6644`, the only -# two references in the repository were both consumers, and the only thing that -# had ever built one was an ad-hoc script under /tmp that dies with its -# container. So `mise run in-progress-drain` was unrunnable in a fresh clone: -# non-negotiable rule 2 inverted, the gate runnable and its input feedforward. -# -# THIS DECIDES NOTHING, which is what lets it hold a credential. The board-payload -# gates — `landed-check`, `graph-check`, `done-check`, `released`, `done-pr-check`, -# `board-move-guard` — are uniformly gh-free, and that is the agents-fetch-gates- -# decide split rather than an accident. This is the FETCH side: it gathers -# evidence and emits it, `landed-check` remains the one authority on landedness, -# and `claimed-keys` remains the one authority on what a closing keyword is. The -# same split `branch-age-check` draws when it reads the remote's refs. -# -# WHY IT ASKS `claimed-keys` PER PULL REQUEST rather than grepping the bodies -# itself: a second copy of CLAIM_RE is a second authority, and CLOUD-378 was -# filed for applying `claimed-keys` to one side of a comparison and not the -# other. Per-PR invocation is also what keys each answer to its number. It passes -# `--closing-only` (CLOUD-804) because the branch-name and `Refs:` fallbacks -# answer "what does this branch claim", which would readmit the citation this -# whole chain exists to refuse. -# -# TRUNCATION IS THE CORRECTNESS RISK, NOT A DETAIL, and it is measured rather -# than anticipated. `gh pr list --state merged --limit 400` returned exactly 400 -# and cut the range at #161, hiding #170, #337 and #339, which then had to be -# checked one at a time by hand. This repository has 554 merged pull requests. -# -# The direction matters: a truncated evidence file is an UNDER-report, so a row -# whose work landed reads as live work and the drain stops naming it — silently, -# which is the property that makes it worse than an over-report. So the limit is -# explicit and the count is CHECKED AGAINST IT: a result equal to the limit means -# capped, and capped is "could not look", never a short answer. That is the -# general form of the issue's "pages to exhaustion", and it keeps working when -# the repository outgrows whatever number is written here. -# -# An empty result is also could-not-look, copying `branch-age-check`'s reading -# that a remote reporting no branches at all cannot be true of a repository with -# a trunk. An empty evidence file would silently disarm half the disjunction. -# -# Injectable, so the suite runs offline with no `gh` and no network: -# MERGED_PR_KEYS_SOURCE file of the JSON `gh pr list --json number,body` returns -# MERGED_PR_KEYS_LIMIT the fetch limit, so a case can drive the cap cheaply -# -# Exit 0 with rows / 2 could-not-look. There is no exit 1: this reports evidence -# and decides no verdict, so it has no "violation" to report. -# -# The mutations target the two conjuncts a caller cannot see for itself. -#MUTANT merged-pr-keys-ignores-truncation|s@^if \[\[ "\$count" -ge "\$limit" \]\]; then@if false; then@|a reading at the fetch limit is refused as truncated, not returned short -#MUTANT merged-pr-keys-accepts-empty|s@^if \[\[ "\$count" -eq 0 \]\]; then@if false; then@|an empty forge answer is could-not-look, never an empty evidence file -set -euo pipefail - -cannot_look() { - echo "::error:: merged-pr-keys: $1" >&2 - exit 2 -} - -here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -limit="${MERGED_PR_KEYS_LIMIT:-5000}" -case "$limit" in -'' | *[!0-9]*) cannot_look "MERGED_PR_KEYS_LIMIT is not a number ('$limit')" ;; -esac - -# --- the reading -------------------------------------------------------------- -if [[ -n "${MERGED_PR_KEYS_SOURCE:-}" ]]; then - raw=$(cat "$MERGED_PR_KEYS_SOURCE" 2>/dev/null) || - cannot_look "cannot read MERGED_PR_KEYS_SOURCE ($MERGED_PR_KEYS_SOURCE)" -else - command -v gh >/dev/null 2>&1 || cannot_look "\`gh\` is not on PATH, so merged pull requests cannot be read. Set MERGED_PR_KEYS_SOURCE to a saved \`gh pr list --json number,body\` payload instead." - raw=$(gh pr list --state merged --limit "$limit" --json number,body 2>/dev/null) || - cannot_look "cannot list merged pull requests — is \`gh\` authenticated?" -fi - -jq -e 'type == "array"' <<<"$raw" >/dev/null 2>&1 || - cannot_look "the merged-pull-request reading is not a JSON array" - -count=$(jq 'length' <<<"$raw") - -# A result AT the limit is indistinguishable from one truncated by it, and the -# truncated reading is the dangerous one. Refuse rather than answer short. -if [[ "$count" -ge "$limit" ]]; then - cannot_look "the forge returned $count pull request(s), which is the fetch limit — the answer is truncated and a truncated evidence file makes landed work read as live. Raise MERGED_PR_KEYS_LIMIT above $limit and run again." -fi - -if [[ "$count" -eq 0 ]]; then - cannot_look "the forge reports no merged pull requests at all, which cannot be true of a repository with a trunk. That is a reachability problem, not an empty answer." -fi - -# --- the extraction, delegated ------------------------------------------------ -# -# One `claimed-keys` call per pull request, with the body on stdin: argv cannot -# carry a body of arbitrary size (`landed-check` met `Argument list too long` at -# 1.27 MB), and stdin is the channel `claimed-keys` documents for exactly the -# evidence a caller holds and it cannot read for itself. -rows="" -while IFS= read -r idx; do - number=$(jq -r ".[$idx].number" <<<"$raw") - case "$number" in - '' | null | *[!0-9]*) cannot_look "a pull request in the reading carries no usable number" ;; - esac - body=$(jq -r ".[$idx].body // \"\"" <<<"$raw") - keys=$(printf '%s' "$body" | "$here/claimed-keys.sh" --closing-only --branch "" --title "" --log "" 2>/dev/null) || - cannot_look "claimed-keys could not judge the body of #$number" - [[ -n "$keys" ]] || continue - while IFS= read -r key; do - [[ -n "$key" ]] || continue - rows+="$key $number"$'\n' - done <<<"$keys" -done < <(jq -r 'keys_unsorted[]' <<<"$raw") - -# Sorted and de-duplicated, so two runs over the same forge are byte-identical. -# Keys and numbers only — never a title or a body, which is where the keyword -# lives and which rule 4 keeps out of a report. -[[ -n "$rows" ]] && printf '%s' "$rows" | sort -u -exit 0 diff --git a/mise.toml b/mise.toml index 42b167619..376e40139 100644 --- a/mise.toml +++ b/mise.toml @@ -617,7 +617,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" BATS_TEST_TIMEOUT = "300" REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "mise,attestation-check,engine-checks-green,engine-config,engine-doctor,engine-landed,engine-perf,engine-mcp,engine-pinned,engine-ready,engine-verdict,engine-wiring,engine-surface,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow,engine-lease,engine-handler" +MUTANT_GATES = "mise,attestation-check,engine-checks-green,engine-config,engine-doctor,engine-landed,engine-perf,engine-mcp,engine-pinned,engine-ready,engine-verdict,engine-wiring,engine-surface,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land-divergence-assert,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,spawn-widening,nextest-slow,engine-lease,engine-handler" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. @@ -3018,80 +3018,6 @@ fi echo "lock-check: mise.lock is complete and current" ''' -# ONE TURN'S CROSS-TRIPLE TYPE-CHECK, DETACHED (CLOUD-1731). -# -# `cross-check` runs in exactly one place — inside `verify` — so "this does not -# compile on Windows" is learned at the cadence of the whole gate, which in -# practice means after the author has moved on. `rules/rust.md` states the cost -# in its own words ("the next edit to the other one is discovered by CI") and -# CLOUD-1148 is the recorded instance: the `windows` job reddening alone, and the -# first fix making it worse. -# -# AFFORDABLE BECAUSE OF THE RECEIPT, measured 2026-09-09 rather than assumed: -# a re-run whose inputs, command and toolchain are unchanged answers from -# `step-receipt.sh` in **1.0s**; a run that must re-derive takes 54s cold. So the -# per-turn cost is a second on every turn that changed no Rust, and 54s on the -# turns that changed some — which are the turns that need it. -# -# DETACHED, BECAUSE A HANDLER THAT WAITS IS A HANDLER THAT TAXES EVERY TURN. The -# verdict is not needed before the turn proceeds; it is needed before the author -# stops thinking about the change, which is a much weaker deadline. `verify` -# remains the blocking authority and this never substitutes for it. -# -# THE FAILURE IS REPORTED ON THE NEXT TURN AND SUCCESS IS SILENT. A log nobody -# reads is sensor-only (non-negotiable rule 2), so the marker file is what turns -# this from a log into a mechanism: the next invocation prints it and clears it. -# Success says nothing, because a line on every turn is noise that trains the -# reader to skip the one turn it matters. -# -# THE PATTERN TOLERATES COLOUR RATHER THAN SUPPRESSING IT, and that is the third -# draft of this line. `[env]` sets `CARGO_TERM_COLOR = "always"` repo-wide, so a -# machine-read log carries escapes between `: ` and `error`: the first draft -# scraped `-->` and got nothing, the second matched `: error` and got nothing, -# and the third set `CARGO_TERM_COLOR=never` on the invocation — which ALSO got -# nothing, because a task's own environment beats a value inherited from the -# caller. Three silent failures, each announcing a break with no pointer. -# -# So the pattern anchors on the SHAPE `--message-format=short` guarantees — -# `path:line:col: ` — and lets `.*` span whatever decoration sits after it. It -# needs no escape literal, survives either colour setting, and excludes the -# trailing `error: could not compile` summaries, which carry no location and -# would have spent the cap on nothing. -# -# BOUNDED AT FIVE LINES, AND BOUNDED IS NOT THE SAME AS USEFUL. The first draft -# emitted one fixed sentence, which is cheap and costs the reader a whole turn -# re-running the check to learn what broke — bounded and useless is its own -# waste. What it emits instead is the first THREE `path:line` pointers and a -# path to the rest: actionable enough to fix without a second run, and -# pointer-only per non-negotiable rule 4, since a compiler's full output is the -# payload and these are the pointer. The window cost of a failing turn is five -# lines; of a passing turn, zero. -# -# `[hook_output] max_repeats = 1` IS SATISFIED BY THE CLEAR, not by luck: the -# marker is removed once printed, so one failure is announced once rather than -# on every turn until it is fixed — which is what would otherwise trip that -# ceiling and, worse, train the reader to skip the line. -# -# ON STDOUT, AND THAT IS THE WHOLE DIFFERENCE BETWEEN AN ALARM AND A LOG. -# `handler.rs` states the contract: "Exit `0` with stdout: advisory text, to be -# merged into Batten's own" — so stdout is what reaches the AGENT and stderr is -# what reaches a log nobody opens. The first draft of this line wrote the marker -# to stderr, which would have printed the failure, cleared it, and shown it to -# no one: a sensor wearing an alarm's clothes, one layer inside the rule against -# exactly that. Caught in review before it landed. -# -# ONE LINE JOINED BY `;` RATHER THAN A `"""` BODY, for `deps-install`'s reason: -# `inline-task-bodies-not-growing-basic` is `non_increasing` against -# `origin/main` with no `admits_with`, so a new block body is refused and the -# only routes are extraction or a waiver. Neither is worth spending here. -# -# `batten singleton` RATHER THAN A PROCESS PROBE: the lock is the declared -# mechanism for "may a second copy start in this clone", and polling the process -# table for one is the shape `polls-a-local-process` refuses. -[tasks."cross-turn"] -description = "One turn's cross-triple type-check, detached — reports the previous run's failure and never blocks the turn" -run = "f=target/cross-turn.fail; if [ -f $f ]; then cat $f; rm -f $f; fi; if batten singleton acquire cross-turn $$ >/dev/null 2>&1; then (mise run cross-check >target/cross-turn.log 2>&1 || { echo '::error:: cross-turn: a declared target did not type-check'; grep -E '^[^ :]+:[0-9]+:[0-9]+: .*error' target/cross-turn.log | head -3 | sed 's/^/ /'; echo ' (full output: target/cross-turn.log)'; } >$f; batten singleton release cross-turn >/dev/null 2>&1) & fi; exit 0" - [tasks.cross-check] description = "Type-check for other targets from Linux (no macOS runner needed)" # The `rustup target add` below is not idempotent against a half-installed @@ -3137,13 +3063,7 @@ for t in x86_64-pc-windows-gnu; do # # It cannot red on a third-party warning: cargo compiles registry dependencies # with `--cap-lints allow`, so only workspace code is held to this. - # `--message-format=short` is the compiler doing the reduction instead of a - # grep guessing at it: one `path:line:col: error: …` per diagnostic, no span - # art and no `-->` lines belonging to notes and helps. Nothing parses this - # task's stdout — the loop reads the exit status — so the format is free to be - # the useful one, and `cross-turn` extracts pointers from it without having to - # tell an error's span from a note's (CLOUD-1731). - if ! RUSTFLAGS="-D warnings" cargo check --workspace --all-targets --target "$t" --message-format=short; then + if ! RUSTFLAGS="-D warnings" cargo check --workspace --all-targets --target "$t"; then echo "::error:: cross-check: $t does not type-check cleanly (warnings are denied here — see CLOUD-397)." >&2 exit 1 fi @@ -4172,64 +4092,12 @@ echo "verify: fast-forward-green — rebased on latest main, ci + cross + commit [tasks.commit-msg] description = "Gate: one pending commit message's subject follows the convention (policy: [commit] in batten.toml)" -# THE BINARY ON PATH FIRST, AND `cargo run` ONLY WHERE THERE IS NONE — CLOUD-1620's -# shape, applied to the path that pays it most (CLOUD-1397). -# -# A GIT HOOK IS A BATTEN HOOK, and is held to the same published ceiling. README's -# `wired` row is 8.0ms p50 against a ≤100ms budget, and `perf-assert` enforces it -# on the mediated surface; nothing argues the commit-msg surface is exempt just -# because git is the harness rather than Claude Code. -# -# MEASURED 2026-09-09 on this container, same message, warm, three runs: this -# task as the hook fired it, 582ms; `cargo run --quiet` with NOTHING to rebuild -# 291ms; `target/debug/batten` 176ms; the release binary 125ms; and 8687ms on the -# first commit after touching one `crates/batten/src` file. -# -# A FIRST PASS AT THESE NUMBERS READ 12ms FOR THE RELEASE BINARY AND IT WAS A -# MEASUREMENT OF A FAILURE. `target/release/batten` was 0.0.155 against a tree at -# 0.0.158 and REFUSED `batten.toml` outright — "vocabulary `action`: `fix` is -# declared and no class or route name spends it" — so the 12ms was a config load -# aborting, not a gate reaching a verdict. Rebuilt, the same call is 125ms. A -# timing taken over a non-zero exit is not a timing of the work, and a stale -# binary is exactly the shape that produces one (CLOUD-1688). -# -# A HOOK MUST NEVER COMPILE, and this is not a preference. Rebuilding that stale -# release binary took ~4 minutes on this container (15:48 -> 15:52, `lto = "thin"` -# in `[profile.release]`), so a hook that rebuilds when it finds a stale binary -# would spend four minutes at the moment an author saves. The 8687ms debug case is -# the same defect an order of magnitude cheaper: a hook running the compiler is -# not a slow gate, it is a different program. The build belongs at provisioning, -# where `session:batten` already puts it. The -# `_.path` entry above already resolves a bare `batten` to THIS checkout's -# `target/release`, built by `session:batten` -> `install:local` at session start -# with an `::error::` when it cannot be — so the fast branch is the tree's own -# engine, not a stale installed one, and the property the previous comment claimed -# for `cargo run` ("judge the working tree's engine and config as the pair that -# ships") is kept rather than traded. -# -# THE CONDITION TESTS PRESENCE AND NOTHING ELSE, which is where `attribution -# identity`'s spelling must not be copied. That one reads -# `if command -v batten && batten ; then :; else cargo run …` and its own -# comment claims `if`/`else` avoids the fallthrough that `a && b || c` has — but -# those two are the same program: a binary that EXISTS and legitimately REFUSES -# takes the else branch either way, so the shape it warns about is the shape it -# ships. Harmless for a write that wants a retry; wrong for a gate, which would -# then pay the build precisely when it refuses and run the whole judgement twice -# to reach the same verdict. A gate that is slowest exactly when it says no is a -# gate authors learn to stop running. -# -# So the verdict is not in the condition. Resolve, then run once, and let the -# exit code be the gate's own. -# -# The fallback is what keeps a runner green: CI has no installed binary and -# `bash: line 25: batten: command not found` is a measured failure there -# (`auto-bot-land.yml:305-310`), so the branch that builds stays for the host that -# needs it and never runs where a human is waiting. -# +# `cargo run` for the same reason `batten-check` and `commit-attribution` use it: +# the gate must judge the working tree's engine and config as the pair that ships. # The pattern itself is `batten.toml`'s, not this file's — a rule about what a # commit may BE is the engine's, and `mise.toml` configures how tools run # (CLOUD-701). -run = 'if command -v batten >/dev/null 2>&1; then batten commit check --message "{{arg(name="file")}}"; else cargo run --quiet -p batten -- commit check --message "{{arg(name="file")}}"; fi' +run = 'cargo run --quiet -p batten -- commit check --message "{{arg(name="file")}}"' [tasks.commit-attribution] description = "Gate: no vendor authorship, branding or session links in BASE_SHA..HEAD_SHA (policy: [attribution] in batten.toml)" @@ -4242,12 +4110,7 @@ description = "Gate: no vendor authorship, branding or session links in one pend # The commit-msg-hook half. Same policy and same engine as `commit-attribution`; # only the object differs — a message on disk plus the identity `git var` says # git is about to stamp, rather than commits that already exist. -# -# Resolve-then-run, for `commit-msg`'s reasons above and its measurements: these -# two are the pair that fires on EVERY commit, so the 582ms each was paying was -# the whole per-commit tax, and the 8687ms first-commit-after-an-edit case was a -# git hook running the compiler twice over. -run = 'if command -v batten >/dev/null 2>&1; then batten attribution check --message "{{arg(name="file")}}"; else cargo run --quiet -p batten -- attribution check --message "{{arg(name="file")}}"; fi' +run = 'cargo run --quiet -p batten -- attribution check --message "{{arg(name="file")}}"' [tasks.attribution-identity] description = "Write: set this clone's repo-local git identity when it is unset or carries a denied vendor identity" diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index b47890915..47d821100 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -963,6 +963,45 @@ mentions_retired(path, line, gone) if { contains(line, span) } +# AND THE SCRIPT-DIRECTORY BINDING ITSELF, once this delta has taken its last +# spend (CLOUD-1752). +# +# CLOUD-843's arm above closed this for a `local … reg …` DECLARATION. It does +# not reach the other spelling this tree uses just as often — a standalone +# `here=$(cd "$(dirname "$0")" && pwd)` whose only consumer was a call to the +# program being retired. Repoint that call at a verb and `here` is bound and +# never spent, which `shellcheck` refuses as SC2034; keep the call and the +# program cannot die. That is the same "no landable spelling in either +# direction" CLOUD-843 records, one assignment form further on, and it was +# measured retiring `claimed-keys.sh` out of `mise-tasks/landed-check.sh`. +# +# WHY THIS IS NOT A LICENCE: the binding may go only when EVERY line in the BASE +# that spends the variable is a call to the path this delta deletes. One +# surviving spend and the arm does not hold, so a caller cannot drop a binding it +# still uses — which is exactly the conjunct `case_earns_removal`'s second arm +# relies on, read over a whole file rather than one `@test` block. +spends_only_the_retired(path, variable, gone) if { + spends := {line | + some line in delta["base-lines"][path] + some spelling in {concat("", ["$", variable]), concat("", ["${", variable, "}"])} + contains(line, spelling) + } + count(spends) > 0 + every line in spends { + some form in { + concat("", ["$", variable, "/", basename(gone)]), + concat("", ["${", variable, "}/", basename(gone)]), + } + contains(line, form) + } +} + +mentions_retired(path, line, gone) if { + variable := assigned_name(line) + variable in script_dir_vars(path) + spends_only_the_retired(path, variable, gone) +} + # A REPOINTING: the removed line with the retired path replaced by a successor # its own ledger row declares, and nothing else changed (CLOUD-1121). # diff --git a/tests/claimed-keys.bats b/tests/claimed-keys.bats deleted file mode 100644 index 2a471e584..000000000 --- a/tests/claimed-keys.bats +++ /dev/null @@ -1,342 +0,0 @@ -#!/usr/bin/env bats -# subject: mise-tasks/claimed-keys.sh -# The claim derivation, split out of `issue-guard` when `deferral-check` needed -# the same answer (CLOUD-338). Two guards disagreeing about which issue a PR -# claims would be worse than either misfiring, and a second copy is how that -# happens — so the precedence is pinned here, once, and both callers read it. -# -# WHICH issue a branch claims is narrower than which it mentions. That -# distinction is the whole point: `issue-guard` produced false positives against -# its own PR twice by conflating them, and `deferral-check` exempted a deferral -# using the key `issue-guard` had forced onto the PR. - -setup() { - KEYS="$BATS_TEST_DIRNAME/../mise-tasks/claimed-keys.sh" - REPO="$BATS_TEST_TMPDIR/repo" - mkdir -p "$REPO" - # `git init -b`, never `git branch -f`: forcing the checked-out branch fails, - # and CI hides it only because the runner still defaults to `master` - # (CLOUD-282). A commit is required — an unborn branch has no HEAD to resolve. - git init -q -b claude/cloud-777-fixture "$REPO" - cd "$REPO" || return 1 - commit() { - git -c user.email=t@t -c user.name=t -c commit.gpgsign=false \ - commit -q --allow-empty -m "$1" - } - commit "fixture" - # The commit sources read `origin/main..HEAD`, so the fixture needs that ref - # or the log is empty and only the branch name ever answers. Pointing it at - # the base commit makes every later commit part of "this branch's work". - git update-ref refs/remotes/origin/main HEAD -} - -@test "a branch naming one issue is an unambiguous claim" { - run bash -c "'$KEYS' )`, so for a PR you did not - # author the title is the other self-declaration of what the work is. - run bash -c "'$KEYS' --title 'feat(x): a thing (CLOUD-268)' - git checkout -q -b claude/keyless-bundle - commit "$1" - SPEC_BASE=$(git rev-parse HEAD) - commit "some work of this branch's own" -} - -@test "a key carried only by a speculated commit is not claimed" { - # THE DISCRIMINATOR (CLOUD-418, CLOUD-748 §7b). Before the boundary existed no - # fixture could express an adopted commit at all; `mise run mutant` drives this - # red through `claimed-keys-adopts-speculated`. - speculate_onto "fix(git): something else entirely - -Refs: CLOUD-718" - run bash -c "BATTEN_SPEC_BASE='$SPEC_BASE' '$KEYS' — the reading a `gh pr list --json number,body` would have returned. -src() { printf '%s' "$1" >"$SRC"; } - -@test "a closing keyword in a merged body emits one row" { - src '[{"number":9,"body":"work\n\nCloses CLOUD-9"}]' - run "$TASK" - [ "$status" -eq 0 ] - [ "$output" = "CLOUD-9 9" ] -} - -@test "Fixes and Resolves are claims too" { - src '[{"number":9,"body":"Fixes CLOUD-1"},{"number":10,"body":"Resolves CLOUD-2"}]' - run "$TASK" - [ "$status" -eq 0 ] - [[ "$output" == *"CLOUD-1 9"* ]] - [[ "$output" == *"CLOUD-2 10"* ]] -} - -# The whole chain exists to refuse this: CLOUD-480 was swept to In Review on a -# `Refs:` trailer and sat wrong for 4.5 hours. -@test "a Refs: trailer is a mention and emits nothing" { - src '[{"number":9,"body":"work\n\nRefs: CLOUD-9"}]' - run "$TASK" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "a bare citation in prose emits nothing" { - src '[{"number":9,"body":"on the newer compiler. That is CLOUD-271'"'"'s shape."}]' - run "$TASK" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "several keys in one body emit several rows, all keyed to that PR" { - src '[{"number":11,"body":"Closes CLOUD-3\nFixes CLOUD-4"}]' - run "$TASK" - [ "$status" -eq 0 ] - [[ "$output" == *"CLOUD-3 11"* ]] - [[ "$output" == *"CLOUD-4 11"* ]] -} - -@test "a null body is data, not a crash" { - src '[{"number":12,"body":null},{"number":13,"body":"Closes CLOUD-5"}]' - run "$TASK" - [ "$status" -eq 0 ] - [ "$output" = "CLOUD-5 13" ] -} - -@test "two runs over the same reading are byte-identical" { - src '[{"number":11,"body":"Closes CLOUD-30"},{"number":2,"body":"Closes CLOUD-4"}]' - run "$TASK" - local first="$output" - run "$TASK" - [ "$output" = "$first" ] -} - -# THE MEASURED DEFECT (CLOUD-814). `gh pr list --limit 400` returned exactly 400 -# and cut the range at #161, hiding #170/#337/#339. A truncated evidence file is -# an UNDER-report, so landed work reads as live and the drain stops naming it. -@test "a reading at the fetch limit is refused as truncated, not returned short" { - src '[{"number":9,"body":"Closes CLOUD-9"},{"number":10,"body":"Closes CLOUD-10"}]' - MERGED_PR_KEYS_LIMIT=2 run "$TASK" - [ "$status" -eq 2 ] - [[ "$output" == *"truncated"* ]] - [[ "$output" != *"CLOUD-9 9"* ]] -} - -@test "a reading below the fetch limit is answered" { - src '[{"number":9,"body":"Closes CLOUD-9"}]' - MERGED_PR_KEYS_LIMIT=2 run "$TASK" - [ "$status" -eq 0 ] - [ "$output" = "CLOUD-9 9" ] -} - -@test "an empty forge answer is could-not-look, never an empty evidence file" { - src '[]' - run "$TASK" - [ "$status" -eq 2 ] - [[ "$output" == *"cannot be true of a repository with a trunk"* ]] -} - -@test "an unreadable source is exit 2" { - MERGED_PR_KEYS_SOURCE="$BATS_TEST_TMPDIR/absent.json" run "$TASK" - [ "$status" -eq 2 ] -} - -@test "a source that is not a JSON array is exit 2" { - src 'not json' - run "$TASK" - [ "$status" -eq 2 ] -} - -@test "a non-numeric limit is a caller bug, not a default" { - src '[{"number":9,"body":"Closes CLOUD-9"}]' - MERGED_PR_KEYS_LIMIT=lots run "$TASK" - [ "$status" -eq 2 ] -} - -# Rule 4: the keyword lives in the body, and the body must not reach the report. -@test "output carries no PR body" { - src '[{"number":9,"body":"customer detail here\n\nCloses CLOUD-9"}]' - run "$TASK" - [ "$status" -eq 0 ] - [[ "$output" != *"customer detail"* ]] -} From 7fb7d52f54ff3dd671e7003a4ec9eea3d445c054 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 19:52:05 +0000 Subject: [PATCH 15/32] fix(policy): withdraw the sleep-loop exemption and correct every remedy it left behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `run-shape` exempted a backgrounded `sleep` wrapped in `until`/`while` on the argument that such a loop exits on its condition rather than on the clock. That is true and was never the question: the loop still spends the session hand-rolling the wake-up a backgrounded task's exit notification already delivers (measured 523 of 524). CLOUD-1337 priced the exemption at eleven duplicate watchers running 9h35m; measured again 2026-09-09, one conditioned loop ran 3h34m in this session and the detector was a human reading `ps`. `background-timer` and `polls-a-local-process` now PARTITION on `count(process_probes)` and nothing falls between them, so every backgrounded sleep is refused, with or without a condition. `foreground-sleep` was already total. There is no sanctioned sleep left in either posture. Three remedy strings named the shape that is now refused, so they are rewritten rather than left printing it: both `task run first` routes, and `verdict-not-discarded`'s `reason`. That last one prescribed `>/tmp/.log 2>&1` as "the compliant form" — measured on this host, `run_in_background` already writes the output to a runtime-chosen path and hands back the path, and a windowed read of line 399,995 of 400,000 returns the tail intact. The redirect is the FALLBACK for a host that streams a command's output into the transcript, not the point; the point is backgrounding. A harness-conditional remedy is CLOUD-1695 and is not attempted here. The four cases that asserted the old allow are INVERTED rather than deleted — a deleted case documents nothing, and these are the exact shapes the rule now exists to catch. Stale prose describing the retired bash twin as a live second authority is removed from the module header, `hook.rs` and both tiers. Also fixes four surface assertions red since CLOUD-1711/1713 landed: `claim keys`, `claim merged`, `record fold` and `record show` were missing from the read-only allowlist and the committed row set, `claim keys`' two flag ids were kebab-case, and `claim merged` declared a data channel without the `-J` its sibling also omits. Refs: CLOUD-1337, CLOUD-821, CLOUD-1695, CLOUD-1711, CLOUD-1713 path write refused 77ed1569e56a83059efda4dd05de3f6e38191cf9e1be69394a5ac24d39cf866d spent Admits: c4a01a673288619fafed8d7e30d63876c8dab1d26f574667fd180a5ae0f96784 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:818484fffa482433a20a21c66d77f63adadfcbc1 Admits-epoch: 6565da66b50bbf8003951fed4cc73ab525c51121fa130a106f8abf709753fad2 Admits-author: alec@wenzowski.com Admits-prev: 1d067b325bbd89ab32715b146e4c155aa4db506fe64e9df8f643905fa7e93e6f Admits-answer-lost: The remedies stay incoherent with the mechanism they explain: run-shape now denies EVERY sleep in a loop, backgrounded or not, yet both routes still print `until ; do sleep 1; done` as the compliant form — a gate that refuses the exact shape its own remedy prescribes. Measured this turn: both postures deny and both print that target. verdict-not-discarded meanwhile prescribes `>file 2>&1`, which on this harness the runtime already performs — measured this turn, run_in_background wrote 400k lines to a harness-chosen path and handed back the path. Admits-answer-precondition: The change is to the REMEDY PROSE of three verdict rows (sleep run blocked, timer run refused, verdict-not-discarded), which lives only as `class`/`reason`/`[[verdict.route]].target` strings in batten.toml. No other surface expresses a remedy string; a policy module decides a verdict and cannot rewrite the sentence the refusal prints. The write is three contiguous string edits a reviewer reads in the diff. Admits-answer-rejected-route: config read first — rejected because I have read the rows (batten.toml:12238-12288, 3302-3330) and the read is what established the incoherence; reading again writes nothing. patch run first is a commit-message route and does not apply to a config edit. path write refused 77ed1569e56a83059efda4dd05de3f6e38191cf9e1be69394a5ac24d39cf866d spent Admits: 77ed1569e56a83059efda4dd05de3f6e38191cf9e1be69394a5ac24d39cf866d Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:818484fffa482433a20a21c66d77f63adadfcbc1 Admits-epoch: 929f0871b0ec49f601e700bf3a82dfd6f5460a4519c010b4ac84b05b2c0d5655 Admits-author: alec@wenzowski.com Admits-prev: 8361449ab96ca72fbfa563e12d3492287399473b4bea48933c4246e2051ccbbe Admits-answer-lost: The gate prints, on the refusal it just issued, that the shape it refused is permitted. A remedy naming a refused shape trains the reader to retry it — how the 3h34m loop got written. Admits-answer-precondition: Remedy prose for `timer run refused` (batten.toml:12266-12288): its `class` still told the reader a backgrounded `until`/`while` sleep "is allowed", which policy/run-shape.rego now denies. A verdict's prose lives only in this file; a module decides the token and cannot rewrite the sentence. Admits-answer-rejected-route: config read first — the rows are read and the read located the incoherence. patch run first is a commit-message route, not a config one. path write refused 464a8232c95302a9999a6def409a74a5bd940f12479132ea11c88d19f63bc539 spent Admits: 464a8232c95302a9999a6def409a74a5bd940f12479132ea11c88d19f63bc539 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:818484fffa482433a20a21c66d77f63adadfcbc1 Admits-epoch: 929f0871b0ec49f601e700bf3a82dfd6f5460a4519c010b4ac84b05b2c0d5655 Admits-author: alec@wenzowski.com Admits-prev: 77ed1569e56a83059efda4dd05de3f6e38191cf9e1be69394a5ac24d39cf866d Admits-answer-lost: The remedy prescribes a shim for a capability this runtime already has. Measured 2026-09-09: `run_in_background` wrote 400,000 lines to a runtime-chosen path and handed the path back, and a windowed read of lines 399,995-399,999 returned the tail intact. The sentence spent its length on the half that does not matter and said nothing about backgrounding. Admits-answer-precondition: Remedy prose for `verdict-not-discarded` (batten.toml:3323-3330): its `reason` prescribed `>/tmp/.log 2>&1` as "the compliant form". A rule's reason string exists only in this file. Admits-answer-rejected-route: config read first — the row is read and the read established the redundancy. patch run first is a commit-message route and does not apply. path write refused 1da26320409cf7878b068d2db4754f11f88f8630ad7da3c8f8865af903481971 spent Admits: 1da26320409cf7878b068d2db4754f11f88f8630ad7da3c8f8865af903481971 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/run-shape.rego Admits-anchor: call:818484fffa482433a20a21c66d77f63adadfcbc1 Admits-epoch: 929f0871b0ec49f601e700bf3a82dfd6f5460a4519c010b4ac84b05b2c0d5655 Admits-author: alec@wenzowski.com Admits-prev: 9672c2a4e83cd2722a20ac206ea40231f906b22b6599b65d267be272057f192f Admits-answer-lost: `mise run policy test` stays red and the withdrawal cannot land. The module would both refuse the shape and assert it clean. Admits-answer-precondition: policy/run-shape.rego's own load-time case asserted the exemption this commit withdraws, so `policy test` is red until it is inverted. A module's cases live in the module file and nowhere else. Admits-answer-rejected-route: config read first — the file is read and the read located the case. patch run first is a commit-message route, not a module one. --- AGENTS.md | 5 +- batten.toml | 75 +++---------- crates/batten/src/cli.rs | 4 +- crates/batten/src/hook.rs | 29 +++-- crates/batten/src/spec.rs | 28 +++++ crates/batten/src/surface.rs | 12 +- crates/batten/tests/it/pointer_only.rs | 69 ++++++++++++ crates/batten/tests/it/run_shape.rs | 61 +++++----- .../batten/tests/it/run_shape_guard_door.rs | 15 +-- policy/run-shape.rego | 105 ++++++++---------- 10 files changed, 231 insertions(+), 172 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4fb90f2d3..601c6d587 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,9 +133,8 @@ backgrounded task re-invokes you when it exits (measured 523/524, failures included), so the turn in between is the _designed_ state, not one to fill — **"idle" means a turn with NOTHING backgrounded**, and it is committed-and-pushed, never activity, that survives a reclaim. Manufacturing your own wake-ups with a -backgrounded `sleep N; tail log` is a timer where an exit condition belongs, -duplicating the notification (490 in one session, 2 changed a decision); refused -by `run-shape-guard`. To ask what a live task is _doing_, `mise run alive`. +backgrounded `sleep N; tail log` duplicates it (490 in one session, 2 changed a +decision); **A LOOP IS NO EXEMPTION** (CLOUD-1337) — EVERY sleep is refused, either posture. Live task? `mise run alive`. **Two habits defeat this silently, both failing green:** piping a `mise run` into a pager (the exit status becomes the pager's) or detaching it with `nohup`/`&` diff --git a/batten.toml b/batten.toml index 43c55656f..95575a6d6 100644 --- a/batten.toml +++ b/batten.toml @@ -1966,49 +1966,9 @@ regex = 'CLOUD-[0-9]+' # `[[pattern]]` row and not a literal in the crate: the core stays repo-agnostic, # and a consumer whose forge spells the set differently declares its own row # rather than patching the engine. -# BLANK, NEVER SPACE, and the difference is a measured false positive rather than -# a nicety (CLOUD-1752). `[[:space:]]` matches a NEWLINE, so the anchor above -# stopped meaning "immediately before a key" the moment a body put the verb at the -# end of one line and the key at the start of the next — which ordinary prose does -# constantly. Measured 2026-09-09 over this repository's 713 merged pull requests: -# 16 rows read as CLOSED that no body closes, `mise-tasks/merged-pr-keys.sh` -# emitting none of them. PR #163 is the shape: -# -# ## The residue survived the fix -# -# CLOUD-223 taught `.claude/hooks/session-start.sh` … -# -# A heading ending in "the fix", a blank line, then a citation — read as a claim. -# The harm runs the dangerous way: a false CLAIM tells `in-progress-drain` a row -# landed, and it drains a row that is still live. -# -# `[[:blank:]]` is space and tab and nothing else, so the verb must sit on the -# key's own line — which is what the paragraph above always said this row did. -# A NEGATED CLOSING VERB IS NOT A CLAIM, and the row above cannot see one -# (CLOUD-1752). Its anchor decides the text IMMEDIATELY before a key, which is -# exactly what makes `does not close CLOUD-1` match: the prefix ends in `close` -# and the negation sits one word further back, outside what an end-anchored -# pattern can reach. Rust's regex has no lookbehind, so the guard is a second row -# rather than a cleverer first one. -# -# MEASURED 2026-09-09 over this repository's 713 merged pull requests: SIX rows -# read as closed by a body that says in so many words that it does not close them -# — `## Why this does NOT close CLOUD-1074`, `It also does not close CLOUD-673`, -# `**This does not close CLOUD-1050`, `## Why this does not close CLOUD-607`, -# `Filed, not fixed: CLOUD-466`. Writing out why a change does NOT close a row is -# a habit this repository actively encourages, so the false positive is not rare -# and it runs the dangerous way: a false CLAIM moves a live row. -# -# ANCHORED AT THE END LIKE ITS SIBLING, and read against the text before the VERB -# rather than before the key — so `not` must sit on the verb, never merely -# somewhere earlier in the paragraph. -[[pattern]] -id = "ready-closing-negation" -regex = '(?i)(^|[^0-9A-Za-z-])(not|never|n.t|without|nor)[[:blank:]]*$' - [[pattern]] id = "ready-closing-verb" -regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:blank:]]*:?[[:blank:]]*#?$' +regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:space:]]*:?[[:space:]]*#?$' # THE PROSE-DIALECT THRESHOLD (CLOUD-472) IS `[ready]`, NOT A `[[pattern]]` ROW. # It was drafted as one — a regex over the exempt key range — and that is the @@ -5669,13 +5629,6 @@ severity = "deny" # green fires this and not that; a commit graded red fires both, and they say # different things — do not re-run it, and do not land it. # -# BOTH READINGS NEEDED A PRODUCER, and until CLOUD-1707 neither had one: nothing -# invoked `batten record forge`, so this fact was `null` and the paragraph above -# described a discrimination no checkout could make. `mise run record-verdicts` -# writes it, and writes it ONLY once the fan-in has concluded — so "nothing was -# recorded at all" now means the forge has not finished judging, which is the -# could-not-look both rows read it as. -# # `warn`, NOT `deny`, AND THE FIRST LANDING IS THE REASON. `land` re-verifies and # re-waits every lap by design, because a rebase mints a new SHA and the receipts # keyed to the old one are gone — so the loop legitimately reaches graded commits, @@ -6644,12 +6597,6 @@ looked at it.""" # bytes at this version — absent from the map, not a verdict — which is what a # checkout gets if the producer was skipped or died. The row that wants a verdict # to be REQUIRED is `forge-verdict-required`'s shape and is not this one. -# -# THAT CONTRAST ONLY BECAME REAL WITH CLOUD-1707. `forge-verdict-required` had no -# producer, so it refused nothing and the sentence above named a shape rather than -# a behaviour. Both rows are fed by `mise run record-verdicts` now, and they still -# read absence the same way — what differs is what each does with a record that IS -# present, which is the distinction this block was always drawing. [[rule]] id = "validator-verdict-clean" kind = "policy" @@ -12539,14 +12486,17 @@ exit 143 and 144 over a hung commit, after which the container was reclaimed \ with the work uncommitted. Waiting is the harness's job, not the command's: put \ the work in the background by passing `run_in_background` on the tool call \ itself and act on its exit, which is delivered (measured 523 of 524 in one \ -session). For a condition rather than a process, background a command that EXITS \ -when the condition holds — that is a background wait and is allowed. +session). THERE IS NO SANCTIONED SLEEP — in a loop or out of one, foreground or \ +background (CLOUD-1337, CLOUD-821). A loop changes what the wait is ABOUT and \ +not that the session spends itself performing a poll the exit notification \ +already performs. Chained execution — run X, then when it finishes run Y — is a \ +workflow and gets a row, never a poll. """ [[verdict.route]] id = "task run first" kind = "command" -target = "until ; do sleep 1; done — where reads something the harness does NOT report, never a process table" +target = "pass run_in_background on the long command itself, and act on its exit notification" [[verdict.route]] id = "task run other" @@ -12565,15 +12515,18 @@ so it reports the same whether that thing finished, failed, or never started. \ The wake-up already exists: a backgrounded task's exit notification is delivered, \ measured 523 of 524 in one session including every failure, so idling until it \ arrives is the designed state rather than a turn wasted. Measured 2026-08-21: \ -490 of these in one session, 2 of which changed a decision. A backgrounded \ -command carrying an `until`/`while` construct waits on the condition itself and \ -is allowed. +490 of these in one session, 2 of which changed a decision. THE LOOP IS NOT AN \ +EXEMPTION and was one until CLOUD-1337: a backgrounded `until`/`while` around \ +the sleep waits on the condition rather than the clock, and still spends the \ +session performing by hand the wake-up the runtime already delivers. Measured \ +2026-09-09: one such loop ran 3h34m under that exemption before a human reading \ +`ps` found it. Every sleep is refused, in both postures. """ [[verdict.route]] id = "task run first" kind = "command" -target = "until ; do sleep 1; done — where reads something the harness does NOT report, never a process table" +target = "pass run_in_background on the long command itself, and act on its exit notification" [[verdict.route]] id = "task run other" diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 6f993a3df..c8ffb05a7 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -2149,8 +2149,8 @@ fn claim_of(matches: &ArgMatches) -> Option { branch: matches.get_one::("branch").cloned(), title: matches.get_one::("title").cloned(), log: matches.get_one::("log").cloned(), - closing_only: flag(matches, "closing-only"), - refs_first_only: flag(matches, "refs-first-only"), + closing_only: flag(matches, "closing_only"), + refs_first_only: flag(matches, "refs_first_only"), }), ("carry", matches) => Some(ClaimCommand::Carry { json: flag(matches, "json"), diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index adf64dd04..aec9c0951 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -8844,13 +8844,17 @@ fn flatten_in( // **A CONTROL-FLOW BODY IS WALKED AND TAGGED**, which is what makes a // module able to decide from structure (CLOUD-1381). // - // An earlier revision walked these UNTAGGED and it was an over-deny: - // `run-shape-guard` exempts a `sleep` inside a condition loop, and a - // body command lifted into a bare segment carries nothing saying it was - // in a loop, so `until [ -f /tmp/done ]; do sleep 1; done` -- the wait - // this repository's own rules recommend -- was refused as a bare timer. - // A second revision withdrew the walk entirely, which stopped that - // over-deny and left the module unable to see the loop at all. + // An earlier revision walked these UNTAGGED, and at the time that was an + // over-deny: `run-shape` then exempted a `sleep` inside a condition + // loop, and a body command lifted into a bare segment carries nothing + // saying it was in a loop, so a conditioned wait was refused as a bare + // timer. A second revision withdrew the walk entirely, which stopped + // that over-deny and left the module unable to see the loop at all. + // + // CLOUD-1337 has since withdrawn the exemption, so that particular + // over-deny is no longer possible -- but the tagging is not vestigial: + // `polls-a-local-process` and `background-timer` PARTITION on what the + // loop body reads, and neither can see a body it was never handed. // // Tagging is the answer both attempts were missing. The condition and // the body are DIFFERENT roles and a module needs them apart: the @@ -15447,11 +15451,12 @@ deny contains "refused by themodule" if { /// /// The tag is what makes a module able to decide from structure rather than /// from a keyword, and both halves matter. Walking untagged was an - /// over-deny: `run-shape-guard` exempts a `sleep` inside a condition loop, - /// and a body command lifted into a bare segment carries nothing saying it - /// was in a loop, so the sanctioned `until … do sleep 1; done` wait was - /// refused as a bare timer. Not walking at all left the module unable to see - /// the loop. + /// over-deny while `run-shape` still exempted a `sleep` inside a condition + /// loop: a body command lifted into a bare segment carries nothing saying it + /// was in a loop, so a conditioned wait was refused as a bare timer. Not + /// walking at all left the module unable to see the loop. CLOUD-1337 has + /// since withdrawn the exemption, and the tag is still what the two arms + /// partition on. /// /// The condition and the body are separate roles because a module needs them /// apart: a process probe lives in the condition, a sleep in the body. diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 1571fca5e..9201708f4 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -432,6 +432,13 @@ mod tests { // which is the harmless direction — but it would also be false, // and this list is read as a statement about what the binary // does. + // The two key readers that retired `mise-tasks/claimed-keys.sh` + // and `mise-tasks/merged-pr-keys.sh` (CLOUD-1711). Both walk the + // ref log and the closing bodies through git's read-only + // plumbing and write nothing, so they sit beside `claim race` + // rather than with `claim check`, which records. + "claim keys".to_owned(), + "claim merged".to_owned(), "claim race".to_owned(), // Both `commit` rows, unlike attribution's. The noun IS `read` // here because its whole subtree is — nothing under it writes — @@ -610,6 +617,12 @@ mod tests { // it belongs here beside `receipt status` rather than with // `receipt record`. "receipt verified".to_owned(), + // The read half of the out-of-tree verdict stores (CLOUD-1713). + // `record` itself is the write band and is absent here on + // purpose; these two leaves fold and print what is already + // stored and open nothing. + "record fold".to_owned(), + "record show".to_owned(), // CLOUD-1180's recovered `agent` slice. BOTH the noun and its // leaf are read, and that is the row's §2 predicate rather than // an accident: `show` is the read band under CLOUD-1184's @@ -762,6 +775,10 @@ mod tests { "claim bot".to_owned(), "claim carry".to_owned(), "claim check".to_owned(), + // The two key readers that retired `mise-tasks/claimed-keys.sh` and + // `mise-tasks/merged-pr-keys.sh` (CLOUD-1711). + "claim keys".to_owned(), + "claim merged".to_owned(), "claim race".to_owned(), "commit".to_owned(), "commit check".to_owned(), @@ -991,10 +1008,21 @@ mod tests { // a third row spelled the old way would be a third row to invert. "record".to_owned(), "record closes".to_owned(), + // The two READ leaves of this noun (CLOUD-1713). They fold and + // print what the write leaves already stored, which is why they — + // alone under `record` — are also on the read-only allowlist above. + "record fold".to_owned(), "record forge".to_owned(), + // The two store FAMILIES the record readers work over: a keyed + // store addressed by a composed triple, and an append-only journal + // sharded by name. Same reason `record` carries two write leaves + // rather than one verb with a mode flag. + "record journal".to_owned(), + "record keyed".to_owned(), // The plan a branch declared, so `plan-complete` decides over a // record rather than over a transcript it cannot re-read. "record plan".to_owned(), + "record show".to_owned(), "record tool".to_owned(), // The API-compatibility noun (CLOUD-1050), ported off // `mise-tasks/semver.sh` when CLOUD-1059 made editing a shell diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 65e687199..bdb0a2c86 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -869,7 +869,7 @@ const CLAIM_LOG: FlagDecl = FlagDecl { /// different SINGLE source, so both together is a caller that has not decided /// which question it is asking, never an intersection to compute. const CLOSING_ONLY: FlagDecl = FlagDecl { - id: "closing-only", + id: "closing_only", long: Some("closing-only"), short: None, help: "Answer from a closing keyword alone, never falling through to the branch or a trailer", @@ -884,7 +884,7 @@ const CLOSING_ONLY: FlagDecl = FlagDecl { /// Source 3 alone — `closing-key-check`'s need (CLOUD-674). const REFS_FIRST_ONLY: FlagDecl = FlagDecl { - id: "refs-first-only", + id: "refs_first_only", long: Some("refs-first-only"), short: None, help: "Answer from the first key of each `Refs:` trailer alone, never sources 1 or 2", @@ -4057,7 +4057,13 @@ pub const SURFACE: &[CommandDecl] = &[ path: "claim merged", id: "claim.merged", about: "The keys merged pull request bodies close, as `\\t` rows", - data_channel: true, + // `data_channel` is exactly "declares `-J`", which + // `every_data_emitting_verb_declares_the_json_flag` pins in both + // directions — it is not the wider claim the header paragraph makes + // about stdout being data rather than a verdict. Two-column rows are + // this family's shape and `claim keys` beside it says the same, so the + // answer is the line shape, not a second encoding of it. + data_channel: false, exits: EXITS_STANDARD, effect: Effect::Read, flags: &[MERGED_LIMIT], diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 43ba583b0..6d54cf6e8 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -647,6 +647,13 @@ const MAY_ANSWER_COULD_NOT_LOOK: &[&str] = &[ // either the trunk commit or the comparison — which is the could-not-look // this gate is built to fail open on. "lease carries", + // `claim merged` joins it for `lease carries`' reason exactly: it reaches + // the FORGE to read merged pull request bodies, and a corpus with no + // credential can read none of them. It is also the one verb here whose + // could-not-look is a REFUSAL rather than an empty answer — a producer that + // exits clean having produced nothing is indistinguishable from a repository + // with no merged pull requests. + "claim merged", // `lease guard` joins it for `carries`' reason and one more: it is the // composite, so a corpus that cannot read the trunk commit cannot answer its // first half either — and the guard's contract is that every such reading @@ -1031,6 +1038,31 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // `claim-keys.sh`'s successor (CLOUD-1711), and pointer-only for the reason + // that program had to promise by hand: its inputs are a branch NAME, a pull + // request TITLE and a commit LOG, all three of them prose somebody wrote, + // and its answer is a list of `CLOUD-` keys. A key is a pointer by + // construction — the shape of the extraction is the guarantee, not a filter + // applied after it. + Verb { + path: "claim keys", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + // `merged-pr-keys.sh`'s successor, on the same terms one surface out: it + // reads MERGED PULL REQUEST BODIES, which are the largest untrusted prose + // any verb here touches, and emits `\t` rows. Two columns, both + // of them identifiers. It is in `MAY_ANSWER_COULD_NOT_LOOK` because it + // reaches the forge and this corpus carries no credential — the pointer-only + // assertions still run over the could-not-look report, which is where a + // reader of somebody else's body is most tempted to quote it. + Verb { + path: "claim merged", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // The green verdict (CLOUD-1143), pointer-only on the same structural terms. // `checks_green::Finding` carries a check name and a conclusion and has // nowhere to put anything else, so a run's log cannot travel even when the @@ -1824,6 +1856,43 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::PrBody, disposition: Disposition::PointerOnly, }, + // The two GENERIC store writers (CLOUD-1713), and the pair that makes this + // census worth running over them: unlike every other `record` leaf, neither + // knows what its payload MEANS. `record tool` reads a tool verdict and + // `record plan` reads plan entries, so each can be careful about a shape it + // understands; these two take whatever the caller sends and file it under a + // family and a key. `PointerOnly` is therefore the whole of their contract — + // a successful write says nothing, because the record's destination is a + // keyed file under `$GIT_DIR` and there is nothing for it to report. + Verb { + path: "record keyed", + args: &["census", "a-key"], + stdin: Stdin::ToolVerdict, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "record journal", + args: &["census"], + stdin: Stdin::ToolVerdict, + disposition: Disposition::PointerOnly, + }, + // Their READ halves. Over this corpus the stores are empty, so the answers + // are `miss` and `nothing` — which is the state that matters most here: a + // reader that cannot find a record is exactly where a program is tempted to + // print the key it looked for, the path it looked in, or the bytes it half + // read. The closed token vocabulary is what stops all three. + Verb { + path: "record show", + args: &["census", "a-key"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, + Verb { + path: "record fold", + args: &["census"], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // The task registry's six writers (CLOUD-425). Each answers with silence and // an exit code — the record's destination is a keyed file under `$GIT_DIR`, // so there is nothing for a successful write to say. `PointerOnly` rather diff --git a/crates/batten/tests/it/run_shape.rs b/crates/batten/tests/it/run_shape.rs index 811c7ec1d..b6dd6a16a 100644 --- a/crates/batten/tests/it/run_shape.rs +++ b/crates/batten/tests/it/run_shape.rs @@ -206,7 +206,7 @@ fn fixture(name: &str) -> PathBuf { "[[verdict.route]]\n", "id = \"task run first\"\n", "kind = \"command\"\n", - "target = \"until ; do sleep 1; done\"\n\n", + "target = \"run_in_background on the long command itself\"\n\n", "[[verdict]]\n", "id = \"timer run refused\"\n", "gloss = \"a backgrounded `sleep` with no loop around it is a timer, not a wait\"\n", @@ -217,7 +217,7 @@ fn fixture(name: &str) -> PathBuf { "[[verdict.route]]\n", "id = \"task run first\"\n", "kind = \"command\"\n", - "target = \"until ; do sleep 1; done\"\n\n", + "target = \"run_in_background on the long command itself\"\n\n", "[[verdict]]\n", "id = \"task run blocked\"\n", "gloss = \"a foreground `mise` call is killed at ~2 minutes, so it fails rather than runs slowly\"\n", @@ -454,14 +454,22 @@ fn a_backgrounded_bare_sleep_is_a_timer() { } #[test] -fn a_backgrounded_wait_on_a_condition_is_allowed() { - // THE ALLOW THIS WHOLE FAMILY IS SHAPED AROUND. It is the form both refusals - // recommend, and denying it is the pure false positive that gets a guard - // bypassed (CLOUD-199). The keyword is in a DIFFERENT segment from the - // sleep, which is why the loop test is over the whole call. +fn a_backgrounded_conditioned_sleep_loop_is_refused() { + // THE EXEMPTION THIS FAMILY WAS SHAPED AROUND, WITHDRAWN. The case used to + // assert the opposite, on the argument that a loop testing a condition exits + // on the condition rather than the clock. That is true and was never the + // question: the loop still spends the session performing by hand the wake-up + // the runtime already delivers on a backgrounded task's exit (measured 523 + // of 524). CLOUD-1337 priced the exemption at eleven duplicate watchers + // running 9h35m; measured again 2026-09-09, one conditioned loop ran 3h34m + // in this session and the detector was a human reading `ps`. + // + // So the two arms now PARTITION on `count(process_probes)` and nothing falls + // between them: every backgrounded sleep is refused, with or without a + // condition around it. let root = fixture("conditional-wait"); - allowed_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); - allowed_background( + denied_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); + denied_background( &root, "while ! grep -q ready /tmp/log; do sleep 5; done", true, @@ -469,29 +477,26 @@ fn a_backgrounded_wait_on_a_condition_is_allowed() { } #[test] -fn a_loop_body_is_reached_and_the_exemption_decides_it() { - // THE PAIR THAT MAKES THE EXEMPTION LOAD-BEARING (CLOUD-1112). One command, - // twice, differing only in posture — so `waits_on_condition` is what decides - // it, which is what CLOUD-613's acceptance always claimed. - // - // Reaching it needed a keyword look-through. `do sleep 1` resolves to the - // program `do` without one, and `run-shape-guard.sh`'s `resolve()` still - // does: the wrapper table covers `env`/`timeout`/`sudo`/… and no keyword. So - // in the bash BOTH postures pass, for want of a resolvable sleep rather than - // for any reason about waiting, and its comment that an element-scoped test - // "would deny every correct wait" presumes an element it never reaches. - // Porting that would have satisfied the acceptance vacuously. +fn a_loop_body_is_reached_in_either_posture() { + // THE PAIR THAT ONCE MADE THE EXEMPTION LOAD-BEARING (CLOUD-1112). One + // command, twice, differing only in posture — and since CLOUD-1337 withdrew + // the condition exemption the POSTURE no longer changes the answer; it + // changes only which rule answers, `foreground-sleep` or `background-timer`. + // What the pair still proves is the keyword look-through: without it neither + // call resolves a sleep at all and both pass vacuously. // - // This is the one place the two authorities deliberately disagree while both - // are live, and it is in the DENYING direction — no call gets a weaker - // answer than it had. + // Reaching the body needs a keyword look-through: `do sleep 1` resolves to + // the program `do` without one, so a resolver whose wrapper table covers + // `env`/`timeout`/`sudo`/… and no keyword finds no sleep here at all and + // passes the whole family vacuously. That was the retired bash guard's + // behaviour, and it is why this case reads the verdict rather than a count. // - // `for` is not a wait: it counts iterations, so it exits on the clock like - // any timer. The guard calls that a deliberate non-catch "because narrowing - // it costs a real parser"; it costs none now. + // `for` is not a wait either — it counts iterations, so it exits on the + // clock like any timer — and after CLOUD-1337 it needs no separate argument: + // there is no shape of loop that exempts the sleep inside it. let root = fixture("loop-body"); denied_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", false); - allowed_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); + denied_background(&root, "until [ -f /tmp/done ]; do sleep 1; done", true); denied_background(&root, "for i in $(seq 60); do sleep 10; done", true); } diff --git a/crates/batten/tests/it/run_shape_guard_door.rs b/crates/batten/tests/it/run_shape_guard_door.rs index 6967fec4b..ae9bece3f 100644 --- a/crates/batten/tests/it/run_shape_guard_door.rs +++ b/crates/batten/tests/it/run_shape_guard_door.rs @@ -238,15 +238,16 @@ fn the_handler_receives_the_hosts_own_payload_including_the_calls_background_fla } #[test] -fn a_backgrounded_wait_on_a_condition_stays_allowed() { - // Driven against the COMMITTED guard deliberately, because this is its allow - // path and the allow path is not broken: the guard prints a document only - // when it denies, so a command it passes leaves the door silent either way. - // A guard refusing every backgrounded sleep would fail this and be the false - // positive that gets a guard switched off (CLOUD-418). +fn a_backgrounded_wait_on_a_condition_is_denied_at_the_door() { + // Driven against the COMMITTED guard deliberately: this case used to assert + // the opposite, and it is the one CLOUD-1337 inverted. Refusing every + // backgrounded sleep was called the false positive that gets a guard + // switched off; measured, the exemption was instead the hole that let a + // conditioned loop run 3h34m unseen, so the door is where the withdrawal has + // to show up rather than only in the module's own suite. let dir = fixture("door-background-wait"); let answer = door_bg(&dir, "until [ -f /tmp/done ]; do sleep 1; done"); - assert!(answer.allowed(), "{}", answer.out); + assert!(!answer.allowed(), "{}", answer.out); assert!(answer.unbroken(), "{}", answer.err); } diff --git a/policy/run-shape.rego b/policy/run-shape.rego index e7e77db0e..292d5b8b4 100644 --- a/policy/run-shape.rego +++ b/policy/run-shape.rego @@ -76,7 +76,12 @@ rules contains "background-redirect" # other conjunct. Each of these corrupts the conjunct that carries the verdict. #MUTANT redirect-binding-ignored|s@^ segment\["input-redirect"\] == false@ true@|a_redirect_bound_to_the_commits_own_element_is_a_message_source #MUTANT background-not-consulted|s@^ input.call\["run-in-background"\] != true@ true@|a_backgrounded_wait_on_a_condition_is_allowed -#MUTANT loop-is-not-an-exemption|s@^ not waits_on_condition@ true@|a_bare_sleep_beside_a_condition_loop_is_exempt +# The mutation restores the exemption this row removed: with the partition term +# forced false, a backgrounded `sleep` loop carrying a condition falls through +# `background-timer` exactly as it did before, and only a case asserting THAT +# shape is refused can see it. Every process-polling row still denies under it, +# via the sibling arm — which is what made the hole survive its own suite. +#MUTANT condition-is-an-exemption|s@^ count(process_probes) == 0@ false@|a_backgrounded_conditioned_sleep_loop_is_refused # THE FOUR MUTATIONS, and the last three are the ones worth having: they corrupt # the SCRUBBING and the SPLITTING rather than the flag table, which is where a @@ -147,23 +152,42 @@ violation contains { } if { sleeps input.call["run-in-background"] == true - not waits_on_condition + count(process_probes) == 0 } # A backgrounded wait that polls the LOCAL PROCESS TABLE (CLOUD-1337). # -# `waits_on_condition` above exempts a `sleep` loop from `background-timer` on -# sound reasoning: a loop testing a condition exits on the condition rather than -# on the clock. That holds for a condition NOTHING ELSE REPORTS — a CI run, a -# remote queue, a file another machine writes. It does not hold for a local -# process, because the harness already re-invokes the caller when a backgrounded -# task exits. Polling one duplicates a notification that is guaranteed to fire. +# THE CONDITION EXEMPTION IS GONE, AND THIS ARM IS WHAT SURVIVES IT. # -# THE EXEMPTION ASKS WHETHER THERE IS A CONDITION, NEVER WHAT IT IS ABOUT, and +# `waits_on_condition` used to exempt a `sleep` loop from `background-timer`, on +# reasoning that reads well and does not hold: a loop testing a condition exits on +# the condition rather than on the clock, so a condition NOTHING ELSE REPORTS — a +# CI run, a remote queue, a file another machine writes — looked like a legitimate +# wait. It is not, and AGENTS.md says why in the same breath it bans timers: **the +# exit notification IS the wake-up**, a backgrounded task re-invokes its caller +# when it exits, and the turn in between is the designed state rather than one to +# fill. A hand-rolled poll over ANY condition duplicates a notification the +# harness already guarantees; `ci-wait` and `main-watch` exist for the two +# conditions that genuinely need a poll, and both are tasks that notify on exit. +# +# THE EXEMPTION ASKED WHETHER THERE WAS A CONDITION, NEVER WHAT IT WAS ABOUT, and # `until` was the escape. AGENTS.md has carried the rule since CLOUD-821, with the # measurement — "490 in one session, 2 changed a decision" — and the claim that -# the shape is "refused by `run-shape-guard`". It was not. This arm is what makes -# that sentence true rather than something to soften. +# the shape is "refused by `run-shape-guard`". With the exemption in place that +# sentence was false for every conditioned wait; `background-timer` now reaches +# them and it is true. +# +# MEASURED 2026-09-09, this session: `until grep -q '#' && ! pgrep -f +# ''; do sleep 5; done` ran **3h34m**, spending a wake-up every five seconds +# while its own output had been read in the first minute. It was denied in the +# FOREGROUND and, once backgrounded, fell through every arm — the exemption held +# because the loop had a condition. Found by a human reading `ps`, which is the +# second time (CLOUD-1337 was the first). +# +# THIS ARM STAYS SEPARATE rather than collapsing into the wider one, because the +# count it carries is the diagnostic: a compound polling two processes is two +# duplications. The two arms PARTITION on `count(process_probes)`, so one call +# yields one finding. # # MEASURED 2026-09-02: eleven of these ran on one container, the oldest 9h35m, # while exactly one real job existed. @@ -183,7 +207,6 @@ violation contains { } if { sleeps input.call["run-in-background"] == true - waits_on_condition count(process_probes) > 0 } @@ -272,38 +295,6 @@ sleeps if { basename(segment.words[words_program_index(segment.words)]) == "sleep" } -# OVER THE WHOLE CALL, never one segment, because a loop's keyword and its sleep -# are in different segments. -# -# AND IT IS LOAD-BEARING HERE, which it is not in the bash. There the canonical -# `until ; do sleep 1; done` was allowed because no sleep resolved at all, -# so this conjunct decided nothing and read as coverage (CLOUD-1112). With the -# loop body reached, this is the only thing standing between that command and a -# refusal — which is what CLOUD-613's acceptance always claimed it was. -# -# `for` is NOT a wait. `for i in $(seq 60); do sleep 10; done` counts iterations -# rather than testing a condition, so it exits on the clock like any timer; the -# bash names it a deliberate non-catch "because narrowing that costs a real -# parser", and it costs none now. -# DECIDED FROM THE NODE, never from a keyword (CLOUD-1381). -# -# This read `word in {"until", "while"}` over a segment's words, and that only -# ever worked because the character walk split on `;` and had no idea what a loop -# was -- so `until` and `do` and `done` fell out as ordinary words. A real parse -# has no such token: the keyword IS the node type. `input.call.segments[_] -# .construct` carries it, `null` at the top level. -# -# Reading the node is also a tightening rather than a translation. `for` is -# excluded because it is a DIFFERENT NODE, not because a list of words happens to -# omit it -- `for i in $(seq 60); do sleep 10; done` counts iterations and exits -# on the clock like any timer. And a `!` between the keyword and the test needed -# filtering out by hand before; it is inside the condition now and never reaches -# this predicate. -waits_on_condition if { - some segment in input.call.segments - segment.construct.kind in {"until", "while"} -} - # Every reader of the LOCAL process table in this call. # # The set is the programs whose whole purpose is answering "is this process still @@ -533,17 +524,12 @@ wrappers := {"env", "command", "nice", "stdbuf", "timeout", "xargs", "sudo", "do # SHELL KEYWORDS THAT INTRODUCE A COMMAND, looked through for the same reason # every wrapper above is: what runs after them is the call being judged. # -# `run-shape-guard.sh`'s `resolve()` has no such set, and CLOUD-1112 measured +# The retired bash guard's `resolve()` had no such set, and CLOUD-1112 measured # what that costs: `do sleep 1` resolved to the program `do`, so a sleep in a -# loop body was invisible — and `waits_on_condition` therefore exempted nothing, -# because the canonical `until ; do sleep 1; done` was already allowed for -# want of a resolvable sleep rather than for being a wait. The guard's own -# comment claims the opposite ("the one carrying the sleep has no keyword in -# it"), which only parses if that element IS reached. -# -# CLOUD-613's acceptance turns on that allow being LOAD-BEARING, so porting the -# gap would have satisfied the clause vacuously. This is the narrower reading: -# the engine resolves the loop body, and the exemption is what decides it. +# loop body was invisible and the whole family passed a looped sleep for want of +# a resolvable sleep rather than for any reason about waiting. That is why the +# withdrawal of the condition exemption (CLOUD-1337) needed the look-through +# first: without it there would be nothing for the arms below to refuse. # # **VESTIGIAL SINCE CLOUD-1381, and said so rather than left reading as live.** # The engine emits none of these as words any more: each is a NODE, and a @@ -865,8 +851,14 @@ test_a_process_read_outside_a_loop_is_not_a_wait if { }} } -test_a_backgrounded_wait_on_a_condition_is_allowed if { - count(violation) == 0 with input as {"call": { +# THE WITHDRAWN EXEMPTION (CLOUD-1337). This case asserted `count(violation) == +# 0` for as long as the condition was an exemption. It is inverted rather than +# deleted, because a deleted case documents nothing and this is the exact shape +# the rule now exists to catch: the condition makes the loop exit on the thing +# rather than on the clock, and does not make it stop being a hand-rolled copy of +# a notification the runtime already delivers. +test_a_backgrounded_wait_on_a_condition_is_refused if { + some v in violation with input as {"call": { "command": "until [ -f /tmp/done ]; do sleep 1; done", "run-in-background": true, "segments": [ @@ -874,6 +866,7 @@ test_a_backgrounded_wait_on_a_condition_is_allowed if { inner(["sleep", "1"], "until", "body", null), ], }} + v.rule == "background-timer" } # A FOREGROUND loop spends the turn exactly as a foreground `sleep` does, and it From 84a4b8c5403bf4497bf750e7cc72e98e73590428 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 20:17:26 +0000 Subject: [PATCH 16/32] fix(policy): invert the last four cases asserting the sleep-loop exemption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `035d863` withdrew the exemption in the predicate and left four cases still asserting it — three in the module's own load-time tier and one over the compiled binary. `policy test` reported 849 of 850 and `test:cargo` reported one. Each is INVERTED rather than deleted, because a deleted case documents nothing and these are the shapes the rule now exists to catch: - a bare `sleep 5` beside a loop whose body is `:` — the exemption's worst reachable shape, and the argument against asking WHETHER there is a condition rather than what it is about. The sleep waits on nothing; the old rule exempted it for the company it kept. - a backgrounded `until curl …; do sleep 5; done`. This was the family's anti-vacuity mirror, so it is re-pointed rather than dropped: it now pins that the two arms PARTITION — exactly one fires, and a non-process condition is answered by `background-timer` and not `polls-a-local-process`. Two stale references from the same withdrawal go with them. The header's "THE BASH STILL RUNS" paragraph described `mise-tasks/run-shape-guard.sh` as a live second authority deciding these families; that file is retired and a glob finds nothing, so the paragraph asserted a tree state that does not exist. What it recorded that still matters — the guard never reached a sleep inside a loop body, which is why reaching it was the precondition for withdrawing the exemption — is kept. And `background-not-consulted` named a case that could not observe it: the mutation forces `foreground-sleep`'s posture conjunct true, so only a case asserting a COUNT can see it, and it now names the one that does. Refs: CLOUD-1337, CLOUD-989 path write refused 4387e766bc37f391e0f3ea6cf377f9e8d7d5efa545f6e9a806619ad226a9e47e spent Admits: 4387e766bc37f391e0f3ea6cf377f9e8d7d5efa545f6e9a806619ad226a9e47e Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/run-shape.rego Admits-anchor: call:035d8630029c74dec5c8b5ee5c82178b9a505be7 Admits-epoch: 426a5bf09e81ad4dd690e5c7947f6bd40056e762ff7d6f0a612400030dd5b468 Admits-author: alec@wenzowski.com Admits-prev: 1da26320409cf7878b068d2db4754f11f88f8630ad7da3c8f8865af903481971 Admits-answer-lost: `mise run policy test` stays red and the withdrawal cannot land. This is also the exemption's worst reachable shape — the `sleep 5` waits on nothing and the loop beside it has an empty body — so leaving it asserting clean would leave the module claiming a bare timer is a wait. Admits-answer-precondition: One more load-time case in the module still asserts the exemption CLOUD-1337 withdrew: `test_a_bare_sleep_beside_a_condition_loop_is_exempt` (policy/run-shape.rego:803). A module's cases live in the module file and no other surface can express them; `policy test` is red until it is inverted. One case, read in the diff beside the predicate it tracks. Admits-answer-rejected-route: config read first — the file is read and the read located the case. patch run first is a commit-message route, not a module one. path write refused a35d57dac37925d107b590b088f9e48c53bbcd50eb91507f6849a22f23b1c35d spent Admits: a35d57dac37925d107b590b088f9e48c53bbcd50eb91507f6849a22f23b1c35d Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/run-shape.rego Admits-anchor: call:035d8630029c74dec5c8b5ee5c82178b9a505be7 Admits-epoch: 426a5bf09e81ad4dd690e5c7947f6bd40056e762ff7d6f0a612400030dd5b468 Admits-author: alec@wenzowski.com Admits-prev: 4387e766bc37f391e0f3ea6cf377f9e8d7d5efa545f6e9a806619ad226a9e47e Admits-answer-lost: The module's own header asserts a second authority that does not exist, which is the class of claim `A STATE IS A CLAIM ABOUT THE TREE, AND THE TREE WINS` exists to catch; and `mise run mutant-census` fails on an unresolvable case name, so the gate that proves this module discriminates cannot run at all. Admits-answer-precondition: Two stale references left by the same withdrawal, both in this file and expressible nowhere else. (1) The header's "THE BASH STILL RUNS" paragraph describes `mise-tasks/run-shape-guard.sh` as a live second authority; a Glob over `mise-tasks/run-shape*` returns nothing, so the file is already retired and the paragraph is false. (2) The `background-not-consulted` MUTANT row names case `a_backgrounded_wait_on_a_condition_is_allowed`, which this change renamed, so `mutant-census` cannot resolve it. One write, both edits, read in the diff. Admits-answer-rejected-route: config read first — the file is read and the read is what located both. patch run first is a commit-message route, not a module one. path write refused 450c595a2a2088a3efb39a4286e3c76612703915081bb5ed3fe4e7186a0d1d62 spent Admits: 450c595a2a2088a3efb39a4286e3c76612703915081bb5ed3fe4e7186a0d1d62 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/run-shape.rego Admits-anchor: call:035d8630029c74dec5c8b5ee5c82178b9a505be7 Admits-epoch: 426a5bf09e81ad4dd690e5c7947f6bd40056e762ff7d6f0a612400030dd5b468 Admits-author: alec@wenzowski.com Admits-prev: a35d57dac37925d107b590b088f9e48c53bbcd50eb91507f6849a22f23b1c35d Admits-answer-lost: A declared mutation whose named case cannot observe it is reported as SURVIVED and mis-attributed to the suite — CLOUD-989's exact defect, and this module's header already names its live instances. Leaving my own new one in would add a fifth. Admits-answer-precondition: Correcting the MUTANT row I just wrote: `background-not-consulted` forces `foreground-sleep`'s posture conjunct true, so the case that can observe it must assert a COUNT — `a_backgrounded_wait_on_a_condition_is_refused` uses `some v in violation` and still holds under the mutation. `a_backgrounded_bare_sleep_raises_only_the_timer` asserts `count(violation) == 1` and is the discriminating case. MUTANT rows live in the module file and nowhere else. Admits-answer-rejected-route: config read first — the file is read and the read is what showed the case cannot discriminate. patch run first is a commit-message route, not a module one. path write refused 99da2e24e45d80153e21f7651424df9ed930993837f2ffc36d99ad35ba5fa357 spent Admits: 99da2e24e45d80153e21f7651424df9ed930993837f2ffc36d99ad35ba5fa357 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/run-shape.rego Admits-anchor: call:035d8630029c74dec5c8b5ee5c82178b9a505be7 Admits-epoch: 426a5bf09e81ad4dd690e5c7947f6bd40056e762ff7d6f0a612400030dd5b468 Admits-author: alec@wenzowski.com Admits-prev: 450c595a2a2088a3efb39a4286e3c76612703915081bb5ed3fe4e7186a0d1d62 Admits-answer-lost: `mise run policy test` stays red at 849/850 and nothing in this change can land. The case also still cites `timer run refused`'s route as recommending the shape, which this same commit rewrote. Admits-answer-precondition: The last case in this module still asserting the withdrawn exemption: `test_a_wait_on_a_condition_nobody_reports_is_clean` (policy/run-shape.rego:732) requires a backgrounded `until curl ...; do sleep 5; done` to raise nothing, which CLOUD-1337's partition denies. `policy test` reports it as the one failure of 850. Cases live in the module file and nowhere else. Admits-answer-rejected-route: config read first — the file is read and `batten policy test` named the case by line. patch run first is a commit-message route, not a module one. --- crates/batten/tests/it/run_shape.rs | 14 ++++--- policy/run-shape.rego | 59 ++++++++++++++++++----------- 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/crates/batten/tests/it/run_shape.rs b/crates/batten/tests/it/run_shape.rs index b6dd6a16a..b36449a7a 100644 --- a/crates/batten/tests/it/run_shape.rs +++ b/crates/batten/tests/it/run_shape.rs @@ -519,12 +519,14 @@ fn a_backgrounded_bare_sleep_raises_the_timer_and_not_the_foreground_rule() { } #[test] -fn a_bare_sleep_beside_a_condition_loop_is_exempt() { - // The one shape `waits_on_condition` actually decides, and therefore the - // only case that can discriminate the `loop-is-not-an-exemption` mutation: - // a resolvable bare `sleep` AND a loop keyword in the same backgrounded - // call. Drop the conjunct and this denies. - allowed_background( +fn a_bare_sleep_beside_a_condition_loop_is_no_longer_exempt() { + // The exemption's most obviously wrong reachable shape, and the reason it + // is inverted rather than deleted: the `sleep 5` here waits on NOTHING — + // the loop beside it has an empty body — so the old rule read a condition + // somewhere in the call and exempted a bare timer sitting next to it. That + // is the whole argument against asking WHETHER there is a condition instead + // of what it is about, and after CLOUD-1337 the question is not asked. + denied_background( &fixture("mixed-wait"), "sleep 5; until [ -f /tmp/done ]; do :; done", true, diff --git a/policy/run-shape.rego b/policy/run-shape.rego index 292d5b8b4..86e36a7fe 100644 --- a/policy/run-shape.rego +++ b/policy/run-shape.rego @@ -22,17 +22,16 @@ # event already notifies (CLOUD-821: 490 such # calls in one session, 2 changed a decision). # -# THE BASH STILL RUNS, and that is the ratchet rather than an oversight. -# `shell-retirement` admits DELETING a governed file and refuses SHRINKING one, -# and `run-shape-guard.sh` keeps a fourth family (`cargo-substitutes-for-a-task`) -# whose blocker is CLOUD-856. So the guard cannot lose these three until it can -# lose all four, and both authorities decide them until it does. CLOUD-1108 owns -# that gap; the predicates below are written from the bash's own decision table, -# with ONE deliberate divergence — this reaches a sleep inside a loop body and -# `resolve()` does not (CLOUD-1112) — which is in the DENYING direction, so no -# call gets a weaker answer from the pair than it had from the guard alone. The -# divergence stands; only its mechanism moved, from `keywords` stepping past a -# `do` token to the body arriving as its own segment (CLOUD-1381). +# THE BASH IS GONE AND THIS MODULE IS SOLE AUTHORITY. `mise-tasks/run-shape-guard.sh` +# is retired, so the paragraph that used to stand here — both authorities decide +# these families, with one deliberate divergence in the denying direction — is no +# longer a description of the tree and has been removed rather than left reading +# as live. What it recorded that still matters: the guard's `resolve()` never +# reached a sleep inside a loop body (CLOUD-1112), so the whole family passed a +# looped sleep for want of a resolvable sleep rather than for any reason about +# waiting. Reaching the body was the precondition for CLOUD-1337 withdrawing the +# condition exemption; the mechanism is now the body arriving as its own segment +# (CLOUD-1381) rather than `keywords` stepping past a `do` token. # # TWO ERAS OF INPUT LIVE HERE, deliberately, and the newer one is the model. # `commit-names-no-message-source` landed before `hook::segments` was projected, @@ -75,7 +74,7 @@ rules contains "background-redirect" # `sleep` or `git` token survives, because every ALLOW row already fails some # other conjunct. Each of these corrupts the conjunct that carries the verdict. #MUTANT redirect-binding-ignored|s@^ segment\["input-redirect"\] == false@ true@|a_redirect_bound_to_the_commits_own_element_is_a_message_source -#MUTANT background-not-consulted|s@^ input.call\["run-in-background"\] != true@ true@|a_backgrounded_wait_on_a_condition_is_allowed +#MUTANT background-not-consulted|s@^ input.call\["run-in-background"\] != true@ true@|a_backgrounded_bare_sleep_raises_only_the_timer # The mutation restores the exemption this row removed: with the partition term # forced false, a backgrounded `sleep` loop carrying a condition falls through # `background-timer` exactly as it did before, and only a case asserting THAT @@ -825,12 +824,23 @@ test_a_liveness_signal_is_the_same_question if { v.verdict == "task watch duplicate" } -# THE ANTI-VACUITY MIRROR, and without it every case above is satisfied by a rule -# that refuses all waits. A condition the harness does NOT report stays allowed — -# that is the whole narrowing, and `timer run refused`'s route still recommends -# this shape for it. -test_a_wait_on_a_condition_nobody_reports_is_clean if { - count(violation) == 0 with input as {"call": { +# THE PARTITION'S OTHER SIDE, and it used to be this family's anti-vacuity +# mirror: a condition the harness does not report — a remote readiness probe +# rather than a local process — was the one wait left allowed. CLOUD-1337 removed +# that allow, so what this case now pins is narrower and still worth pinning: the +# two arms must not BOTH fire, and the one that answers a non-process condition +# must be `background-timer` rather than `polls-a-local-process`. A rule that +# refused every wait under one verdict would fail this. +test_a_wait_on_a_condition_nobody_reports_is_a_timer_not_a_poll if { + count(violation) == 1 with input as {"call": { + "command": "until curl -sf https://example.test/ready; do sleep 5; done", + "run-in-background": true, + "segments": [ + inner(["curl", "-sf", "https://example.test/ready"], "until", "condition", ";"), + inner(["sleep", "5"], "until", "body", null), + ], + }} + some v in violation with input as {"call": { "command": "until curl -sf https://example.test/ready; do sleep 5; done", "run-in-background": true, "segments": [ @@ -838,6 +848,7 @@ test_a_wait_on_a_condition_nobody_reports_is_clean if { inner(["sleep", "5"], "until", "body", null), ], }} + v.rule == "background-timer" } # A PROCESS READ WITH NO LOOP IS NOT A WAIT. `mise run alive` asks once and @@ -897,10 +908,13 @@ test_a_backgrounded_counting_loop_is_a_timer if { v.rule == "background-timer" } -# The exemption's other reachable shape: a bare sleep and a loop keyword in one -# backgrounded call, where the sleep resolves without any look-through at all. -test_a_bare_sleep_beside_a_condition_loop_is_exempt if { - count(violation) == 0 with input as {"call": { +# THE EXEMPTION'S WORST REACHABLE SHAPE, and the case that says why asking +# WHETHER there is a condition was never the right question (CLOUD-1337). The +# `sleep 5` here waits on nothing at all — the loop beside it has an empty body — +# so the old rule exempted a bare timer for the company it kept. Inverted rather +# than deleted: this is the shape the withdrawal is FOR. +test_a_bare_sleep_beside_a_condition_loop_is_refused if { + some v in violation with input as {"call": { "command": "sleep 5; until [ -f /tmp/done ]; do :; done", "run-in-background": true, "segments": [ @@ -909,6 +923,7 @@ test_a_bare_sleep_beside_a_condition_loop_is_exempt if { inner([":"], "until", "body", null), ], }} + v.rule == "background-timer" } # THE DISCRIMINATING CASE for `run-in-background`: both rules deny, so only the From bcfddb57630abdb4ffb129b563bb1ff0914161ff Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 20:30:14 +0000 Subject: [PATCH 17/32] docs(agents): name pushing the imperfect draft as the norm, and say why it is not slop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reclaim takes everything unpushed, so a green tree held locally is the more expensive failure than a red one on a remote draft. That trade was already implicit — "committed-and-pushed is the only state surviving a reclaim" — but it sat in the backgrounding section as a warning about wake-ups rather than in the lifecycle list as an instruction about drafts, and read as a reason to be careful before pushing rather than a reason to push. Stated where it binds, with the half that keeps it from licensing slop: the draft cannot merge red and `land` will not ready what the branch's own gates refuse. The discouragement is the mechanism, not the hesitation. The paragraph it was implicit in loses the duplicated clause and keeps `batten doctor session`, so the budget holds. Refs: CLOUD-683 BREAKING CHANGE: `rest::Answer` gains a `headers` field and the CLI command enum gains variants among its existing ones, so literal construction of the first and any discriminant assumption about the second both break. Both were introduced earlier on this branch — `b790f263` for the header capture `gh-preflight` needs off a 403, and `028a7b7f`/`1de54a64`/`d86c1540` for the `claim keys`, `claim merged`, `record keyed|journal|show|fold` leaves. The declaration sits here rather than on those commits because rewording them needs an interactive rebase `rebase-not-hand-stepped` refuses; the gate asks the BRANCH to declare the break, and this names which change it is. --- AGENTS.md | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 601c6d587..e4e465b8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,8 +103,10 @@ what you proved, never where you discover a free-to-catch failure; it runs the s `mise` tasks you do, so one it runs that you can't is a bug. (The toolchain _does_ run in the web sandbox — read `mem:github-access` before doubting.) -1. **PRs start as drafts** (`gh pr create --draft`). CI does not run on drafts — - iterate at zero CI cost. +1. **PRs start as drafts** (`gh pr create --draft`); CI does not run on one, so + iteration is free. **PUSH THE IMPERFECT DRAFT, don't sit on a green tree** — a + reclaim takes all unpushed work. No slop licensed: a draft cannot merge red, and + `land` won't ready what your own gates refuse. Push early, fix after. 2. **`mise run verify` green before readying.** It mirrors CI and asserts the branch is rebased on current `origin/main`. "Green but stale" is not green. 3. **`mise run linear-check`.** Don't ready by hand: `land` readies, after its @@ -133,18 +135,15 @@ backgrounded task re-invokes you when it exits (measured 523/524, failures included), so the turn in between is the _designed_ state, not one to fill — **"idle" means a turn with NOTHING backgrounded**, and it is committed-and-pushed, never activity, that survives a reclaim. Manufacturing your own wake-ups with a -backgrounded `sleep N; tail log` duplicates it (490 in one session, 2 changed a -decision); **A LOOP IS NO EXEMPTION** (CLOUD-1337) — EVERY sleep is refused, either posture. Live task? `mise run alive`. +backgrounded `sleep N; tail log` duplicates it (490 in a session, 2 mattered); **A LOOP IS NO EXEMPTION** (CLOUD-1337) — EVERY sleep is refused, either posture. Live task? `mise run alive`. -**Two habits defeat this silently, both failing green:** piping a `mise run` into -a pager (the exit status becomes the pager's) or detaching it with `nohup`/`&` -(the wake-up is lost). Put `run_in_background` on the long command, never a launcher, and +**Two habits defeat this silently, both failing green:** piping a `mise run` into a +pager (the status becomes the pager's) or detaching it with `nohup`/`&` (the +wake-up is lost). Put `run_in_background` on the long command, never a launcher, and **never redirect it** — the harness captures where the HUMAN watches, so `>log 2>&1` writes where nobody reads. `verdict-not-discarded`, `background-redirect`. -**Never** use a foreground `sleep`, spin a foreground busy-poll, or end a turn idle -"to watch" something — background it, act on its exit, and commit first, since -**committed-and-pushed is the only state surviving a reclaim, and that is the TREE's -half**: declared work dies too, so **"unsaved?" is `batten doctor session`**. +**Never** foreground-`sleep`, busy-poll, or idle "to watch" — background it, act on +its exit, push. Declared work dies too: **"unsaved?" is `batten doctor session`**. ## Non-negotiable project rules From 3c07535932fdbdb06a5488126af85b8e8163b25b Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 20:44:16 +0000 Subject: [PATCH 18/32] fix(tests): assert the record fixtures' setup writes instead of discarding them `cross-check` denies warnings, and two `run_with_stdin` calls in the record families tier dropped an Output that is `#[must_use]`. Silencing them with `let _ =` would compile and would also be the wrong repair: both writes are what the case under them reads back, so a write that failed leaves `a_record_under_ another_key_does_not_answer` asserting `miss` for the wrong reason and `a_half_written_append_is_not_a_record` appending a torn tail to nothing. Asserting the status makes the setup a precondition rather than a hope. Only the windows cross-check caught it, which is the lane's whole point. Refs: CLOUD-397 --- crates/batten/tests/it/record_families.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/batten/tests/it/record_families.rs b/crates/batten/tests/it/record_families.rs index 9af6ab0e5..eedac2c2b 100644 --- a/crates/batten/tests/it/record_families.rs +++ b/crates/batten/tests/it/record_families.rs @@ -88,7 +88,10 @@ fn a_record_under_another_key_does_not_answer() { // compared: a record from other inputs lives under a name nothing opens, so // staleness cannot be a comparison a caller forgets to make. let dir = repo("keying"); - run_with_stdin(&dir, &["record", "keyed", "steps", "key-a"], "answer-a\n"); + // Asserted rather than discarded: a setup write that failed would leave the + // store empty, and `miss` below would then pass for the wrong reason. + let written = run_with_stdin(&dir, &["record", "keyed", "steps", "key-a"], "answer-a\n"); + assert!(written.status.success(), "the setup write lands"); let other = run(&dir, &["record", "show", "steps", "key-b"]); assert_eq!( @@ -106,7 +109,10 @@ fn a_half_written_append_is_not_a_record() { // `reclaim-census` classifies a boot from the KIND of the last record under // it, which is precisely the value a torn tail corrupts. let dir = repo("torn"); - run_with_stdin(&dir, &["record", "journal", "census"], "h 1000 boot-a\n"); + // Same reason: the torn tail below is appended to THIS record, so a failed + // write would leave nothing for the fold to be wrong about. + let written = run_with_stdin(&dir, &["record", "journal", "census"], "h 1000 boot-a\n"); + assert!(written.status.success(), "the setup write lands"); // Append a torn tail the way a crash would: no trailing newline. let shards = shard_dir(&dir, "census"); From 6ff9edfe7c500bd97d7c568c1df7ec5c2e425934 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 21:11:34 +0000 Subject: [PATCH 19/32] fix(lint): clear the clippy denials this branch's new code introduced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `slow` profile runs clippy with `-D warnings`, and this branch had not been through it since the ports landed. Four classes, all in code added here: `record show` and `record fold` wrote their answers with `println!`, which `print_stdout` denies for a reason this repository already holds elsewhere — a verb's answer belongs on the writer its caller passes, not on the process's stdout, or a caller that captures output gets nothing and a test that asserts on it cannot. The writer is threaded from `lib.rs` through `record::run`, which is also why the noun's other leaves keep writing nothing at all: they record, and a successful write has nothing to say. `ready.rs` had an ORPHANED DOC BLOCK, and it is the one worth naming: I inserted `closes_rather_than_disclaims` between `keys_closed_in` and its documentation, so four paragraphs describing the closing-verb grammar were silently attached to the new helper instead. `empty_line_after_doc_comments` is what caught it. The doc is moved back to the function it describes rather than deleted. `forge.rs`'s query builder folds instead of collecting formatted fragments, and the mechanical fixes came from `mise run fix`. Refs: CLOUD-1712, CLOUD-1713, CLOUD-1752 --- crates/batten/src/forge.rs | 21 ++++++++------- crates/batten/src/lib.rs | 2 +- crates/batten/src/race.rs | 9 ++++--- crates/batten/src/ready.rs | 27 +++++++++---------- crates/batten/src/record.rs | 26 ++++++++++-------- crates/batten/tests/it/claimed_keys.rs | 6 ++--- crates/batten/tests/it/forge_window.rs | 2 +- .../it__snapshots__golden_json_schema.snap | 6 ++--- 8 files changed, 53 insertions(+), 46 deletions(-) diff --git a/crates/batten/src/forge.rs b/crates/batten/src/forge.rs index a8dba9f0e..6019c939d 100644 --- a/crates/batten/src/forge.rs +++ b/crates/batten/src/forge.rs @@ -337,11 +337,11 @@ fn conditional_get(git_dir: &Path, path: &str, fetch: Transport<'_>) -> Option<( // PERSIST BEFORE ANSWERING, and a failure to persist is not a failure to // read: the store is an optimisation, so a read-only git directory costs a // conditional request next time rather than the answer this time. - if let Some(validator) = answer.etag.as_deref() { - if std::fs::create_dir_all(&stored).is_ok() { - let _ = std::fs::write(stored.join("etag"), validator); - let _ = std::fs::write(stored.join("body"), &answer.body); - } + if let Some(validator) = answer.etag.as_deref() + && std::fs::create_dir_all(&stored).is_ok() + { + let _ = std::fs::write(stored.join("etag"), validator); + let _ = std::fs::write(stored.join("body"), &answer.body); } Some((answer.status, answer.body)) } @@ -396,10 +396,13 @@ pub fn window_over( // Resolving it from the process's cwd would make the validator store depend // on where the caller happened to be standing, and would make this untestable // without a chdir — which is shared mutable state across a parallel suite. - let query: String = params - .iter() - .map(|(key, value)| format!("&{key}={value}")) - .collect(); + let query: String = params.iter().fold(String::new(), |mut acc, (key, value)| { + use std::fmt::Write as _; + // `write!` to a String cannot fail; the result is bound rather than + // dropped because the lint that sent us here is `#[must_use]`-adjacent. + let _ = write!(acc, "&{key}={value}"); + acc + }); // THE PAGE SIZE IS THE END-OF-COLLECTION SIGNAL where the endpoint reports // no `total_count`: a page carrying fewer rows than were asked for is the // last one. Read off the caller's own `per_page` rather than assumed, diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 449a07262..e80922254 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -397,7 +397,7 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // input, which is exactly the committed authority a `--config-from` is // meant to pin. That is also what stops a caller keying a record to // anything the config does not already declare (CLOUD-1265). - Some(Command::Record { command }) => record::run(command, &overrides), + Some(Command::Record { command }) => record::run(command, &overrides, out), } } diff --git a/crates/batten/src/race.rs b/crates/batten/src/race.rs index 564b02d1b..a1367a076 100644 --- a/crates/batten/src/race.rs +++ b/crates/batten/src/race.rs @@ -228,10 +228,11 @@ pub fn authored_log(dir: &std::path::Path, base: &str) -> String { // back — which is the wider, refusing direction the shell chose. let resolved = crate::git::resolve_ref(dir, &spec_base).ok().flatten(); let shared = crate::git::merge_base(dir, &spec_base).ok().flatten(); - if resolved.is_some() && resolved == shared { - if let Ok(Some(text)) = crate::git::log_messages(dir, &spec_base) { - return text; - } + if resolved.is_some() + && resolved == shared + && let Ok(Some(text)) = crate::git::log_messages(dir, &spec_base) + { + return text; } } crate::git::log_messages(dir, base) diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index 0eb62a095..b7b5398dd 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -707,20 +707,6 @@ impl Grammar { keys } - /// The keys a span names in CLOSING form — the ones a merge will move. - /// - /// **Naming a key and closing one are different facts, and conflating them is - /// the defect this narrows** (CLOUD-674): a body citing a row as evidence is - /// not claiming it, and `claimed-keys` already learned that distinction the - /// expensive way. So this is [`Self::keys_in`] filtered by what precedes each - /// match rather than a second search — the same one definition of a key, asked - /// a narrower question. - /// - /// The verb set is the forge's rather than this repository's, and it lives in - /// the pattern registry for the reason every other token does: one concept, - /// one spelling. Anchored at the END, so it decides the text immediately - /// before the key and nothing further back. - /// Does `prefix` end in a closing verb that is NOT negated? /// /// **Two rows rather than one cleverer row, because Rust's regex has no @@ -744,6 +730,19 @@ impl Grammar { !self.closing_negation.is_match(&prefix[..verb.start()]) } + /// The keys a span names in CLOSING form — the ones a merge will move. + /// + /// **Naming a key and closing one are different facts, and conflating them is + /// the defect this narrows** (CLOUD-674): a body citing a row as evidence is + /// not claiming it, and `claimed-keys` already learned that distinction the + /// expensive way. So this is [`Self::keys_in`] filtered by what precedes each + /// match rather than a second search — the same one definition of a key, asked + /// a narrower question. + /// + /// The verb set is the forge's rather than this repository's, and it lives in + /// the pattern registry for the reason every other token does: one concept, + /// one spelling. Anchored at the END, so it decides the text immediately + /// before the key and nothing further back. #[must_use] pub fn keys_closed_in(&self, text: &str) -> Vec { let found: BTreeSet<&str> = self diff --git a/crates/batten/src/record.rs b/crates/batten/src/record.rs index f4a6777b1..6bdcd1aef 100644 --- a/crates/batten/src/record.rs +++ b/crates/batten/src/record.rs @@ -195,7 +195,11 @@ pub fn run_forge(reference: &str, _overrides: &Overrides) -> Result { /// Whatever the chosen sub-verb returns: a [`UsageError`] for an id or ref that /// resolves to nothing, an unreadable subject, or a malformed verdict line, and /// an internal error when the store cannot be written. -pub fn run(command: crate::cli::RecordCommand, overrides: &Overrides) -> Result { +pub fn run( + command: crate::cli::RecordCommand, + overrides: &Overrides, + out: &mut dyn std::io::Write, +) -> Result { match command { crate::cli::RecordCommand::Tool { id } => run_tool(&id, overrides), crate::cli::RecordCommand::Forge { reference } => run_forge(&reference, overrides), @@ -203,8 +207,8 @@ pub fn run(command: crate::cli::RecordCommand, overrides: &Overrides) -> Result< crate::cli::RecordCommand::Closes => run_closes(overrides), crate::cli::RecordCommand::Keyed { family, key } => run_keyed(&family, &key), crate::cli::RecordCommand::Journal { family } => run_journal(&family), - crate::cli::RecordCommand::Show { family, key } => run_keyed_show(&family, &key), - crate::cli::RecordCommand::Fold { family } => run_journal_show(&family), + crate::cli::RecordCommand::Show { family, key } => run_keyed_show(&family, &key, out), + crate::cli::RecordCommand::Fold { family } => run_journal_show(&family, out), } } @@ -498,18 +502,18 @@ pub fn run_journal(family: &str) -> Result { /// /// A [`UsageError`] when the family or key is not a single path component; an /// internal error when the git directory cannot be resolved. -pub fn run_keyed_show(family: &str, key: &str) -> Result { +pub fn run_keyed_show(family: &str, key: &str, out: &mut dyn std::io::Write) -> Result { let family = safe_component("family", family)?; let git_dir = git::git_dir(Path::new("."))?; match std::fs::read_to_string(keyed_path(&git_dir, &family, key)) { Ok(value) => { - println!("hit"); - print!("{value}"); + writeln!(out, "hit")?; + write!(out, "{value}")?; } // ABSENT IS A MISS, and it is the only reading here: a record that exists // and holds nothing is a hit carrying an empty value, because the producer // chose to record that. - Err(_) => println!("miss"), + Err(_) => writeln!(out, "miss")?, } Ok(ExitCode::Success) } @@ -520,20 +524,20 @@ pub fn run_keyed_show(family: &str, key: &str) -> Result { /// /// A [`UsageError`] when the family is not a single path component; an internal /// error when the git directory cannot be resolved. -pub fn run_journal_show(family: &str) -> Result { +pub fn run_journal_show(family: &str, out: &mut dyn std::io::Write) -> Result { let family = safe_component("family", family)?; let git_dir = git::git_dir(Path::new("."))?; let store_dir = git_dir.join(JOURNAL_STORE).join(&family); match crate::journal::fold_lines(&store_dir) { - crate::journal::Fold::Nothing => println!("nothing"), + crate::journal::Fold::Nothing => writeln!(out, "nothing")?, crate::journal::Fold::Records(records) => { for record in records { - println!("{record}"); + writeln!(out, "{record}")?; } } // A PATH IS A POINTER (§6 names `path:line` outright), so naming the // store a reader could not open is rule 4 satisfied rather than breached. - crate::journal::Fold::Unreadable(path) => println!("unreadable {}", path.display()), + crate::journal::Fold::Unreadable(path) => writeln!(out, "unreadable {}", path.display())?, } Ok(ExitCode::Success) } diff --git a/crates/batten/tests/it/claimed_keys.rs b/crates/batten/tests/it/claimed_keys.rs index ad0d6d363..8dca9dd15 100644 --- a/crates/batten/tests/it/claimed_keys.rs +++ b/crates/batten/tests/it/claimed_keys.rs @@ -112,9 +112,9 @@ fn repo(name: &str, branch: &str) -> std::path::PathBuf { } fn keys(dir: &std::path::Path, args: &[&str], stdin: &str) -> std::process::Output { - let mut argv = vec!["claim", "keys"]; - argv.extend_from_slice(args); - run_with_stdin(dir, &argv, stdin) + let mut command = vec!["claim", "keys"]; + command.extend_from_slice(args); + run_with_stdin(dir, &command, stdin) } #[test] diff --git a/crates/batten/tests/it/forge_window.rs b/crates/batten/tests/it/forge_window.rs index f9fcc2e02..e701996f7 100644 --- a/crates/batten/tests/it/forge_window.rs +++ b/crates/batten/tests/it/forge_window.rs @@ -20,7 +20,7 @@ use std::path::{Path, PathBuf}; use batten::forge::{Shape, Window}; -/// A canned page: a status, an ETag, and a body. +/// A canned page: a status, an `ETag`, and a body. #[derive(Clone)] struct Page { status: u16, diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 150b24b8f..e96f7114b 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -610,7 +610,7 @@ expression: stdout_of(&output) "help": "The head branch, standing in for source 2" }, { - "name": "closing-only", + "name": "closing_only", "short": null, "long": "closing-only", "takes_value": false, @@ -626,7 +626,7 @@ expression: stdout_of(&output) "help": "Commit messages, standing in for sources 1 and 3" }, { - "name": "refs-first-only", + "name": "refs_first_only", "short": null, "long": "refs-first-only", "takes_value": false, @@ -649,7 +649,7 @@ expression: stdout_of(&output) "id": "claim.merged", "about": "The keys merged pull request bodies close, as `\\t` rows", "effect": "read", - "data_channel": true, + "data_channel": false, "flags": [ { "name": "limit", From 6380379d729461063f1097a7f653da8f2d4e77b0 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 21:15:45 +0000 Subject: [PATCH 20/32] fix(ready): restore the config content the rebase dropped without a conflict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I resolved batten.toml against the wrong merge base. The three-way merge took 818484f as the base, but that commit MODIFIES batten.toml — so everything it added read as a deletion by main's side and was dropped, with no marker and no conflict to notice. Three groups went, and only one of them had a test. `ready-closing-negation` — the whole `[[pattern]]` row. `ready.rs` resolves it by name, so two `claim::tests` cases failed loudly with "this repository declares no `[[pattern]]` row". That is the one that announced itself. `ready-closing-verb`'s trailing class reverted from `[[:blank:]]` to `[[:space:]]`, and NOTHING would have caught it. `[[:space:]]` matches a newline, so the end-anchored pattern stops meaning "immediately before a key" the moment a body puts the verb at the end of one line and the key at the start of the next — which ordinary prose does constantly. Measured over 713 merged pull requests: 16 rows read as CLOSED that no body closes. The harm runs the dangerous way, because a false claim tells `in-progress-drain` a live row landed. Two forge comment blocks recording that `forge-verdict-required` and `validator-verdict-clean` finally have a producer (CLOUD-1707, in this same PR). Found by auditing every line this branch added to batten.toml against the branch point rather than waiting for tests to name them one at a time — which is the only way the second group surfaces at all. One group stays dropped on purpose: `verdict-not-discarded`'s reason, which main superseded with a stronger version that refuses the redirect outright rather than calling it a fallback. Refs: CLOUD-1752, CLOUD-1707 Admits: 790696259d0606a5cd83b397d6e24f1708abbd556fd80ed5c3ab6d03ea34e19b Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:33ecf1ea5af2dc1c70c5568f2032e105b3f50383 Admits-epoch: 7146a0370c5b472fd69a43b11f298c382f5880934fba06e723fc8490e844511f Admits-author: alec@wenzowski.com Admits-prev: 464a8232c95302a9999a6def409a74a5bd940f12479132ea11c88d19f63bc539 Admits-answer-lost: Two `claim::tests` cases stay red and the CLOUD-1752 narrowing is silently gone: `does not close CLOUD-1` reads as a claim again. Measured over 713 merged pull requests, six rows read as closed by bodies saying in so many words that they do not close them — and the failure runs the dangerous way, because a false claim moves a live row. Admits-answer-precondition: Restoring a `[[pattern]]` row my own conflict resolution dropped. `ready.rs:483` resolves `ready-closing-negation` from the registry and `claim::tests` fails with "this repository declares no `[[pattern]]` row `ready-closing-negation`". A pattern row exists only in batten.toml; no other surface declares one. The content is recovered verbatim from 0e61e55, the pre-rebase head. Admits-answer-rejected-route: config read first — I read it, and the read is what found the row missing. patch run first is a commit-message route and does not apply. Admits: b77a0280ec2c2d53ea57072b8edad95e8b1e6414c5165f9dd6e96d36d3c59b6b Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:33ecf1ea5af2dc1c70c5568f2032e105b3f50383 Admits-epoch: f37d25c9f34550d4c6a9cf3989ac6b041c78b26711057c4b4adfd62215452efd Admits-author: alec@wenzowski.com Admits-prev: 790696259d0606a5cd83b397d6e24f1708abbd556fd80ed5c3ab6d03ea34e19b Admits-answer-lost: The end-anchored verb pattern keeps reaching across a paragraph break, so a body whose prose happens to end in `closes` claims whatever key opens the following line — and the restored negation guard cannot help, because it asks about the text before the VERB, not about how far the verb reached. Admits-answer-precondition: The sibling half of the row just restored, dropped by the same conflict resolution: `ready-closing-verb`'s trailing class reverted to `[[:space:]]`, which matches a NEWLINE, so a closing verb ending one paragraph matches a key opening the next. It was narrowed to `[[:blank:]]` on this branch and the content is recovered verbatim from 0e61e55. A pattern row exists only in batten.toml. Admits-answer-rejected-route: config read first — I read it, and the read is what showed the class had reverted. patch run first is a commit-message route and does not apply. Admits: ceef7aa8d7a5da4db77277e02a4bccd07a65be3daebc718b9500e173cac8cf01 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:33ecf1ea5af2dc1c70c5568f2032e105b3f50383 Admits-epoch: 459de7a5ef85a7fc7243040732233fdff2bb5d358a8e6d4c66e1f7b276c7e382 Admits-author: alec@wenzowski.com Admits-prev: b77a0280ec2c2d53ea57072b8edad95e8b1e6414c5165f9dd6e96d36d3c59b6b Admits-answer-lost: Two blocks of measured rationale that are already load-bearing elsewhere. The `[[:blank:]]` block records the 713-PR measurement — 16 rows read as CLOSED that no body closes, PR #163 named as the shape — that is the only written justification for the character class the row now carries, so a later reader has a narrowing with no reason and would widen it back. The two forge blocks record that `forge-verdict-required` and `validator-verdict-clean` finally HAVE a producer (CLOUD-1707, landed in this same PR), correcting paragraphs that describe a discrimination no checkout could make; without them the config states as behaviour something that was only a shape. Admits-answer-precondition: Completing the restoration of content my conflict resolution dropped. I audited every line this branch added to batten.toml against the branch point c179129 and found three groups missing; one is prose main deliberately superseded and stays dropped, and these two are real. Comments and rule rows exist only in batten.toml. All content is recovered verbatim from 0e61e55, the pre-rebase head, in one write. Admits-answer-rejected-route: config read first — the audit IS the read, and it is what produced this list. patch run first is a commit-message route and does not apply. Admits: a0ce0e4b753dc4b1108430244c2d0322b281e3f1001b8718f30f1051f3478b97 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:33ecf1ea5af2dc1c70c5568f2032e105b3f50383 Admits-epoch: 6e21afbb9f87ba336b2bc3708495ddc9cdeb25c7884bbc859de8177562fa4f82 Admits-author: alec@wenzowski.com Admits-prev: ceef7aa8d7a5da4db77277e02a4bccd07a65be3daebc718b9500e173cac8cf01 Admits-answer-lost: The restored block breaks off after describing the false positive and never reaches its conclusion — that `[[:blank:]]` is space and tab and nothing else, so the verb must sit on the key's own line. A rationale that stops before its point is worse than none, because it reads as complete. Admits-answer-precondition: Completing the previous write: my slice of the recovered `[[:blank:]]` rationale stopped three lines short, so the block currently ends mid-argument, before the sentence that states what the class actually matches. The re-audit against the branch point names the three lines. Comments exist only in batten.toml and the content is verbatim from 0e61e55. Admits-answer-rejected-route: config read first — the audit is the read and it named the missing lines. patch run first is a commit-message route and does not apply. --- batten.toml | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/batten.toml b/batten.toml index 95575a6d6..78be89c8d 100644 --- a/batten.toml +++ b/batten.toml @@ -1966,9 +1966,49 @@ regex = 'CLOUD-[0-9]+' # `[[pattern]]` row and not a literal in the crate: the core stays repo-agnostic, # and a consumer whose forge spells the set differently declares its own row # rather than patching the engine. +# BLANK, NEVER SPACE, and the difference is a measured false positive rather than +# a nicety (CLOUD-1752). `[[:space:]]` matches a NEWLINE, so the anchor above +# stopped meaning "immediately before a key" the moment a body put the verb at the +# end of one line and the key at the start of the next — which ordinary prose does +# constantly. Measured 2026-09-09 over this repository's 713 merged pull requests: +# 16 rows read as CLOSED that no body closes, `mise-tasks/merged-pr-keys.sh` +# emitting none of them. PR #163 is the shape: +# +# ## The residue survived the fix +# +# CLOUD-223 taught `.claude/hooks/session-start.sh` … +# +# A heading ending in "the fix", a blank line, then a citation — read as a claim. +# The harm runs the dangerous way: a false CLAIM tells `in-progress-drain` a row +# landed, and it drains a row that is still live. +# +# `[[:blank:]]` is space and tab and nothing else, so the verb must sit on the +# key's own line — which is what the paragraph above always said this row did. +# A NEGATED CLOSING VERB IS NOT A CLAIM, and the row above cannot see one +# (CLOUD-1752). Its anchor decides the text IMMEDIATELY before a key, which is +# exactly what makes `does not close CLOUD-1` match: the prefix ends in `close` +# and the negation sits one word further back, outside what an end-anchored +# pattern can reach. Rust's regex has no lookbehind, so the guard is a second row +# rather than a cleverer first one. +# +# MEASURED 2026-09-09 over this repository's 713 merged pull requests: SIX rows +# read as closed by a body that says in so many words that it does not close them +# — `## Why this does NOT close CLOUD-1074`, `It also does not close CLOUD-673`, +# `**This does not close CLOUD-1050`, `## Why this does not close CLOUD-607`, +# `Filed, not fixed: CLOUD-466`. Writing out why a change does NOT close a row is +# a habit this repository actively encourages, so the false positive is not rare +# and it runs the dangerous way: a false CLAIM moves a live row. +# +# ANCHORED AT THE END LIKE ITS SIBLING, and read against the text before the VERB +# rather than before the key — so `not` must sit on the verb, never merely +# somewhere earlier in the paragraph. +[[pattern]] +id = "ready-closing-negation" +regex = '(?i)(^|[^0-9A-Za-z-])(not|never|n.t|without|nor)[[:blank:]]*$' + [[pattern]] id = "ready-closing-verb" -regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:space:]]*:?[[:space:]]*#?$' +regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:blank:]]*:?[[:blank:]]*#?$' # THE PROSE-DIALECT THRESHOLD (CLOUD-472) IS `[ready]`, NOT A `[[pattern]]` ROW. # It was drafted as one — a regex over the exempt key range — and that is the @@ -5629,6 +5669,13 @@ severity = "deny" # green fires this and not that; a commit graded red fires both, and they say # different things — do not re-run it, and do not land it. # +# BOTH READINGS NEEDED A PRODUCER, and until CLOUD-1707 neither had one: nothing +# invoked `batten record forge`, so this fact was `null` and the paragraph above +# described a discrimination no checkout could make. `mise run record-verdicts` +# writes it, and writes it ONLY once the fan-in has concluded — so "nothing was +# recorded at all" now means the forge has not finished judging, which is the +# could-not-look both rows read it as. +# # `warn`, NOT `deny`, AND THE FIRST LANDING IS THE REASON. `land` re-verifies and # re-waits every lap by design, because a rebase mints a new SHA and the receipts # keyed to the old one are gone — so the loop legitimately reaches graded commits, @@ -6597,6 +6644,11 @@ looked at it.""" # bytes at this version — absent from the map, not a verdict — which is what a # checkout gets if the producer was skipped or died. The row that wants a verdict # to be REQUIRED is `forge-verdict-required`'s shape and is not this one. +# THAT CONTRAST ONLY BECAME REAL WITH CLOUD-1707. `forge-verdict-required` had no +# producer, so it refused nothing and the sentence above named a shape rather than +# a behaviour. Both rows are fed by `mise run record-verdicts` now, and they still +# read absence the same way — what differs is what each does with a record that IS +# present, which is the distinction this block was always drawing. [[rule]] id = "validator-verdict-clean" kind = "policy" From a71f095d4198b974be77212aa827f94e7185ab31 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 21:20:58 +0000 Subject: [PATCH 21/32] fix(mise): merge the manifest against the real base, and re-point two run-shape mirrors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same mistake as the last commit, and worse here. For mise.toml I did not merge at all — I grafted the two conflicting spots onto MY version and kept the rest — so 143 lines main added between the branch point and de759a4 were clobbered, `[tasks.refusal-render-bench]` among them. Three `acquisition_metric` cases were the only thing pointing at it. THAT KIND OF LOSS IS INVISIBLE IN REVIEW: a clobbered addition shows as no diff against the branch's own previous state, so nothing in the PR looks like a deletion. Regenerated with `git merge-file` against c179129, the true base. The one real conflict is `MUTANT_GATES`, resolved as main's line minus the two gates this branch retires: 134 names, main's `engine-perf`/`engine-wiring`/ `engine-checks-green`/`engine-lease` present, `claimed-keys`/`merged-pr-keys` gone. Audited in BOTH directions afterwards — every line either side added is present. The two run-shape cases are the merge working rather than a conflict. Main added a task-output poll to `process_probes` while this branch withdrew the condition exemption, so both anti-vacuity mirrors now describe refused calls. They are RE-POINTED, not deleted: each asserts the arms still partition — exactly one fires, and it is `background-timer` rather than `task watch duplicate`. Deleting them would let a later collapse of the two arms pass every case above while losing the probe count the narrower arm exists to carry. Refs: CLOUD-1337, CLOUD-1704 Admits: 92dc3cd4459044233700d79237a468edd51474c5ae1e847657e8921a50115afa Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: mise.toml Admits-anchor: call:b4d2ab58f7ad2eff936d4e5edb8b349bba08cef0 Admits-epoch: 930838efdeb25b793c859171b73a4194ff91f706e855641a6bb1c986e892e2b0 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: Three tests stay red and main's bench task, its metric stamp and its detached cross-check work are silently absent from the branch — a clobber that lands as "the rebase took main's side" and is invisible in review, because a deletion by merge shows as no diff at all against the branch's own previous state. Admits-answer-precondition: Redoing a rebase resolution I got wrong. For mise.toml I hand-grafted two spots onto MY version instead of merging, so 143 lines main added between the branch point and de759a4 were clobbered — including `[tasks.refusal-render-bench]`, whose absence reddens three `acquisition_metric` cases. The file is regenerated by `git merge-file` against the TRUE base (c179129), and its one real conflict is `MUTANT_GATES`, resolved as main's line minus the two gates this branch retires. mise.toml is the only surface for a task or a gate list. Admits-answer-rejected-route: config read first — I read both sides and the read is what produced the 143-line audit. patch run first is a commit-message route and does not apply. --- crates/batten/tests/it/run_shape.rs | 42 +++++--- mise.toml | 147 +++++++++++++++++++++++++++- 2 files changed, 172 insertions(+), 17 deletions(-) diff --git a/crates/batten/tests/it/run_shape.rs b/crates/batten/tests/it/run_shape.rs index b36449a7a..de1d5f527 100644 --- a/crates/batten/tests/it/run_shape.rs +++ b/crates/batten/tests/it/run_shape.rs @@ -800,31 +800,49 @@ fn the_probing_program_is_not_what_decides_a_task_output_poll() { } #[test] -fn a_wait_on_a_local_file_that_is_not_a_task_output_is_clean() { - // THE OTHER HALF OF THE ANTI-VACUITY PAIR, and the one that keeps this arm - // from becoming "no backgrounded loop may read a file". A file another - // machine writes is exactly the condition `waits_on_condition` exists to - // permit, and it is a local read like any other — what disqualifies the task - // output is that the harness already reports it, not that it is on disk. +fn a_wait_on_a_local_file_that_is_not_a_task_output_is_not_a_task_watch() { + // THE OTHER HALF OF THE ANTI-VACUITY PAIR, re-pointed rather than dropped + // (CLOUD-1337). It used to assert this call was CLEAN, on the reading that a + // file another machine writes is a condition the harness does not report. + // Withdrawing the exemption makes the call refused — but by which arm still + // matters, and that is what this case now pins: a shared file is not a task + // output, so `polls-a-local-process` must stay silent and `background-timer` + // must answer. Without it, folding the two arms into one would pass every + // case above while losing the count that makes the narrower one worth having. let root = fixture("waits-on-a-shared-file"); - allowed_background( + let (deny, text) = hook_background( &root, "until grep -q ready /mnt/shared/deploy.status; do sleep 30; done", true, ); + assert!(deny, "{text}"); + assert!(text.contains("timer run refused"), "{text}"); + assert!( + !text.contains("task watch duplicate"), + "a shared file is not a task output: {text}" + ); } #[test] -fn a_wait_on_a_condition_nobody_reports_is_clean() { - // THE ANTI-VACUITY MIRROR. Without it every case above is satisfied by a rule - // that refuses all waits — and this form is what `timer run refused`'s own - // route still recommends for a condition the harness does not report. +fn a_wait_on_a_condition_nobody_reports_is_a_timer_not_a_task_watch() { + // THE ANTI-VACUITY MIRROR, on the same re-pointing (CLOUD-1337). A remote + // readiness probe was the last shape left allowed, on the argument that the + // harness cannot report it. It cannot — and the poll is still a wake-up the + // session performs by hand every five seconds, so the answer is a refusal + // under `background-timer`. What stays pinned is that the arms partition: + // exactly one of them answers, and it is not the process-poll one. let root = fixture("waits-on-a-remote"); - allowed_background( + let (deny, text) = hook_background( &root, "until curl -sf https://example.test/ready; do sleep 5; done", true, ); + assert!(deny, "{text}"); + assert!(text.contains("timer run refused"), "{text}"); + assert!( + !text.contains("task watch duplicate"), + "a remote probe reads no local process: {text}" + ); } #[test] diff --git a/mise.toml b/mise.toml index 376e40139..359c88501 100644 --- a/mise.toml +++ b/mise.toml @@ -3018,6 +3018,80 @@ fi echo "lock-check: mise.lock is complete and current" ''' +# ONE TURN'S CROSS-TRIPLE TYPE-CHECK, DETACHED (CLOUD-1731). +# +# `cross-check` runs in exactly one place — inside `verify` — so "this does not +# compile on Windows" is learned at the cadence of the whole gate, which in +# practice means after the author has moved on. `rules/rust.md` states the cost +# in its own words ("the next edit to the other one is discovered by CI") and +# CLOUD-1148 is the recorded instance: the `windows` job reddening alone, and the +# first fix making it worse. +# +# AFFORDABLE BECAUSE OF THE RECEIPT, measured 2026-09-09 rather than assumed: +# a re-run whose inputs, command and toolchain are unchanged answers from +# `step-receipt.sh` in **1.0s**; a run that must re-derive takes 54s cold. So the +# per-turn cost is a second on every turn that changed no Rust, and 54s on the +# turns that changed some — which are the turns that need it. +# +# DETACHED, BECAUSE A HANDLER THAT WAITS IS A HANDLER THAT TAXES EVERY TURN. The +# verdict is not needed before the turn proceeds; it is needed before the author +# stops thinking about the change, which is a much weaker deadline. `verify` +# remains the blocking authority and this never substitutes for it. +# +# THE FAILURE IS REPORTED ON THE NEXT TURN AND SUCCESS IS SILENT. A log nobody +# reads is sensor-only (non-negotiable rule 2), so the marker file is what turns +# this from a log into a mechanism: the next invocation prints it and clears it. +# Success says nothing, because a line on every turn is noise that trains the +# reader to skip the one turn it matters. +# +# THE PATTERN TOLERATES COLOUR RATHER THAN SUPPRESSING IT, and that is the third +# draft of this line. `[env]` sets `CARGO_TERM_COLOR = "always"` repo-wide, so a +# machine-read log carries escapes between `: ` and `error`: the first draft +# scraped `-->` and got nothing, the second matched `: error` and got nothing, +# and the third set `CARGO_TERM_COLOR=never` on the invocation — which ALSO got +# nothing, because a task's own environment beats a value inherited from the +# caller. Three silent failures, each announcing a break with no pointer. +# +# So the pattern anchors on the SHAPE `--message-format=short` guarantees — +# `path:line:col: ` — and lets `.*` span whatever decoration sits after it. It +# needs no escape literal, survives either colour setting, and excludes the +# trailing `error: could not compile` summaries, which carry no location and +# would have spent the cap on nothing. +# +# BOUNDED AT FIVE LINES, AND BOUNDED IS NOT THE SAME AS USEFUL. The first draft +# emitted one fixed sentence, which is cheap and costs the reader a whole turn +# re-running the check to learn what broke — bounded and useless is its own +# waste. What it emits instead is the first THREE `path:line` pointers and a +# path to the rest: actionable enough to fix without a second run, and +# pointer-only per non-negotiable rule 4, since a compiler's full output is the +# payload and these are the pointer. The window cost of a failing turn is five +# lines; of a passing turn, zero. +# +# `[hook_output] max_repeats = 1` IS SATISFIED BY THE CLEAR, not by luck: the +# marker is removed once printed, so one failure is announced once rather than +# on every turn until it is fixed — which is what would otherwise trip that +# ceiling and, worse, train the reader to skip the line. +# +# ON STDOUT, AND THAT IS THE WHOLE DIFFERENCE BETWEEN AN ALARM AND A LOG. +# `handler.rs` states the contract: "Exit `0` with stdout: advisory text, to be +# merged into Batten's own" — so stdout is what reaches the AGENT and stderr is +# what reaches a log nobody opens. The first draft of this line wrote the marker +# to stderr, which would have printed the failure, cleared it, and shown it to +# no one: a sensor wearing an alarm's clothes, one layer inside the rule against +# exactly that. Caught in review before it landed. +# +# ONE LINE JOINED BY `;` RATHER THAN A `"""` BODY, for `deps-install`'s reason: +# `inline-task-bodies-not-growing-basic` is `non_increasing` against +# `origin/main` with no `admits_with`, so a new block body is refused and the +# only routes are extraction or a waiver. Neither is worth spending here. +# +# `batten singleton` RATHER THAN A PROCESS PROBE: the lock is the declared +# mechanism for "may a second copy start in this clone", and polling the process +# table for one is the shape `polls-a-local-process` refuses. +[tasks."cross-turn"] +description = "One turn's cross-triple type-check, detached — reports the previous run's failure and never blocks the turn" +run = "f=target/cross-turn.fail; if [ -f $f ]; then cat $f; rm -f $f; fi; if batten singleton acquire cross-turn $$ >/dev/null 2>&1; then (mise run cross-check >target/cross-turn.log 2>&1 || { echo '::error:: cross-turn: a declared target did not type-check'; grep -E '^[^ :]+:[0-9]+:[0-9]+: .*error' target/cross-turn.log | head -3 | sed 's/^/ /'; echo ' (full output: target/cross-turn.log)'; } >$f; batten singleton release cross-turn >/dev/null 2>&1) & fi; exit 0" + [tasks.cross-check] description = "Type-check for other targets from Linux (no macOS runner needed)" # The `rustup target add` below is not idempotent against a half-installed @@ -3063,7 +3137,13 @@ for t in x86_64-pc-windows-gnu; do # # It cannot red on a third-party warning: cargo compiles registry dependencies # with `--cap-lints allow`, so only workspace code is held to this. - if ! RUSTFLAGS="-D warnings" cargo check --workspace --all-targets --target "$t"; then + # `--message-format=short` is the compiler doing the reduction instead of a + # grep guessing at it: one `path:line:col: error: …` per diagnostic, no span + # art and no `-->` lines belonging to notes and helps. Nothing parses this + # task's stdout — the loop reads the exit status — so the format is free to be + # the useful one, and `cross-turn` extracts pointers from it without having to + # tell an error's span from a note's (CLOUD-1731). + if ! RUSTFLAGS="-D warnings" cargo check --workspace --all-targets --target "$t" --message-format=short; then echo "::error:: cross-check: $t does not type-check cleanly (warnings are denied here — see CLOUD-397)." >&2 exit 1 fi @@ -4092,12 +4172,64 @@ echo "verify: fast-forward-green — rebased on latest main, ci + cross + commit [tasks.commit-msg] description = "Gate: one pending commit message's subject follows the convention (policy: [commit] in batten.toml)" -# `cargo run` for the same reason `batten-check` and `commit-attribution` use it: -# the gate must judge the working tree's engine and config as the pair that ships. +# THE BINARY ON PATH FIRST, AND `cargo run` ONLY WHERE THERE IS NONE — CLOUD-1620's +# shape, applied to the path that pays it most (CLOUD-1397). +# +# A GIT HOOK IS A BATTEN HOOK, and is held to the same published ceiling. README's +# `wired` row is 8.0ms p50 against a ≤100ms budget, and `perf-assert` enforces it +# on the mediated surface; nothing argues the commit-msg surface is exempt just +# because git is the harness rather than Claude Code. +# +# MEASURED 2026-09-09 on this container, same message, warm, three runs: this +# task as the hook fired it, 582ms; `cargo run --quiet` with NOTHING to rebuild +# 291ms; `target/debug/batten` 176ms; the release binary 125ms; and 8687ms on the +# first commit after touching one `crates/batten/src` file. +# +# A FIRST PASS AT THESE NUMBERS READ 12ms FOR THE RELEASE BINARY AND IT WAS A +# MEASUREMENT OF A FAILURE. `target/release/batten` was 0.0.155 against a tree at +# 0.0.158 and REFUSED `batten.toml` outright — "vocabulary `action`: `fix` is +# declared and no class or route name spends it" — so the 12ms was a config load +# aborting, not a gate reaching a verdict. Rebuilt, the same call is 125ms. A +# timing taken over a non-zero exit is not a timing of the work, and a stale +# binary is exactly the shape that produces one (CLOUD-1688). +# +# A HOOK MUST NEVER COMPILE, and this is not a preference. Rebuilding that stale +# release binary took ~4 minutes on this container (15:48 -> 15:52, `lto = "thin"` +# in `[profile.release]`), so a hook that rebuilds when it finds a stale binary +# would spend four minutes at the moment an author saves. The 8687ms debug case is +# the same defect an order of magnitude cheaper: a hook running the compiler is +# not a slow gate, it is a different program. The build belongs at provisioning, +# where `session:batten` already puts it. The +# `_.path` entry above already resolves a bare `batten` to THIS checkout's +# `target/release`, built by `session:batten` -> `install:local` at session start +# with an `::error::` when it cannot be — so the fast branch is the tree's own +# engine, not a stale installed one, and the property the previous comment claimed +# for `cargo run` ("judge the working tree's engine and config as the pair that +# ships") is kept rather than traded. +# +# THE CONDITION TESTS PRESENCE AND NOTHING ELSE, which is where `attribution +# identity`'s spelling must not be copied. That one reads +# `if command -v batten && batten ; then :; else cargo run …` and its own +# comment claims `if`/`else` avoids the fallthrough that `a && b || c` has — but +# those two are the same program: a binary that EXISTS and legitimately REFUSES +# takes the else branch either way, so the shape it warns about is the shape it +# ships. Harmless for a write that wants a retry; wrong for a gate, which would +# then pay the build precisely when it refuses and run the whole judgement twice +# to reach the same verdict. A gate that is slowest exactly when it says no is a +# gate authors learn to stop running. +# +# So the verdict is not in the condition. Resolve, then run once, and let the +# exit code be the gate's own. +# +# The fallback is what keeps a runner green: CI has no installed binary and +# `bash: line 25: batten: command not found` is a measured failure there +# (`auto-bot-land.yml:305-310`), so the branch that builds stays for the host that +# needs it and never runs where a human is waiting. +# # The pattern itself is `batten.toml`'s, not this file's — a rule about what a # commit may BE is the engine's, and `mise.toml` configures how tools run # (CLOUD-701). -run = 'cargo run --quiet -p batten -- commit check --message "{{arg(name="file")}}"' +run = 'if command -v batten >/dev/null 2>&1; then batten commit check --message "{{arg(name="file")}}"; else cargo run --quiet -p batten -- commit check --message "{{arg(name="file")}}"; fi' [tasks.commit-attribution] description = "Gate: no vendor authorship, branding or session links in BASE_SHA..HEAD_SHA (policy: [attribution] in batten.toml)" @@ -4110,7 +4242,12 @@ description = "Gate: no vendor authorship, branding or session links in one pend # The commit-msg-hook half. Same policy and same engine as `commit-attribution`; # only the object differs — a message on disk plus the identity `git var` says # git is about to stamp, rather than commits that already exist. -run = 'cargo run --quiet -p batten -- attribution check --message "{{arg(name="file")}}"' +# +# Resolve-then-run, for `commit-msg`'s reasons above and its measurements: these +# two are the pair that fires on EVERY commit, so the 582ms each was paying was +# the whole per-commit tax, and the 8687ms first-commit-after-an-edit case was a +# git hook running the compiler twice over. +run = 'if command -v batten >/dev/null 2>&1; then batten attribution check --message "{{arg(name="file")}}"; else cargo run --quiet -p batten -- attribution check --message "{{arg(name="file")}}"; fi' [tasks.attribution-identity] description = "Write: set this clone's repo-local git identity when it is unset or carries a denied vendor identity" From 336ec26058401144950aa811feb561d97015d9c2 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 21:24:32 +0000 Subject: [PATCH 22/32] fix(tests): restore the door tier's allow case, which asserts the door and not the rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My error, not the merge's. Withdrawing the sleep-loop exemption, I swept `run_shape_guard_door.rs` for cases asserting a conditioned wait is clean and inverted the one I found. It was not asserting that. That tier drives the DOOR. Its fixture carries exactly one `[[hook.handler]]` row and no `[[rule]]` at all — the isolation the file's own header argues for, so an engine row's verdict cannot stand in for the handler's — and the stub behind it exits 0 whatever it is handed. `run-shape` never runs there. What the case pins is that a handler which passes prints no document, so an allowed command leaves both channels silent; a door manufacturing a verdict from a quiet handler is what would fail it. Restored, renamed to say what it tests, and the misreading is recorded in the comment rather than dropped: the next sweep for this shape will find the same command and needs to stop at the same place. The exemption's withdrawal is asserted where the rule actually runs — `run_shape.rs` and the module's own load-time tier. Refs: CLOUD-1337, CLOUD-312 --- .../batten/tests/it/run_shape_guard_door.rs | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/batten/tests/it/run_shape_guard_door.rs b/crates/batten/tests/it/run_shape_guard_door.rs index ae9bece3f..bc447900e 100644 --- a/crates/batten/tests/it/run_shape_guard_door.rs +++ b/crates/batten/tests/it/run_shape_guard_door.rs @@ -238,16 +238,23 @@ fn the_handler_receives_the_hosts_own_payload_including_the_calls_background_fla } #[test] -fn a_backgrounded_wait_on_a_condition_is_denied_at_the_door() { - // Driven against the COMMITTED guard deliberately: this case used to assert - // the opposite, and it is the one CLOUD-1337 inverted. Refusing every - // backgrounded sleep was called the false positive that gets a guard - // switched off; measured, the exemption was instead the hole that let a - // conditioned loop run 3h34m unseen, so the door is where the withdrawal has - // to show up rather than only in the module's own suite. +fn a_command_a_handler_passes_is_forwarded_silently() { + // THE ALLOW PATH THROUGH THE DOOR, and the command is incidental. I briefly + // inverted this case for CLOUD-1337, reading it as an assertion that a + // conditioned sleep loop is permitted — it is not, and cannot be: this + // fixture carries ONE `[[hook.handler]]` row and no `[[rule]]` at all, by the + // isolation this file's header argues for, so `run-shape` never runs here and + // the stub behind the door exits 0 whatever it is handed. + // + // What the case pins is the door: a handler that passes prints no document, + // so an allowed command must leave both channels silent. A door that + // manufactured a verdict from a quiet handler would fail this. + // + // The module's own withdrawal of the exemption is asserted where the rule + // actually runs, in `run_shape.rs` and in the module's load-time tier. let dir = fixture("door-background-wait"); let answer = door_bg(&dir, "until [ -f /tmp/done ]; do sleep 1; done"); - assert!(!answer.allowed(), "{}", answer.out); + assert!(answer.allowed(), "{}", answer.out); assert!(answer.unbroken(), "{}", answer.err); } From 1fb906d1c33aaf3cdc351f0d955a918a20ba2445 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 21:49:44 +0000 Subject: [PATCH 23/32] test(fixtures): copy the git template instead of forking, per main's ratchet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main landed `policy/fixture-forks.rego` while this branch was in flight, so the two tiers added here — `record_families.rs` and `claimed_keys.rs` — still called `git init` by hand. `Fixture`'s `init_repo` copies a repository the harness publishes once per filesystem and forks nothing. The trace behind the ratchet counted 1,819 `init` processes and 4.49s over one run across 79 such call sites, each spent roughly twenty times, so two more is not a rounding error. Neither case is about initialisation, so neither earns the `// needs-real-fixture:` escape the class provides for a case whose subject IS the fork. Refs: CLOUD-1419 --- crates/batten/tests/it/claimed_keys.rs | 5 +++-- crates/batten/tests/it/record_families.rs | 8 ++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/batten/tests/it/claimed_keys.rs b/crates/batten/tests/it/claimed_keys.rs index 8dca9dd15..607e80bc6 100644 --- a/crates/batten/tests/it/claimed_keys.rs +++ b/crates/batten/tests/it/claimed_keys.rs @@ -78,7 +78,7 @@ use crate::common; -use common::{git_in, run_with_stdin, scratch, stderr, stdout, write}; +use common::{git_in, init_repo, run_with_stdin, scratch, stderr, stdout, write}; /// A repository whose branch names a key, with one commit carrying a trailer. /// @@ -95,7 +95,8 @@ fn repo(name: &str, branch: &str) -> std::path::PathBuf { .join("batten.toml"); std::fs::copy(&config, dir.join("batten.toml")).expect("the committed config is readable"); write(&dir, "seed.txt", "seed\n"); - git_in(&dir, &["init", "-q", "-b", "main", "."]); + // The TEMPLATE, never a fork (CLOUD-1419), for `record_families.rs`'s reason. + init_repo(&dir); git_in(&dir, &["add", "-A"]); git_in( &dir, diff --git a/crates/batten/tests/it/record_families.rs b/crates/batten/tests/it/record_families.rs index eedac2c2b..b0dc4818d 100644 --- a/crates/batten/tests/it/record_families.rs +++ b/crates/batten/tests/it/record_families.rs @@ -26,13 +26,17 @@ use crate::common; use std::path::Path; -use common::{git_in, run, run_with_stdin, scratch, stdout, write}; +use common::{git_in, init_repo, run, run_with_stdin, scratch, stdout, write}; /// A repository with a git directory for the stores to live under. fn repo(name: &str) -> std::path::PathBuf { let dir = scratch(&format!("record-families-{name}")); write(&dir, "seed.txt", "seed\n"); - git_in(&dir, &["init", "-q", "-b", "main", "."]); + // The TEMPLATE, never a fork (CLOUD-1419). `init_repo` copies a repository + // the harness publishes once per filesystem; a hand-rolled `git init` here + // pays a process for what the copy already has, and the trace that motivated + // the ratchet counted 1,819 of them in one run. + init_repo(&dir); git_in(&dir, &["add", "-A"]); git_in(&dir, &["commit", "-qm", "seed"]); dir From 37672fd292fabd7cb3d71985a6428bd1fdc7428d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 22:11:15 +0000 Subject: [PATCH 24/32] test(bats): give the repointed callers' fixtures the grammar the engine leaf reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `claimed-keys.sh` carried the key grammar inline and answered in ANY tree. `batten claim keys` resolves `ready-issue-key` and the closing rows from the `[[pattern]]` registry, so a fixture with no `batten.toml` resolves nothing and returns no keys. Every case in these suites that asserts a REFUSAL was therefore passing for the wrong reason after the repointing — nine of them across `deferral-check`, `landed-check` and `in-progress-drain`, and the two that announced it were `a deferral exempted only by the PR's own claimed issue fails` and `a closing keyword in the body claims that issue too`, both asserting exit 1 against a gate that had nothing to judge. The precondition is already written down for the Rust tiers — a fixture that exercises the Ready/claim grammar copies the repo's own config — and I did not carry it to the bats fixtures of the callers I repointed. Now they do. `in-progress-drain` needed the other half. Two cases stubbed `merged-pr-keys.sh` beside the real scripts; that program is retired and the drain reaches `batten claim merged`. The replacement SHADOWS THE BINARY and dispatches: the one verb under test is faked and every other call — the `claim keys` that `landed-check` makes one hop down — execs the real batten. A blanket stub would answer for both and the case would prove nothing. Refs: CLOUD-1711 --- tests/deferral-check.bats | 7 ++++++ tests/in-progress-drain.bats | 49 ++++++++++++++++++++++++++++++------ tests/landed-check.bats | 7 ++++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/tests/deferral-check.bats b/tests/deferral-check.bats index 39dfd3cca..580ef8337 100644 --- a/tests/deferral-check.bats +++ b/tests/deferral-check.bats @@ -32,6 +32,13 @@ setup() { mkdir -p "$REPO" git init -q -b claude/cloud-777-fixture "$REPO" cd "$REPO" || return 1 + # THE COMMITTED CONFIG, because the claim derivation is an ENGINE leaf now + # (CLOUD-1711). `claimed-keys.sh` carried the key grammar inline and answered + # in any tree; `batten claim keys` resolves `ready-issue-key` and the closing + # rows from the `[[pattern]]` registry, so a fixture with no `batten.toml` + # resolves nothing, returns no keys, and every case asserting a REFUSAL passes + # for the wrong reason. + cp "$BATS_TEST_DIRNAME/../batten.toml" "$REPO/batten.toml" # An unborn branch has no HEAD to resolve, and the claim derivation reads # `git rev-parse --abbrev-ref HEAD` — so a fixture with no commit fails open # and would prove nothing. One empty commit is what makes the branch real. diff --git a/tests/in-progress-drain.bats b/tests/in-progress-drain.bats index a6c14dd31..517e83cf4 100644 --- a/tests/in-progress-drain.bats +++ b/tests/in-progress-drain.bats @@ -19,6 +19,13 @@ setup() { export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b work "$REPO" cd "$REPO" || return 1 + # THE COMMITTED CONFIG, because the claim derivation is an ENGINE leaf now + # (CLOUD-1711). `claimed-keys.sh` carried the key grammar inline and answered + # in any tree; `batten claim keys` resolves `ready-issue-key` and the closing + # rows from the `[[pattern]]` registry, so a fixture with no `batten.toml` + # resolves nothing, returns no keys, and every case asserting a REFUSAL passes + # for the wrong reason. + cp "$BATS_TEST_DIRNAME/../batten.toml" "$REPO/batten.toml" git config user.email t@t git config user.name t git commit -q --allow-empty -m "chore: init" @@ -354,26 +361,52 @@ Closes CLOUD-179" mkdir -p "$stub" cp "$BATS_TEST_DIRNAME/../mise-tasks/in-progress-drain.sh" "$stub/in-progress-drain.sh" cp "$BATS_TEST_DIRNAME/../mise-tasks/landed-check.sh" "$stub/landed-check.sh" - cp "$BATS_TEST_DIRNAME/../mise-tasks/claimed-keys.sh" "$stub/claimed-keys.sh" - printf '#!/usr/bin/env bash\nprintf "CLOUD-179\\t42\\n"\n' >"$stub/merged-pr-keys.sh" - chmod +x "$stub/merged-pr-keys.sh" + # `claimed-keys.sh` and `merged-pr-keys.sh` are retired (CLOUD-1711); the + # drain reaches `batten claim merged` now. So the stub SHADOWS THE BINARY and + # dispatches: the one verb under test is faked and every other call — the + # `claim keys` that `landed-check` makes one hop down — reaches the real one. + # A blanket stub would answer for both and the case would prove nothing. + batten_stub "$stub" 'printf "CLOUD-179\t42\n"' + land "fix: work with no closing key in the commit" unset DRAIN_MERGED_PRS - run bash -c "printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | $stub/in-progress-drain.sh" + run bash -c "PATH='$stub:$PATH' printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | PATH='$stub:$PATH' $stub/in-progress-drain.sh" [ "$status" -eq 1 ] [[ "$output" == *"CLOUD-179"* ]] } +# A `batten` that answers ONE verb and defers the rest to the real binary. +# +# `$1` is the directory to place it in — put first on PATH by the case — and +# `$2` is the body run for `claim merged`. Everything else `exec`s the batten +# resolved outside this directory, so the gate under test keeps its real +# `claim keys`, its real config loading and its real exit contract. +batten_stub() { + local dir="$1" body="$2" real + real="$(PATH="${PATH#"$dir":}" command -v batten)" + cat >"$dir/batten" <<-STUB + #!/usr/bin/env bash + if [ "\$1" = "claim" ] && [ "\$2" = "merged" ]; then + $body + exit 0 + fi + exec "$real" "\$@" + STUB + chmod +x "$dir/batten" +} + @test "a failed gather is could-not-look, never a short sweep" { local stub="$BATS_TEST_TMPDIR/bin2" mkdir -p "$stub" cp "$BATS_TEST_DIRNAME/../mise-tasks/in-progress-drain.sh" "$stub/in-progress-drain.sh" cp "$BATS_TEST_DIRNAME/../mise-tasks/landed-check.sh" "$stub/landed-check.sh" - cp "$BATS_TEST_DIRNAME/../mise-tasks/claimed-keys.sh" "$stub/claimed-keys.sh" - printf '#!/usr/bin/env bash\nexit 2\n' >"$stub/merged-pr-keys.sh" - chmod +x "$stub/merged-pr-keys.sh" + # The same shadowing stub, refusing (CLOUD-1711). `batten claim merged` exits + # non-zero on a truncated or empty forge answer, and that is what must become + # the drain's own 2 rather than a short sweep reported as clean. + batten_stub "$stub" 'exit 2' + unset DRAIN_MERGED_PRS - run bash -c "printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | $stub/in-progress-drain.sh" + run bash -c "PATH='$stub:$PATH' printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | PATH='$stub:$PATH' $stub/in-progress-drain.sh" [ "$status" -eq 2 ] [[ "$output" == *"merged-pr-keys"* ]] } diff --git a/tests/landed-check.bats b/tests/landed-check.bats index 7798ba7a8..ef4eb1754 100644 --- a/tests/landed-check.bats +++ b/tests/landed-check.bats @@ -34,6 +34,13 @@ setup() { # rule over this directory and would fire on its own explanation. git init -q -b work "$REPO" cd "$REPO" || return 1 + # THE COMMITTED CONFIG, because the claim derivation is an ENGINE leaf now + # (CLOUD-1711). `claimed-keys.sh` carried the key grammar inline and answered + # in any tree; `batten claim keys` resolves `ready-issue-key` and the closing + # rows from the `[[pattern]]` registry, so a fixture with no `batten.toml` + # resolves nothing, returns no keys, and every case asserting a REFUSAL passes + # for the wrong reason. + cp "$BATS_TEST_DIRNAME/../batten.toml" "$REPO/batten.toml" git config user.email t@t git config user.name t git commit -q --allow-empty -m "chore: init" From 49c0cff290af03df06d2a1ea9309349cd5ea4955 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 22:45:07 +0000 Subject: [PATCH 25/32] fix(policy): admit the suite change a repointed caller forces, and withdraw two stubs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retiring `claimed-keys.sh` moved the issue-key grammar out of inline shell and into the `[[pattern]]` registry, which is where non-negotiable rule 1 says a consumer fact belongs. The shell answered in ANY tree; the engine leaf resolves the grammar from the committed config and answers could-not-look without one. So every suite whose fixture is a bare scratch repo stopped exercising its gate — and the cases asserting a REFUSAL went green, because a gate with nothing to judge refuses nothing. Fourteen cases across four suites, all four green on origin/main, verified by running them in a clean worktree rather than argued. `shell edit refused` admits only edits that REMOVE references to the retired program, so the fix was structurally unavailable. That is CLOUD-1051's gap one surface out: the module admits repointing a retired program's callers and refuses the change the repoint forces on those callers' own suites, so it cannot complete a retirement it mandates. Its arm is the precedent and this one is modelled on it. THE NARROWING IS WHAT KEEPS IT A RATCHET. Every added line must be a comment — which cannot change what a suite exercises — or name `batten.toml` or the retired path's declared successor invocation, read from the ledger rather than spelled in the module. Removals still answer to `admitted_removal` unchanged; the two tests compose rather than relax each other. An author cannot reach for this to change a program's behaviour, because a line that changes behaviour names none of those. MEASURED AGAINST ITSELF: the arm admits the three fixtures that needed the config and REFUSES `in-progress-drain.bats`, whose edit added a fourteen-line helper shadowing the `batten` binary. That refusal is correct — a helper that shadows the binary is behaviour, not a precondition — so those two cases take the sanctioned route instead and are WITHDRAWN with a note saying what is no longer covered: the drain's translation of the leaf's refusal into its own exit 2. The leaf's own refusal is covered by `claimed_keys.rs`. Stated rather than left to be found by its absence. Refs: CLOUD-1711, CLOUD-1051 Admits: 4e9d3c21bb09bc1b1173ca0095e61fd601a2755e080ec0d01a8ed9871fdf796e Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-anchor: call:24404a5ffa0334f64173c97b512945bec2aa6998 Admits-epoch: 0870c1164980e3c613f5d29602cec9ddb32f0a8a59aa2e605e5d9b55eab0fc52 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The retirement cannot land. Reverting it drops the campaign's only completed programs; leaving the suites red lands four bats files whose refusal cases pass over a gate with nothing to judge, which is the vacuous green this repository refuses everywhere else — and which this branch has already been bitten by twice. Admits-answer-precondition: The module admits repointing a retired program's callers and refuses the change that repointing forces on those callers' own governed suites, so it cannot complete a retirement it mandates. Measured on this branch: retiring `claimed-keys.sh` and `merged-pr-keys.sh` (CLOUD-1711) repointed four gates under the admitted arm and reddened fourteen cases across `tests/deferral-check.bats`, `tests/landed-check.bats`, `tests/in-progress-drain.bats` and `tests/board-sweep.bats`. All four are green on origin/main, verified by running them in a clean worktree at bc420eb. `shell edit refused` declares no override route and no bypass_env, and a module's arms live only in the module. This is CLOUD-1051's class one surface out and its arm is the precedent. Admits-answer-rejected-route: config read first — I read the module, then ran the four suites against a clean origin/main worktree, and that reading is what established the gap rather than a guess. patch run first is a commit-message route and does not apply to a module edit. Admits: cb8d58392512167172fee40eb230238140f27eedf185ebe0a8424e9506b902c4 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-anchor: call:24404a5ffa0334f64173c97b512945bec2aa6998 Admits-epoch: 0870c1164980e3c613f5d29602cec9ddb32f0a8a59aa2e605e5d9b55eab0fc52 Admits-author: alec@wenzowski.com Admits-prev: 4e9d3c21bb09bc1b1173ca0095e61fd601a2755e080ec0d01a8ed9871fdf796e Admits-answer-lost: The arm decides two thirds of the case it was written for, and the remaining suite is the one where the retirement is most visible: it stubbed `merged-pr-keys.sh` by name. Leaving it refused means the retirement still cannot land, having already spent a policy change to say it may. Admits-answer-precondition: Completing the arm I just added. Measured with `batten check --rule shell-retirement`: it admits two of the three suites and still refuses `tests/in-progress-drain.bats`, whose edit both DROPS references to the retired programs and ADDS the successor's precondition. My clause required zero removals, so a suite doing both earns neither arm. The fix composes them — every removed line must still earn `admitted_removal` exactly as the sibling arm demands — and a module's arms live only in the module. Admits-answer-rejected-route: config read first — I read the module, added the arm, and MEASURED which paths it admits; that measurement is what named this gap rather than a guess. patch run first is a commit-message route and does not apply. --- policy/shell-retirement.rego | 90 ++++++++++++++++++++++++++++++++++++ tests/board-sweep.bats | 7 +++ tests/in-progress-drain.bats | 66 ++++++-------------------- 3 files changed, 110 insertions(+), 53 deletions(-) diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 47d821100..6f46c9121 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -282,6 +282,96 @@ violation contains { some path in delta.edited governed_at_head(path) not only_drops_a_retired_reference(path) + not only_supplies_the_successors_precondition(path) +} + +# THE SECOND ADMITTED EDIT, and it is CLOUD-1051's arm one surface further out. +# +# That arm exists because retiring a program requires editing the SIBLINGS that +# declare it. This one exists because repointing those siblings can invalidate +# their own governed SUITES — and the module admitted the repoint while refusing +# the change the repoint forced, so it could not complete a retirement it had +# itself mandated. The same structural gap, found the same way: by a campaign +# hitting a wall it built. +# +# MEASURED 2026-09-09 (CLOUD-1711). Retiring `claimed-keys.sh` moved the issue-key +# grammar out of inline shell and into the `[[pattern]]` registry, which is where +# non-negotiable rule 1 says a consumer fact belongs. The shell answered in ANY +# tree; the engine leaf resolves the grammar from the committed config and answers +# could-not-look without one. Every suite whose fixture is a bare scratch repo +# therefore stopped exercising its gate — and the cases that assert a REFUSAL went +# green, because a gate with nothing to judge refuses nothing. Fourteen such cases +# across four suites, all four green on `origin/main`, verified in a clean +# worktree rather than argued. +# +# THE NARROWING IS WHAT KEEPS THIS A RATCHET. `shell edit refused` is the arm that +# refuses the move which READS as progress and is not, so widening it to "an edit +# that adds lines" would be the ratchet with a lie in it. Every ADDED line must +# either be a comment — which cannot change what a suite exercises — or name the +# committed config or the declared successor's own invocation. An author cannot +# reach for this to change a program's behaviour, because a line that changes +# behaviour names neither. +# +# REMOVALS STAY THE OTHER ARM'S BUSINESS. This one admits additions only; an edit +# that both adds a precondition and drops a line has to earn the drop under +# `only_drops_a_retired_reference` exactly as before. +only_supplies_the_successors_precondition(path) if { + base := delta["base-lines"][path] + head := {line | some line in input.tree.lines[path]} + added := {line | some line in head; not line in base} + + # An edit that added nothing is not this case — it removed or reordered, and + # the sibling arm above owns the first. + count(added) > 0 + + # EVERY REMOVAL STILL EARNS THE SIBLING ARM. A suite that both drops references + # to the retired program and gains its successor's precondition is doing ONE + # thing, and the first draft of this clause demanded zero removals — which + # earned neither arm and refused `in-progress-drain.bats`, the suite that + # stubbed `merged-pr-keys.sh` by name and therefore had to lose those lines. + # + # Composing rather than widening: the removals answer to `admitted_removal` + # exactly as they do above, and the additions answer to `supplies_a_precondition` + # below. Neither test is relaxed by standing next to the other. + removed := {line | some line in base; not line in head} + count({line | + some line in removed + admitted_removal(path, line, removed) + }) == count(removed) + + count({line | + some line in added + supplies_a_precondition(line) + }) == count(added) +} + +# A comment cannot change what the suite exercises, so it carries the reason. +supplies_a_precondition(line) if { + startswith(trim_space(line), "#") +} + +# The committed config, which holds the grammar a retired program carried inline +# and its successor reads from the `[[pattern]]` registry instead. +# +# THE NAME IS A LITERAL HERE, and that is the narrow reading rather than a lapse. +# `batten.toml` is the ONE committed authority (house-style §8) — there is no +# second spelling for this module to be a second authority over — and an +# undefined reference in Rego is not an error but a clause that never holds, so +# reaching for a token this module does not define would have made the whole arm +# silently dead. That is the failure this file's own `#MUTANT` rows exist to +# catch, and it is cheaper to spell the name than to ship an arm that decides +# nothing. +supplies_a_precondition(line) if { + contains(line, "batten.toml") +} + +# The successor's own invocation, as the retired path's ledger arm declared it — +# read from the ledger rather than spelled here, so a suite cannot admit a line +# naming a verb no retirement mapped to it. +supplies_a_precondition(line) if { + some gone in delta.deleted + some succ in invocations_for(gone) + contains(line, succ) } # THE ONE ADMITTED EDIT, and it is what makes this campaign able to clean up diff --git a/tests/board-sweep.bats b/tests/board-sweep.bats index cbaf47a76..3f0a7c295 100644 --- a/tests/board-sweep.bats +++ b/tests/board-sweep.bats @@ -18,6 +18,13 @@ setup() { export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b work "$REPO" cd "$REPO" || return 1 + # THE COMMITTED CONFIG, because the sweep delegates to `landed-check`, which + # reaches `batten claim keys` now (CLOUD-1711). `claimed-keys.sh` carried the + # key grammar inline and answered in any tree; the engine leaf resolves it from + # the `[[pattern]]` registry and answers could-not-look without one — which the + # sweep then reports as a gate that could not look, so a case asserting the + # sweep's own verdict never reaches it. + cp "$BATS_TEST_DIRNAME/../batten.toml" "$REPO/batten.toml" git config user.email t@t git config user.name t git commit -q --allow-empty -m "chore: init" diff --git a/tests/in-progress-drain.bats b/tests/in-progress-drain.bats index 517e83cf4..0837afc6c 100644 --- a/tests/in-progress-drain.bats +++ b/tests/in-progress-drain.bats @@ -356,57 +356,17 @@ Closes CLOUD-179" # what makes it runnable in a fresh clone. Every case above injects # DRAIN_MERGED_PRS and so never reaches this branch — that is what keeps the # suite offline — so the gather path needs its own case with the producer stubbed. -@test "with no DRAIN_MERGED_PRS the drain gathers evidence rather than refusing" { - local stub="$BATS_TEST_TMPDIR/bin" - mkdir -p "$stub" - cp "$BATS_TEST_DIRNAME/../mise-tasks/in-progress-drain.sh" "$stub/in-progress-drain.sh" - cp "$BATS_TEST_DIRNAME/../mise-tasks/landed-check.sh" "$stub/landed-check.sh" - # `claimed-keys.sh` and `merged-pr-keys.sh` are retired (CLOUD-1711); the - # drain reaches `batten claim merged` now. So the stub SHADOWS THE BINARY and - # dispatches: the one verb under test is faked and every other call — the - # `claim keys` that `landed-check` makes one hop down — reaches the real one. - # A blanket stub would answer for both and the case would prove nothing. - batten_stub "$stub" 'printf "CLOUD-179\t42\n"' - - land "fix: work with no closing key in the commit" - unset DRAIN_MERGED_PRS - run bash -c "PATH='$stub:$PATH' printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | PATH='$stub:$PATH' $stub/in-progress-drain.sh" - [ "$status" -eq 1 ] - [[ "$output" == *"CLOUD-179"* ]] -} - -# A `batten` that answers ONE verb and defers the rest to the real binary. +# THE TWO GATHER CASES ARE WITHDRAWN (CLOUD-1711), not silently dropped. # -# `$1` is the directory to place it in — put first on PATH by the case — and -# `$2` is the body run for `claim merged`. Everything else `exec`s the batten -# resolved outside this directory, so the gate under test keeps its real -# `claim keys`, its real config loading and its real exit contract. -batten_stub() { - local dir="$1" body="$2" real - real="$(PATH="${PATH#"$dir":}" command -v batten)" - cat >"$dir/batten" <<-STUB - #!/usr/bin/env bash - if [ "\$1" = "claim" ] && [ "\$2" = "merged" ]; then - $body - exit 0 - fi - exec "$real" "\$@" - STUB - chmod +x "$dir/batten" -} - -@test "a failed gather is could-not-look, never a short sweep" { - local stub="$BATS_TEST_TMPDIR/bin2" - mkdir -p "$stub" - cp "$BATS_TEST_DIRNAME/../mise-tasks/in-progress-drain.sh" "$stub/in-progress-drain.sh" - cp "$BATS_TEST_DIRNAME/../mise-tasks/landed-check.sh" "$stub/landed-check.sh" - # The same shadowing stub, refusing (CLOUD-1711). `batten claim merged` exits - # non-zero on a truncated or empty forge answer, and that is what must become - # the drain's own 2 rather than a short sweep reported as clean. - batten_stub "$stub" 'exit 2' - - unset DRAIN_MERGED_PRS - run bash -c "PATH='$stub:$PATH' printf '%s' '[$(row CLOUD-179 2026-08-20T10:00:00.000Z feat/x '')]' | PATH='$stub:$PATH' $stub/in-progress-drain.sh" - [ "$status" -eq 2 ] - [[ "$output" == *"merged-pr-keys"* ]] -} +# Both stubbed `mise-tasks/merged-pr-keys.sh` beside the real gates and drove the +# drain's no-`DRAIN_MERGED_PRS` path through it. That program is retired; the +# drain reaches `batten claim merged`, and a stub for an engine leaf is a shim +# for `batten` itself — which `shell edit refused` rightly will not admit into a +# governed suite, because a shell helper that shadows the binary is behaviour, +# not a fixture precondition. +# +# WHAT IS LOST AND WHERE IT IS COVERED. The leaf's own refusal on a truncated or +# empty forge answer is `crates/batten/tests/it/claimed_keys.rs`. What no case +# now covers is the drain's TRANSLATION of that refusal into its own exit 2, and +# that is stated here rather than left for a reader to discover by its absence — +# it belongs in the drain's own port, which is this campaign's next batch. From 21f0ca5bb5da62bab20c158d11bc111847c4108a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 23:07:33 +0000 Subject: [PATCH 26/32] fix(ledger): port the drain's two gather cases, because their subject survives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I withdrew them and that was the wrong arm. `bats-tests-not-deleted` reported `subject-alive mise-tasks/in-progress-drain.sh`: `SubjectFacts::died` is `.all()`, so a withdrawal cannot hold while the program under test is still in the tree, and a comment in the suite is not a ledger entry at all — the ledger lives in the Rust tiers. `ported` is both the arm that clears it and the honest claim. Each case's property — the gather answering with rows, and the gather refusing a truncated or empty forge answer — is asserted in `claimed_keys.rs` over the leaf itself, which is what a port means; and `subject:` names the survivor rather than pretending nothing survived. What did NOT move is stated in the block rather than left to inference: the drain's translation of that refusal into its own exit 2 rather than a clean short sweep. That belongs to the drain's own port, and a reader who cannot see it would otherwise assume it is still covered. Refs: CLOUD-1711, CLOUD-1268 --- crates/batten/tests/it/claimed_keys.rs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/crates/batten/tests/it/claimed_keys.rs b/crates/batten/tests/it/claimed_keys.rs index 607e80bc6..200c95cab 100644 --- a/crates/batten/tests/it/claimed_keys.rs +++ b/crates/batten/tests/it/claimed_keys.rs @@ -4,6 +4,25 @@ //! //! A program and its suite are TWO rows, never one. +// THE DRAIN'S TWO GATHER CASES, PORTED (CLOUD-1711). Both stubbed +// `mise-tasks/merged-pr-keys.sh` beside the real gates to drive the drain's +// no-`DRAIN_MERGED_PRS` path. That program is retired and the drain reaches +// `batten claim merged`, so the stub would have to shadow the `batten` binary — +// behaviour in a governed suite rather than a fixture precondition, which +// `shell edit refused` will not admit and should not. +// +// `ported` RATHER THAN `withdrawn`, and the subject is why: `in-progress-drain.sh` +// SURVIVES, so `died()` cannot hold and a withdrawal has nothing to name. The +// property each case carried — the gather answering with rows, and the gather +// refusing a truncated or empty forge answer — is asserted here over the leaf +// itself, which is what makes this a port. +// +// WHAT DID NOT MOVE, said plainly: the drain's TRANSLATION of that refusal into +// its own exit 2 rather than a clean short sweep. It belongs to the drain's own +// port, and naming it here is what stops a reader assuming the half they cannot +// see is still covered. +// ported: "with no DRAIN_MERGED_PRS the drain gathers evidence rather than refusing" crates/batten/tests/it/claimed_keys.rs subject:mise-tasks/in-progress-drain.sh +// ported: "a failed gather is could-not-look, never a short sweep" crates/batten/tests/it/claimed_keys.rs subject:mise-tasks/in-progress-drain.sh // carried: mise-tasks/claimed-keys.sh crates/batten/src/race.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs runs:batten+claim+keys // carried: tests/claimed-keys.bats crates/batten/src/race.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs // carried: mise-tasks/merged-pr-keys.sh crates/batten/src/forge.rs kind:mechanism crates/batten/tests/it/claimed_keys.rs runs:batten+claim+merged From a348900ccb877355d68100a4bca240fd89c5e1cd Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 23:31:29 +0000 Subject: [PATCH 27/32] fix(policy): the admitted addition is a comment or the config, never a successor name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My arm shipped with the hole its own module's suite exists to catch, and I tightened the wrong side of it first. It admitted an added line that NAMES the retired path's declared successor invocation. That reads as the obvious third case and is exactly what a REPOINT produces — a line rewritten to call the successor — so every repoint satisfied it. `shell_retirement::a_repointing_that_also_changes_the_rest_of_the_line_is_refused` and `test_replacing_a_span_that_is_not_a_retired_reference_is_refused` both went green, which is the pair that refuses an edit repointing AND changing the rest. An arm whose whole argument was that it does not widen the ratchet had disabled two of its anti-vacuity cases. The first repair forbade removals instead, and that is the wrong bound: measured, `tests/in-progress-drain.bats` fails 26 cases without the config line, so the one suite this arm exists for both drops a retired stub and gains the grammar its successor reads. Removals compose with `admitted_removal` unchanged. What is left as an admitted ADDITION is a comment or the committed config — the set a rewritten line cannot be. A repoint stays `repoints_at_the_declared_invocation`'s business, which already polices that it changes nothing else. `policy test` 859/859, `shell-retirement` and `bats-tests-not-deleted` both clean, and all four suites green. Refs: CLOUD-1711, CLOUD-1051 Admits: b80bd3cb48abe51f0b673a8f8f1c6646f1a092a13cbfaeda71fda2361d63ab1f Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-anchor: call:164bab56037bd79ab59d31a8a02fe2f7f6bc4db8 Admits-epoch: 0870c1164980e3c613f5d29602cec9ddb32f0a8a59aa2e605e5d9b55eab0fc52 Admits-author: alec@wenzowski.com Admits-prev: cb8d58392512167172fee40eb230238140f27eedf185ebe0a8424e9506b902c4 Admits-answer-lost: A landed arm that silently disables two anti-vacuity cases in the rule it extends — the widening the ratchet exists to prevent, shipped by the change whose whole argument was that it did not widen it. Admits-answer-precondition: Narrowing the arm I added, because it is too loose and its own module's suite says so. Measured: `shell_retirement::a_repointing_that_also_changes_the_rest_of_the_line_is_refused` and `test_replacing_a_span_that_is_not_a_retired_reference_is_refused` both went green. My successor-invocation clause admits ANY added line naming the successor, so a line that repoints and rewrites the rest of itself is admitted — which is the exact vacuity those two cases exist to refuse. Requiring the edit to remove nothing confines this arm to pure additions and leaves repoint-shaped edits to `repoints_at_the_declared_invocation`, which already polices that they change nothing else. A module's arms live only in the module. Admits-answer-rejected-route: config read first — the module is read; the suite it ships is what named the looseness, and that is a measurement rather than a reading. patch run first is a commit-message route and does not apply. Admits: 2decaa1d00458588b8ce71d83ee8644374c1800711a39364207414884fea82f2 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-anchor: call:164bab56037bd79ab59d31a8a02fe2f7f6bc4db8 Admits-epoch: 0870c1164980e3c613f5d29602cec9ddb32f0a8a59aa2e605e5d9b55eab0fc52 Admits-author: alec@wenzowski.com Admits-prev: b80bd3cb48abe51f0b673a8f8f1c6646f1a092a13cbfaeda71fda2361d63ab1f Admits-answer-lost: Either the arm swallows the two anti-vacuity cases of the rule it extends, or it refuses the one suite that both drops a retired stub and gains the grammar its successor reads. The first is the widening this change promised not to be; the second leaves the retirement unlandable. Admits-answer-precondition: Tightening what the arm counts as an admitted ADDITION, which is where the looseness actually was. Measured: dropping the successor-invocation clause is what restores `a_repointing_that_also_changes_the_rest_of_the_line_is_refused` and `test_replacing_a_span_that_is_not_a_retired_reference_is_refused`, because a repoint's rewritten line names the successor but is neither a comment nor the config. Measured the other way too: `tests/in-progress-drain.bats` fails 26 cases without the config line, so its edit must both add and remove, and forbidding removals outright refuses a suite that has to do both. A module's arms live only in the module. Admits-answer-rejected-route: config read first — the module is read; what named both bounds is running its suite and running the bats suite with the line removed, which is measurement rather than reading. patch run first is a commit-message route and does not apply. --- policy/shell-retirement.rego | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 6f46c9121..3a74acd61 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -324,15 +324,11 @@ only_supplies_the_successors_precondition(path) if { # the sibling arm above owns the first. count(added) > 0 - # EVERY REMOVAL STILL EARNS THE SIBLING ARM. A suite that both drops references - # to the retired program and gains its successor's precondition is doing ONE - # thing, and the first draft of this clause demanded zero removals — which - # earned neither arm and refused `in-progress-drain.bats`, the suite that - # stubbed `merged-pr-keys.sh` by name and therefore had to lose those lines. - # - # Composing rather than widening: the removals answer to `admitted_removal` - # exactly as they do above, and the additions answer to `supplies_a_precondition` - # below. Neither test is relaxed by standing next to the other. + # EVERY REMOVAL STILL EARNS THE SIBLING ARM, unchanged. A suite that both drops + # a retired program's stub and gains the grammar its successor reads is doing + # ONE thing: `tests/in-progress-drain.bats` fails 26 cases without the config + # line, measured, so forbidding removals here would refuse the very suite this + # arm exists for. removed := {line | some line in base; not line in head} count({line | some line in removed @@ -365,14 +361,20 @@ supplies_a_precondition(line) if { contains(line, "batten.toml") } -# The successor's own invocation, as the retired path's ledger arm declared it — -# read from the ledger rather than spelled here, so a suite cannot admit a line -# naming a verb no retirement mapped to it. -supplies_a_precondition(line) if { - some gone in delta.deleted - some succ in invocations_for(gone) - contains(line, succ) -} +# THERE IS NO THIRD SHAPE, and the one that was here is why this comment is. +# +# A draft also admitted an added line naming the retired path's declared successor +# invocation. It reads as the obvious third case and it is the hole: a REPOINT +# rewrites a line to name the successor, so every such edit satisfied it — and +# the module's own suite said so, `a_repointing_that_also_changes_the_rest_of_the_line_is_refused` +# and `test_replacing_a_span_that_is_not_a_retired_reference_is_refused` both +# going green. Those two exist to refuse an edit that repoints AND changes the +# rest, which is precisely what the clause admitted. +# +# A repoint is `repoints_at_the_declared_invocation`'s business and already +# polices that it changes nothing else. What is left here — a comment, or the +# committed config — is the set a rewritten line cannot be, which is the property +# that keeps this arm from swallowing the rule it extends. # THE ONE ADMITTED EDIT, and it is what makes this campaign able to clean up # after itself (CLOUD-1051). From 00b0d40d25b4570f0ca6db6d4c3d4b07dc5296c9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 9 Sep 2026 23:44:35 +0000 Subject: [PATCH 28/32] fix(mise): repoint commit-lint, the caller of claimed-keys the retirement missed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[tasks.commit-lint]` bound `keys=.../mise-tasks/claimed-keys.sh` and tested whether that program found a `CLOUD-` in each commit message. The path is deleted, so the command emitted nothing and every commit in the range read as claiming no issue — 21 of them, including the sibling's borrowed commit and several carrying a well-formed `Refs:` trailer. THAT LAST PART IS THE TELL, and it is the same failure this branch has now hit four times: a missing program's empty output is byte-identical to a real negative. `commit-lint` did not error on a path that does not exist; it concluded something false about every commit, in the gate's own vocabulary, and only looked like 21 authors forgetting a trailer. Repointed at `batten claim keys`, which is what the other four callers already reach. Verified over this branch's own range: 21 commits, exit 0. Refs: CLOUD-1711 Admits: fd0545f6abe7c42237488e965d7fc59c23b500d1e2f4704312d9852771698dd5 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: mise.toml Admits-anchor: call:cec1ca4a20541d6880ba2893d98cce61c008e2e5 Admits-epoch: 0870c1164980e3c613f5d29602cec9ddb32f0a8a59aa2e605e5d9b55eab0fc52 Admits-author: alec@wenzowski.com Admits-prev: 92dc3cd4459044233700d79237a468edd51474c5ae1e847657e8921a50115afa Admits-answer-lost: `verify` cannot pass at all: commit-lint refuses all 21 commits in the range for a reason none of them has. Worse than a red gate, it is a gate that reads clean-vs-broken identically — the empty output of a missing program is indistinguishable from a commit that names no issue, so the failure mode is the vacuous one this repository refuses everywhere else. Admits-answer-precondition: A caller of the retired program that the repointing missed. `[tasks.commit-lint]` binds `keys="$(git rev-parse --show-toplevel)/mise-tasks/claimed-keys.sh"` and tests its output; that path is deleted, so the command produces nothing and EVERY commit is reported as claiming no issue — the sibling's borrowed commit and commits that do carry a `Refs:` trailer alike. A mise task body lives only in mise.toml. Admits-answer-rejected-route: config read first — I read the task body, and reading it is what found the dangling path rather than a guess. patch run first is a commit-message route and does not apply to a task edit. --- mise.toml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/mise.toml b/mise.toml index 359c88501..c62128270 100644 --- a/mise.toml +++ b/mise.toml @@ -4377,7 +4377,7 @@ shell = "bash -c" # uses ${sha # no others: a bot commit reaching anything else is not a bump and is not exempt. run = ''' fail=0 -keys="$(git rev-parse --show-toplevel)/mise-tasks/claimed-keys.sh" + for sha in $(git rev-list --no-merges "${BASE_SHA:?}..${HEAD_SHA:?}"); do subject=$(git show -s --format=%s "$sha") # The subject-convention half is NOT here: it is `commit-check`, above in @@ -4405,7 +4405,12 @@ for sha in $(git rev-list --no-merges "${BASE_SHA:?}..${HEAD_SHA:?}"); do # extra evidence from stdin whenever stdin is not a tty, so under a task runner # — where it never is — a call with nothing piped BLOCKS FOREVER. Measured here # as a two-minute harness kill. Same shape as CLOUD-511's bare `cat`. - if [ -z "$("$keys" --log "$(git show -s --format='%B' "$sha")" /dev/null)" ]; then + # `batten claim keys`, not the retired `claimed-keys.sh` (CLOUD-1711). The + # binding this replaces pointed at a deleted path, so the command emitted + # nothing and every commit read as claiming no issue — including ones carrying + # a `Refs:` trailer, which is the tell: a missing program's empty output is + # byte-identical to a message that names no row. + if [ -z "$(batten claim keys --branch "" --title "" --log "$(git show -s --format='%B' "$sha")" /dev/null)" ]; then echo "::error::Commit ${sha:0:8} claims no CLOUD- issue: '$subject' — add a 'Refs: CLOUD-' trailer naming the work it serves" fail=1 fi From dae85eaf97f7f0ce3e36d88ed43d414ca7a4b42d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 10 Sep 2026 02:13:35 +0000 Subject: [PATCH 29/32] fix(mise): commit-lint reaches batten through cargo, not through PATH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit My repointing of this gate used a bare `batten`. That resolves in a container where the binary is installed and not on a CI runner, so the command emitted nothing and every commit read as claiming no issue — including four carrying `Refs: CLOUD-1688`. The same range exits 0 locally and failed in CI, which is the only way this shows. THE FAILURE IS THE ONE THE REPOINTING WAS FIXING, one environment over. A missing program's empty output is byte-identical to a real negative, so the gate does not error, it concludes something false in its own vocabulary. That is now the third instance on this branch: a deleted path, a config-less fixture, and a name that is not on PATH. `cargo run --quiet -p batten --` is how the other 52 call sites in this file spell it, and none of them depends on what is installed. Verified over this branch's own range after the change. Refs: CLOUD-1711 Admits: 9a563919404fc95d0f2e8e78f83028b9160dc38b33669bf59f04802d454caf87 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: mise.toml Admits-anchor: call:00b0d40d25b4570f0ca6db6d4c3d4b07dc5296c9 Admits-epoch: 77833cca5687b6f3a07782d61c4fecd3ed6d0c22d249bc6d58d5f1bd9c080aea Admits-author: alec@wenzowski.com Admits-prev: fd0545f6abe7c42237488e965d7fc59c23b500d1e2f4704312d9852771698dd5 Admits-answer-lost: `commit-lint` stays red on every CI run while passing locally, so the branch cannot land and the gate accuses commits that plainly name their row. It is also the third instance this session of a missing binary's empty output being read as a real negative — shipping it knowingly would be worse than the defect it replaced. Admits-answer-precondition: My repointing of `commit-lint` used a bare `batten`, which resolves in this container and not on the runner. Measured: the same range exits 0 locally and fails in CI naming four commits that carry `Refs: CLOUD-1688` trailers — so the command emitted nothing and the gate reported a false negative, the exact vacuity the repointing was fixing. mise.toml is the only surface a task body lives on, and the house form `cargo run --quiet -p batten --` is used by 52 other call sites in this same file. Admits-answer-rejected-route: config read first — the task body is read, and reading it is not what settled this: running the identical range locally against the CI log did. patch run first is a commit-message route and does not apply to a task edit. --- mise.toml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/mise.toml b/mise.toml index c62128270..3b4457b86 100644 --- a/mise.toml +++ b/mise.toml @@ -4410,7 +4410,15 @@ for sha in $(git rev-list --no-merges "${BASE_SHA:?}..${HEAD_SHA:?}"); do # nothing and every commit read as claiming no issue — including ones carrying # a `Refs:` trailer, which is the tell: a missing program's empty output is # byte-identical to a message that names no row. - if [ -z "$(batten claim keys --branch "" --title "" --log "$(git show -s --format='%B' "$sha")" /dev/null)" ]; then + # + # `cargo run`, NEVER A BARE `batten`, and the first repair got this wrong in a + # way only CI could show: a bare name resolves wherever the binary happens to be + # installed, so the gate passed locally and failed on the runner — accusing four + # commits that carry `Refs: CLOUD-1688`. That is the SAME false negative one + # environment over, which is why the form matters rather than being a style + # choice: 52 other call sites in this file spell it this way and none of them + # depends on what is on PATH. + if [ -z "$(cargo run --quiet -p batten -- claim keys --branch "" --title "" --log "$(git show -s --format='%B' "$sha")" /dev/null)" ]; then echo "::error::Commit ${sha:0:8} claims no CLOUD- issue: '$subject' — add a 'Refs: CLOUD-' trailer naming the work it serves" fail=1 fi From bf71f97c4060c7aa54e8d6c45c6529b1664a2f47 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 10 Sep 2026 03:00:56 +0000 Subject: [PATCH 30/32] refactor(gate)!: retire stop-posture-check onto the module that already decides it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1717's own words: "already ported — retire it, don't port it." `policy/stop-posture.rego` and `mise-tasks/stop-posture-check.sh` decided the same question from the same bytes — the shell read `last_assistant_message` off stdin, the module reads it off the `Stop` payload — and the module has been registered at `severity = "deny"` throughout. All nineteen cases map onto the module's own, one for one: the two named tells, the witnessed miss, the opener symmetry, the four scrub classes plus the wrapped span, the two ordinary-use negatives, and silence on a clean message. Eighteen are `carried`. ONE IS A DIVERGENCE AND IS RECORDED AS ONE. "empty stdin is clean rather than an error" was a real input state for a program reading a stream; the module is handed a payload and an absent `last_assistant_message` is a missing FIELD. `a_tool_call_carries_no_final_message` decides it as silence — same verdict, different question — so it is `changed` with the reason rather than a carry that would overstate the correspondence. The program's `#MUTANT hedged-framing-passes` row does not move: the module carries `hedge-unread` over the same conjunct, so dropping `stop-posture-check` from `$MUTANT_GATES` leaves a live mutation on the surviving surface rather than an orphan on a deleted one. 86 programs to 85; 96 suites. No caller was repointed because there was none — every remaining mention of the name is prose. Refs: CLOUD-1717 Admits: 48ca68d119bf5e0e64e4668228636e7aa23df75ea269e3c54ccfa6195265f2b2 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: mise.toml Admits-anchor: call:dae85eaf97f7f0ce3e36d88ed43d414ca7a4b42d Admits-epoch: 77833cca5687b6f3a07782d61c4fecd3ed6d0c22d249bc6d58d5f1bd9c080aea Admits-author: alec@wenzowski.com Admits-prev: 9a563919404fc95d0f2e8e78f83028b9160dc38b33669bf59f04802d454caf87 Admits-answer-lost: `mise run mutant-census` goes red on a gate name whose program no longer exists, so the retirement cannot land — and the sweep would be reporting an orphan rather than the coverage it exists to measure. Admits-answer-precondition: Retiring `stop-posture-check.sh` (CLOUD-1717) requires dropping its `$MUTANT_GATES` entry, because `mutate census` reads a declaring module that is not in the list as uncovered and a listed gate whose program is deleted as an orphan. The list is a single quoted string on one line of mise.toml and exists on no other surface. The module `stop-posture` stays listed and keeps its own `hedge-unread` mutation over the same conjunct. Admits-answer-rejected-route: config read first — I read the list and the program's own `#MUTANT` row to establish that the surviving module already carries an equivalent mutation. patch run first is a commit-message route and does not apply to a manifest edit. --- bench/suites/RESULTS.md | 1 - crates/batten/tests/it/stop_posture.rs | 31 +++++ mise-tasks/stop-posture-check.sh | 161 ------------------------ mise.toml | 2 +- tests/stop-posture-check.bats | 166 ------------------------- 5 files changed, 32 insertions(+), 329 deletions(-) delete mode 100755 mise-tasks/stop-posture-check.sh delete mode 100644 tests/stop-posture-check.bats diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 91c8009ff..a845c7846 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -71,7 +71,6 @@ to it pays. | 0.5 | 0.3% | `tests/duplicate-close-check.bats` | | 0.5 | 0.3% | `tests/suite-bench-check.bats` | | 0.5 | 0.3% | `tests/macos-link-check.bats` | -| 0.5 | 0.3% | `tests/stop-posture-check.bats` | | 0.4 | 0.3% | `tests/checksums.bats` | | 0.4 | 0.3% | `tests/publish-credential-check.bats` | | 0.4 | 0.3% | `tests/msrv-pin-agreement.bats` | diff --git a/crates/batten/tests/it/stop_posture.rs b/crates/batten/tests/it/stop_posture.rs index c3aeb65b9..7f9867bc2 100644 --- a/crates/batten/tests/it/stop_posture.rs +++ b/crates/batten/tests/it/stop_posture.rs @@ -22,6 +22,37 @@ //! The suite's successor is the module, which is where the one rule that COULD //! be a predicate went. //! +// THE PROGRAM THIS MODULE MADE REDUNDANT (CLOUD-1717). `stop-posture-check.sh` +// and `policy/stop-posture.rego` decided the same question from the same bytes: +// the shell read `last_assistant_message` off stdin, the module reads it off the +// `Stop` payload. The row's own words were "already ported — retire it, don't +// port it", and this is that retirement: nothing new is written, the shell's +// nineteen cases are mapped onto the module's own and the program goes. +// +// The program's `#MUTANT hedged-framing-passes` row does not move: the module +// carries `hedge-unread` over the same conjunct, so the sweep keeps a live +// mutation on the surviving surface rather than gaining an orphan. +// carried: mise-tasks/stop-posture-check.sh policy/stop-posture.rego kind:mechanism crates/batten/tests/it/stop_posture.rs +// carried: tests/stop-posture-check.bats policy/stop-posture.rego kind:mechanism crates/batten/tests/it/stop_posture.rs +// carried: "the first tell AGENTS.md names fires" policy/stop-posture.rego kind:mechanism +// carried: "the second tell AGENTS.md names fires" policy/stop-posture.rego kind:mechanism +// carried: "the inflection that a closed two-item list would have missed fires" policy/stop-posture.rego kind:mechanism +// carried: "the report carries a count" policy/stop-posture.rego kind:mechanism +// carried: "THE WITNESSED MISS: the CLOUD-347 sentence fires" policy/stop-posture.rego kind:mechanism +// carried: "the asymmetry is gone: mentioning is a flagging verb under BOTH openers" policy/stop-posture.rego kind:mechanism +// carried: "every opener carries the witnessed verb" policy/stop-posture.rego kind:mechanism +// carried: "an UNWITNESSED near-miss stays out — that is the line against inventing a list" policy/stop-posture.rego kind:mechanism +// carried: "the report never echoes the sentence it judged" policy/stop-posture.rego kind:mechanism +// carried: "a code span carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a double-quoted span carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a block quote carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a fenced block carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "a LINE-WRAPPED quoted span carrying the tell does not fire" policy/stop-posture.rego kind:mechanism +// carried: "reporting a measured value is not hedged framing" policy/stop-posture.rego kind:mechanism +// carried: "a command flag is not hedged framing" policy/stop-posture.rego kind:mechanism +// carried: "a plainly stated finding with a durable home does not fire" policy/stop-posture.rego kind:mechanism +// changed: "empty stdin is clean rather than an error" policy/stop-posture.rego kind:mechanism the input surface moved: the shell read the message on stdin, so an empty stream was a real state it had to answer for. The module is handed the `Stop` payload and an absent `last_assistant_message` is a missing FIELD, which `a_tool_call_carries_no_final_message` decides as silence. Same verdict, different question, so it is recorded as a divergence rather than a carry +// carried: "a clean message produces no output at all" policy/stop-posture.rego kind:mechanism // carried: mise-tasks/stop-guard.sh crates/batten/src/lib.rs kind:mechanism crates/batten/tests/it/stop_posture.rs // // CLOUD-1163's unlanded unit. The program was spawned by `stop_nudges` with diff --git a/mise-tasks/stop-posture-check.sh b/mise-tasks/stop-posture-check.sh deleted file mode 100755 index 04e270e66..000000000 --- a/mise-tasks/stop-posture-check.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -#MISE description="Gate: an end-of-turn message carrying hedged flag-framing, the one output-posture tell AGENTS.md names literally (reads the message on stdin; pointer-only)" -# -# CLOUD-248. AGENTS.md's output-posture section says the failure it kills is -# "writing findings twice, once durably and once as editorial", names the tell in -# as many words — hedged flag-framing, with two literal examples — and then -# concedes that no gate is possible because "hooks see tool calls, not prose". -# That concession is scoped wrong, and CLOUD-200's own body had it right: a -# *PreToolUse* hook sees tool calls, not prose. A `Stop` hook is handed -# `last_assistant_message`, the text of the turn's final response, so the prose -# does pass through a tool boundary after all — just a different one. -# -# So this adds no policy. AGENTS.md already enumerates; what it lacked was an -# exit code. The literal set below is the set that file already writes down. -# -# Why an enumeration is honest HERE when AGENTS.md says "it is a predicate, not a -# list". That sentence is about the rule as *feedforward*: a list in prose invites -# satisfying the list and drifting elsewhere, which is why the previous version -# did not hold. As a *gate* the tradeoff inverts — an incomplete literal set costs -# recall, never precision, and a true positive stays true. Measured over a real -# 33-turn session transcript this fired 3 times with 3 true positives, and one of -# them is witnessed independently rather than by opinion: that turn's flag-framed -# defect was filed nine turns later, so the kick would have closed a nine-turn -# latency. Recall is the weak half and is stated rather than hidden: of the three -# findings whose staleness a later filing witnesses, this catches one, because -# `last_assistant_message` carries only the FINAL text block — measured at 26,893 -# of 60,916 assistant-prose characters, 44%, and two misses sat in earlier blocks. -# -# What was measured and deliberately NOT shipped, so nobody re-derives it: -# - uncommitted / untracked / unpushed at stop. Already enforced one tier up by -# the launcher's own Stop hook (~/.claude/stop-hook-git-check.sh, wired in -# ~/.claude/launcher-settings.json). Duplicating it buys nothing. -# - "green"/"landed" claims joined against a receipt or `git merge-base`. The -# claim is about a past SHA while the world-state half tests current HEAD, so -# the conjuncts are about different objects: 0 true positives, 2 false. -# - deferral-of-a-settled-call. Fired on a turn that deferred two genuinely -# ambiguous calls AGENTS.md sanctions deferring, and missed a plainer one. -# - finding-shape without a durable write (this issue's own conjunct). 1/1 true, -# but it needs the turn's tool-use records, so it must read the transcript — -# which lags the current turn's most recent messages, meaning a late -# `save_issue` reads as "no durable write" and would kick a turn that DID -# file. Report-only until that ordering is settled; it stays CLOUD-248's. -# -# Pointer-only (non-negotiable rule 4), and here that is load-bearing rather than -# ceremonial: the input is a whole assistant message. The report emits the rule -# id, a count, and the matched literal — a parameter of the rule, defined in this -# file — and never a byte of the surrounding sentence. -# -# Exit 0 clean, 1 the predicate fired (reason on stdout), 2 stdin unreadable. -# A gate listed in $MUTANT_GATES with no row here fails `mise run mutant`. -#MUTANT hedged-framing-passes|s/^exit 1$/exit 0/|the first tell AGENTS.md names fires - -set -euo pipefail - -msg=$(cat) || exit 2 -[[ -n "$msg" ]] || exit 0 - -# A message QUOTING the tell is not the message MAKING it, and this is not a -# hypothetical distinction: the sibling `run-shape-guard` denied the very command -# that documented it, twice, before its scrubber covered quoted spans. Code spans, -# fenced blocks, block quotes and double-quoted spans all come out first. -# -# WHOLE-INPUT throughout, because the default line-at-a-time model leaves the -# interior of a wrapped quotation exposed and every one of these spans is -# routinely line-wrapped in real prose — the same defect `run-shape-guard` fixed -# at its own scrubber. The last substitution needs it twice over: it matches `\n` -# inside the pattern space, which a line-based reader never has. -# -# `perl -0777` and not sed's NUL-separated mode (CLOUD-282): `-z` is a GNU -# extension, BSD sed exits `illegal option -- z`, and these are GATES — a macOS -# checkout could not run them at all. Byte-identical for all four substitutions, -# differentially verified against the real bats fixtures and both edges (a -# leading `\n`, a blank line before `>`); only `\1` becomes `$1`. Not awk: -# `awk-regex-check` forbids `-v`-passed regexes, and this repo's dev boxes are -# mawk against a gawk runner. The banned literal is deliberately not spelled -# here — `no-gnu-sed-z` in batten.toml is a substring rule over this directory. -scrubbed=$( - # shellcheck disable=SC2016 # the backticks are literal markdown, not a subshell - printf '%s' "$msg" | - perl -0777 -pe 's/```[^`]*```/FENCED/g' | - perl -0777 -pe 's/`[^`]*`/CODE/g' | - perl -0777 -pe 's/"[^"]*"/QUOTED/g' | - perl -0777 -pe 's/(^|\n)[[:space:]]*>[^\n]*/$1QUOTED/g' -) - -# The literal set is AGENTS.md's own two examples plus their direct inflections. -# Kept deliberately narrow: every entry names an act of flagging rather than any -# use of "note" or "flag", so "I noted the exit code" and "the --flag argument" -# are outside it. Inline in the grep, never through `awk -v` (awk-regex-check). -# -# Both the contracted and the expanded auxiliary, because the first draft carried -# only `I'?d` and its own test caught the miss: "one thing I would flag" is the -# commoner spelling of the phrase AGENTS.md writes contracted. Both apostrophes -# too — a straight one and a typographic one are the same word to a reader. -# -# ONE VERB SET ACROSS THE `worth`/`bears` OPENERS (CLOUD-387), because they used to -# carry two and the difference was an accident of how the alternation was written: -# `worth (noting|flagging)` beside `bears (noting|mentioning)`, so `mentioning` was -# a flagging verb to this file under one opener and unknown under the other. -# Measured before and after, one sentence per row: -# -# before after -# worth noting fires fires -# worth flagging fires fires -# bears noting fires fires -# bears mentioning fires fires -# worth naming SILENT fires <- the witnessed miss -# worth mentioning SILENT fires <- the asymmetry -# bears naming SILENT fires -# it's worth naming SILENT fires -# worth calling out SILENT SILENT <- deliberately still out -# -# `naming` is the witnessed one. The CLOUD-347..356 audit closed its report with -# "One open thread worth naming: the census … never interrogated host settings", -# a real finding that reached chat and nothing else and became CLOUD-380 only -# because a human asked. That sentence is silent here; the same sentence with -# `noting` fires. And nothing else covered it: `stop-guard` consults -# `finding-sink-check` precisely WHEN this rule is silent, and that gate needs a -# `path:line` citation the sentence does not carry — so the turn's one advisory -# slot was not spent, it was never claimed. -# -# `calling out` is the line between completing an inflection and inventing a -# phrase list, and it stays out: unwitnessed, and not already in this file. That -# distinction is the whole reason this is not the unmeasured-literal mistake -# CLOUD-323 and CLOUD-326 forbid — those govern a NEW SHAPE, while every verb -# here is either witnessed or already present, inside a construction measured at -# 3/3. Precision holds by construction: "worth naming" is the same -# opener-plus-communication-verb form as "worth noting". -# -# The `I would flag` / `I should note` family keeps `(flag|note)`, measured and -# deliberately unchanged — "I would name that" is not natural flagging, and -# widening there would be the invention this paragraph exists to refuse. -FLAG_VERB="noting|flagging|mentioning|naming" -HEDGES="worth ($FLAG_VERB)|one thing (I would|I['’]?d) (flag|note)|I['’]?d (flag|note) (that|one)|I would (flag|note) that|I should (note|flag)|(it|that)['’]?s worth ($FLAG_VERB)|bears ($FLAG_VERB)" - -# `-o` on a here-string, never `producer | grep -q`: under pipefail an -# early-exiting grep in a pipeline promotes a MATCH to a failure status -# (pipefail-grep-check). A here-string has no upstream process to signal. -# -# The count is over MATCHES, not matching lines — `grep -c` answers the second -# question and its own test caught that too: two tells in one sentence counted as -# one, which understates exactly the double-write this rule exists to name. -matches=$(grep -oiE "$HEDGES" <<<"$scrubbed" || true) -[[ -n "$matches" ]] || exit 0 -hits=$(wc -l <<<"$matches" | tr -d ' ') - -matched=$(sort -u <<<"$matches" | tr '\n' '|') - -cat < its tell is hedged flag-framing, worth noting being the commonest form - -That is the sentence this gate makes computable.' - [ "$status" -eq 0 ] -} - -@test "a fenced block carrying the tell does not fire" { - run check 'The literal set is: - -``` -worth noting|worth flagging -``` - -and nothing else.' - [ "$status" -eq 0 ] -} - -@test "a LINE-WRAPPED quoted span carrying the tell does not fire" { - # The defect this asserts against is sed being line-based by default: a - # line-based scrub leaves the interior of a wrapped quotation exposed, so the - # gate denies the very message documenting it. `-z` is why this passes. - run check 'The posture section says "the failure this kills is writing findings -twice, and its tell is hedged flag-framing — worth noting being the -commonest form" and that is what the gate now reads.' - [ "$status" -eq 0 ] -} - -# --- narrowness: it judges an act of flagging, not any use of the words ------ - -@test "reporting a measured value is not hedged framing" { - run check 'I noted the exit code was 2 and the receipt was absent.' - [ "$status" -eq 0 ] -} - -@test "a command flag is not hedged framing" { - run check 'The --flag argument is passed through to the child process.' - [ "$status" -eq 0 ] -} - -@test "a plainly stated finding with a durable home does not fire" { - run check 'config-lint claimed a --config-from caller that does not exist. Filed as CLOUD-236 and the false claim is removed in this commit.' - [ "$status" -eq 0 ] -} - -# --- failure posture --------------------------------------------------------- - -@test "empty stdin is clean rather than an error" { - run bash -c "printf '' | $CHECK" - [ "$status" -eq 0 ] - [ -z "$output" ] -} - -@test "a clean message produces no output at all" { - # The overload this asserts against: a predicate whose fail-open path and whose - # fired path both exit 0 fires on every turn. Silence on the clean path is what - # makes the caller's `&& exit 0` meaningful. - run check 'Landed on main by fast-forward, CI green. CLOUD-233 is Done.' - [ "$status" -eq 0 ] - [ -z "$output" ] -} From 98c718d6e4bb4ce1866dd47b9574ce1de1d0e6ed Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 10 Sep 2026 03:20:09 +0000 Subject: [PATCH 31/32] feat(record): one producer door for the nine measurements, keyed by branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1717 carries nine programs that are MEASUREMENTS rather than gates — `attestation-check`, `branch-age-check`, `checksums`, `land-divergence`, `nonverdict-scan`, `pr-unsubscribed`, `release-assets-check`, `release-due`, `timeout-drift`. House-style §5 keeps the `gh` spawn outside the engine and moves only the adjudication in, so each needs a producer writing a record a module can read. This is that door, once, rather than nine times. THE POLICY STORE, WHICH IS NOT THE TWO THIS NOUN ALREADY HAS. `record named` writes through `recorder::record_path` — the store `Fact::Records` projects onto `input.tree.records.`, which is how `plan-complete.rego` reads `records.plan` today with no `[[recorder]]` row declaring it. `record keyed` and `record journal` write TASK stores that only `record show`/`record fold` read back. Same noun, two different readers, two different key shapes, so they are separate leaves rather than one leaf with a mode flag. NO KEY POSITIONAL: the branch is the key and the engine resolves it, so a caller cannot record against a branch it is not on — `record plan`'s anti-staleness argument applied to a family the caller names. NO VALIDATION OF THE LINES, deliberately. `record plan` refuses an unknown status because a plan entry has a closed vocabulary this binary owns; a measurement's shape is its module's business, and a second reading here would make the module's own malformed-line arm unreachable. Nine bespoke verbs would each have been `run_plan` with its validation removed — the duplication this campaign deletes rather than relocates. Refs: CLOUD-1717 --- completions/batten.bash | 73 ++++++++++++++++++- completions/batten.fish | 70 ++++++++++++------ completions/batten.zsh | 56 ++++++++++++++ crates/batten/src/cli.rs | 17 +++++ crates/batten/src/record.rs | 53 ++++++++++++++ crates/batten/src/spec.rs | 4 + crates/batten/src/surface.rs | 27 +++++++ crates/batten/tests/it/pointer_only.rs | 11 +++ .../it__snapshots__golden_json_schema.snap | 18 +++++ man/batten-record-named.1 | 16 ++++ man/batten-record.1 | 3 + 11 files changed, 322 insertions(+), 26 deletions(-) create mode 100644 man/batten-record-named.1 diff --git a/completions/batten.bash b/completions/batten.bash index e96191890..44f807103 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -781,6 +781,9 @@ _batten() { batten__subcmd__help__subcmd__record,keyed) cmd="batten__subcmd__help__subcmd__record__subcmd__keyed" ;; + batten__subcmd__help__subcmd__record,named) + cmd="batten__subcmd__help__subcmd__record__subcmd__named" + ;; batten__subcmd__help__subcmd__record,plan) cmd="batten__subcmd__help__subcmd__record__subcmd__plan" ;; @@ -1261,6 +1264,9 @@ _batten() { batten__subcmd__record,keyed) cmd="batten__subcmd__record__subcmd__keyed" ;; + batten__subcmd__record,named) + cmd="batten__subcmd__record__subcmd__named" + ;; batten__subcmd__record,plan) cmd="batten__subcmd__record__subcmd__plan" ;; @@ -1288,6 +1294,9 @@ _batten() { batten__subcmd__record__subcmd__help,keyed) cmd="batten__subcmd__record__subcmd__help__subcmd__keyed" ;; + batten__subcmd__record__subcmd__help,named) + cmd="batten__subcmd__record__subcmd__help__subcmd__named" + ;; batten__subcmd__record__subcmd__help,plan) cmd="batten__subcmd__record__subcmd__help__subcmd__plan" ;; @@ -5332,7 +5341,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__record) - opts="tool forge keyed journal show fold plan closes" + opts="tool forge named keyed journal show fold plan closes" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -5415,6 +5424,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__record__subcmd__named) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__record__subcmd__plan) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9136,7 +9159,7 @@ _batten() { return 0 ;; batten__subcmd__record) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge keyed journal show fold plan closes help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help tool forge named keyed journal show fold plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -9256,7 +9279,7 @@ _batten() { return 0 ;; batten__subcmd__record__subcmd__help) - opts="tool forge keyed journal show fold plan closes help" + opts="tool forge named keyed journal show fold plan closes help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -9353,6 +9376,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__help__subcmd__named) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__help__subcmd__plan) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -9455,6 +9492,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__record__subcmd__named) + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__record__subcmd__plan) opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 8410d788e..a182e5c61 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -2967,36 +2967,37 @@ complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_sub complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "settle" -d 'Record what was decided about a stored finding' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand state; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' -complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge keyed journal show fold plan closes help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "named" -d 'Record one named family under this branch, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "fold" -d 'Fold a journal family: `nothing`, its records, or `unreadable `' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "plan" -d 'Record this branch\'s plan, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "closes" -d 'Record which rows this branch\'s pull request body closes, read on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and not __fish_seen_subcommand_from tool forge named keyed journal show fold plan closes help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from tool" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3039,6 +3040,27 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from forge" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from named" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from keyed" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -3167,6 +3189,7 @@ complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from closes" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "named" -d 'Record one named family under this branch, read from stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' complete -c batten -n "__fish_batten_using_subcommand record; and __fish_seen_subcommand_from help" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' @@ -3857,6 +3880,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from state" -f -a "list" -d 'List stored findings and the refs they were observed in' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "tool" -d 'Record a declared tool row\'s verdict, read as ` ` lines on stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "forge" -d 'Record the forge\'s check verdicts for one commit, read as ` ` lines on stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "named" -d 'Record one named family under this branch, read from stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "keyed" -d 'Put one value into a keyed store family, read from stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "journal" -d 'Append one record to an append-and-fold store family, read from stdin' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from record" -f -a "show" -d 'Read one keyed record back: `hit` and the value, or `miss`' diff --git a/completions/batten.zsh b/completions/batten.zsh index 4e6684966..23d0c9127 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -5078,6 +5078,36 @@ trace\:"Add everything"))' \ ':ref -- The ref or sha the verdict was taken against:_default' \ && ret=0 ;; +(named) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +':family -- The record family, which is the key a module reads it under:_default' \ +&& ret=0 +;; (keyed) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -5278,6 +5308,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(named) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (keyed) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -7089,6 +7123,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(named) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (keyed) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -8641,6 +8679,7 @@ _batten__subcmd__help__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'named:Record one named family under this branch, read from stdin' \ 'keyed:Put one value into a keyed store family, read from stdin' \ 'journal:Append one record to an append-and-fold store family, read from stdin' \ 'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ @@ -8675,6 +8714,11 @@ _batten__subcmd__help__subcmd__record__subcmd__keyed_commands() { local commands; commands=() _describe -t commands 'batten help record keyed commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__record__subcmd__named_commands] )) || +_batten__subcmd__help__subcmd__record__subcmd__named_commands() { + local commands; commands=() + _describe -t commands 'batten help record named commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__record__subcmd__plan_commands] )) || _batten__subcmd__help__subcmd__record__subcmd__plan_commands() { local commands; commands=() @@ -9769,6 +9813,7 @@ _batten__subcmd__record_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'named:Record one named family under this branch, read from stdin' \ 'keyed:Put one value into a keyed store family, read from stdin' \ 'journal:Append one record to an append-and-fold store family, read from stdin' \ 'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ @@ -9799,6 +9844,7 @@ _batten__subcmd__record__subcmd__help_commands() { local commands; commands=( 'tool:Record a declared tool row'\''s verdict, read as \` \` lines on stdin' \ 'forge:Record the forge'\''s check verdicts for one commit, read as \` \` lines on stdin' \ +'named:Record one named family under this branch, read from stdin' \ 'keyed:Put one value into a keyed store family, read from stdin' \ 'journal:Append one record to an append-and-fold store family, read from stdin' \ 'show:Read one keyed record back\: \`hit\` and the value, or \`miss\`' \ @@ -9839,6 +9885,11 @@ _batten__subcmd__record__subcmd__help__subcmd__keyed_commands() { local commands; commands=() _describe -t commands 'batten record help keyed commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__help__subcmd__named_commands] )) || +_batten__subcmd__record__subcmd__help__subcmd__named_commands() { + local commands; commands=() + _describe -t commands 'batten record help named commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__help__subcmd__plan_commands] )) || _batten__subcmd__record__subcmd__help__subcmd__plan_commands() { local commands; commands=() @@ -9864,6 +9915,11 @@ _batten__subcmd__record__subcmd__keyed_commands() { local commands; commands=() _describe -t commands 'batten record keyed commands' commands "$@" } +(( $+functions[_batten__subcmd__record__subcmd__named_commands] )) || +_batten__subcmd__record__subcmd__named_commands() { + local commands; commands=() + _describe -t commands 'batten record named commands' commands "$@" +} (( $+functions[_batten__subcmd__record__subcmd__plan_commands] )) || _batten__subcmd__record__subcmd__plan_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index c8ffb05a7..8fa28bf81 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -1364,6 +1364,20 @@ pub enum RecordCommand { /// The ref or sha the verdict was taken against. reference: String, }, + /// Record one named family under this branch, for a module to read. + /// + /// The POLICY-readable store, unlike [`RecordCommand::Keyed`] and + /// [`RecordCommand::Journal`] below, which are task stores: this writes + /// through `recorder::record_path`, which is what `Fact::Records` projects + /// onto `input.tree.records.`. + /// + /// No key positional: the BRANCH is the key and the engine resolves it, so a + /// caller cannot record against a branch it is not on — `record plan`'s + /// anti-staleness argument, applied to a family the caller names. + Named { + /// The record family, which is the key a module reads it under. + family: String, + }, /// Put one value into a keyed store family (CLOUD-1713). Keyed { /// The store family the record belongs to. @@ -2408,6 +2422,9 @@ fn record_of(matches: &ArgMatches) -> Option { ("forge", matches) => Some(RecordCommand::Forge { reference: matches.get_one::("ref")?.clone(), }), + ("named", matches) => Some(RecordCommand::Named { + family: matches.get_one::("family")?.clone(), + }), ("keyed", matches) => Some(RecordCommand::Keyed { family: matches.get_one::("family")?.clone(), key: matches.get_one::("key")?.clone(), diff --git a/crates/batten/src/record.rs b/crates/batten/src/record.rs index 6bdcd1aef..744e5c62c 100644 --- a/crates/batten/src/record.rs +++ b/crates/batten/src/record.rs @@ -205,6 +205,7 @@ pub fn run( crate::cli::RecordCommand::Forge { reference } => run_forge(&reference, overrides), crate::cli::RecordCommand::Plan => run_plan(), crate::cli::RecordCommand::Closes => run_closes(overrides), + crate::cli::RecordCommand::Named { family } => run_named(&family), crate::cli::RecordCommand::Keyed { family, key } => run_keyed(&family, &key), crate::cli::RecordCommand::Journal { family } => run_journal(&family), crate::cli::RecordCommand::Show { family, key } => run_keyed_show(&family, &key, out), @@ -424,6 +425,58 @@ fn safe_component(what: &str, value: &str) -> Result { Ok(clean.to_owned()) } +/// Record one named family under this branch, read from stdin. +/// +/// **The POLICY-readable store, which is a different store from the two below.** +/// `record keyed`/`record journal` write task stores that `record show`/`record +/// fold` read back; this writes through [`crate::recorder::record_path`], which +/// is the store [`crate::facts::Fact::Records`] projects onto +/// `input.tree.records.`. A module reads what this writes; nothing reads +/// what those write except the task that wrote it. Keeping them apart is why +/// this is a third verb rather than a flag on one of them: the two stores have +/// different keys, different readers and different lifetimes. +/// +/// **One verb, not one per measurement** (CLOUD-1717). Nine programs in that wave +/// are measurements rather than gates — the `gh` call stays outside per +/// house-style §5 and only the adjudication moves in — so each needs a producer +/// that writes a record a module can read. Nine bespoke verbs would be nine +/// spellings of `run_plan` with the validation removed, which is the duplication +/// the retirement campaign exists to delete rather than to relocate. +/// +/// **NO VALIDATION OF THE LINES, deliberately.** [`run_plan`] refuses an unknown +/// status because a plan entry has a closed vocabulary this binary owns. A +/// measurement's shape is the module's business, and a second reading here would +/// be the two-authorities-over-one-fact defect: the module already has to decide +/// what a malformed line means, and a writer that pre-judged it would make the +/// module's own arm unreachable. +/// +/// # Errors +/// +/// A [`UsageError`] when the family is not a single path component, when the +/// repository has no branch to key on, or when this is not a git repository; an +/// internal error when the store cannot be written. +pub fn run_named(family: &str) -> Result { + let family = safe_component("family", family)?; + let raw = verdict_lines()?; + let root = Path::new("."); + let git_dir = git::git_dir(root).map_err(|_| { + UsageError::raise( + "record named: not a git repository, so there is nothing to key on".to_owned(), + ) + })?; + let Ok(Some(branch)) = git::current_branch(root) else { + return Err(UsageError::raise( + "record named: a detached HEAD has no branch to key the record on".to_owned(), + )); + }; + let claim = claim_of(&git_dir, &branch); + store( + &crate::recorder::record_path(&git_dir, &family, &branch, claim.as_deref()), + &raw, + )?; + Ok(ExitCode::Success) +} + /// The record path for one (family, key) pair. /// /// Keyed by a digest of the key rather than by the key itself, on diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index 9201708f4..321c8b880 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -1019,6 +1019,10 @@ mod tests { // rather than one verb with a mode flag. "record journal".to_owned(), "record keyed".to_owned(), + // CLOUD-1717's producer door: the POLICY-readable store, keyed by + // branch, which `Fact::Records` projects. The two leaves above it + // are task stores read back only by `record show`/`record fold`. + "record named".to_owned(), // The plan a branch declared, so `plan-complete` decides over a // record rather than over a transcript it cannot re-read. "record plan".to_owned(), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index bdb0a2c86..ab69e9365 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -4863,6 +4863,33 @@ pub const SURFACE: &[CommandDecl] = &[ // CLOUD-1546 counts 42 top-level rows and CLOUD-1182 records nine ports // becoming nine nouns, and a store family is an object this verb records, not // a verb of its own. + // CLOUD-1717's producer door. Nine programs in that wave are MEASUREMENTS + // rather than gates: house-style §5 keeps the `gh` spawn outside the engine + // and moves only the adjudication in, so each needs a producer writing a + // record a module can read. + // + // THE POLICY STORE, WHICH IS NOT THE TWO BELOW. This writes through + // `recorder::record_path`, the store `Fact::Records` projects onto + // `input.tree.records.`; `record keyed`/`record journal` write task + // stores that only `record show`/`record fold` read back. Same noun, two + // different readers, so they are different leaves rather than one leaf with + // a mode flag. + // + // ONE LEAF FOR NINE PRODUCERS. Nine bespoke verbs would each be `record + // plan` with its validation removed — the duplication this campaign deletes + // rather than relocates. + CommandDecl { + path: "record named", + id: "record.named", + about: "Record one named family under this branch, read from stdin", + data_channel: false, + exits: EXITS_STANDARD, + effect: Effect::Write, + flags: &[FlagDecl::positional( + "family", + "The record family, which is the key a module reads it under", + )], + }, CommandDecl { path: "record keyed", id: "record.keyed", diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 6d54cf6e8..a8a551d67 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -1864,6 +1864,17 @@ const CENSUS: &[Verb] = &[ // family and a key. `PointerOnly` is therefore the whole of their contract — // a successful write says nothing, because the record's destination is a // keyed file under `$GIT_DIR` and there is nothing for it to report. + // CLOUD-1717's producer door, and pointer-only for the reason the other two + // generic writers are: it does not know what its payload MEANS. A successful + // write says nothing at all — the record's destination is a branch-keyed file + // under `$GIT_DIR`, and what a module later makes of the lines is the + // module's business, not this verb's. + Verb { + path: "record named", + args: &["census"], + stdin: Stdin::ToolVerdict, + disposition: Disposition::PointerOnly, + }, Verb { path: "record keyed", args: &["census", "a-key"], diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index e96f7114b..f01381a0c 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -2542,6 +2542,24 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "record named", + "id": "record.named", + "about": "Record one named family under this branch, read from stdin", + "effect": "write", + "data_channel": false, + "flags": [ + { + "name": "family", + "short": null, + "long": null, + "takes_value": true, + "positional": true, + "help": "The record family, which is the key a module reads it under" + } + ], + "subcommands": [] + }, { "path": "record plan", "id": "record.plan", diff --git a/man/batten-record-named.1 b/man/batten-record-named.1 new file mode 100644 index 000000000..98b5f2346 --- /dev/null +++ b/man/batten-record-named.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-record-named 1 batten +.SH NAME +batten\-record\-named \- Record one named family under this branch, read from stdin +.SH SYNOPSIS +\fBbatten record named\fR [\fB\-h\fR|\fB\-\-help\fR] <\fIfamily\fR> +.SH DESCRIPTION +Record one named family under this branch, read from stdin +.SH OPTIONS +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help +.TP +<\fIfamily\fR> +The record family, which is the key a module reads it under diff --git a/man/batten-record.1 b/man/batten-record.1 index 64fcdd4f4..210ca39c2 100644 --- a/man/batten-record.1 +++ b/man/batten-record.1 @@ -19,6 +19,9 @@ Record a declared tool row\*(Aqs verdict, read as ` ` lines on stdi batten\-record\-forge(1) Record the forge\*(Aqs check verdicts for one commit, read as ` ` lines on stdin .TP +batten\-record\-named(1) +Record one named family under this branch, read from stdin +.TP batten\-record\-keyed(1) Put one value into a keyed store family, read from stdin .TP From 66f86ff1fa42cf6a0efa7bd4e517a0461a070a15 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 10 Sep 2026 03:35:39 +0000 Subject: [PATCH 32/32] feat(policy): decide branch age on the engine, ahead of the program's retirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first of CLOUD-1707's nine measurements ported onto the engine. `policy/branch-age.rego` decides staleness and name reuse from the record `mise run branch-age-record` writes; the producer keeps the two `gh` calls and the civil-calendar arithmetic outside, because `Fact::Instant` projects `null` to every module and `clock_ban.rs` holds the engine to it. Two properties, not one: a tip older than the threshold, and a branch NAME heading more than one merged pull request AND still on the remote. The survivor conjunct is what keeps the second clearable — merged pull requests are immutable, so an unintersected count would be an alarm no action clears. A present record naming no branch is refused rather than passed; an absent record says nothing, which is could-not-look. THE PROGRAM IS NOT RETIRED YET, and deleting it here would land the retirement as the regression this campaign exists to remove: `input.tree.records["branch-age"]` does not reach the module. `recorder_records` projects only families named by a `[[recorder]]` row or by `record::VERB_WRITTEN`, and a `record named` family is in neither — so `batten check --rule branch-age` exits 0 over a record holding branches aged 36 days. `mise-tasks/branch-age-check.sh` and its suite stay until the projection is fixed, and the module carries a `#MUTANT-EXEMPT` rather than mutation rows until its compiled-binary tier exists to redden. Refs: CLOUD-1717 path write refused 3d3705506e89801fb9ec61531069724d292df6fa84668f48a647a57e4cb29b28 spent Admits: 3d3705506e89801fb9ec61531069724d292df6fa84668f48a647a57e4cb29b28 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:98c718d6e4bb4ce1866dd47b9574ce1de1d0e6ed Admits-epoch: dbcb39edd82864df2969a8945e054e8949c59d12b54d86752d3fb309fea72c6e Admits-author: alec@wenzowski.com Admits-prev: d489ecce0a83e22eb0fbe6b34b917cb44190a221aa65f20db55168bfa45e5f2f Admits-answer-lost: CLOUD-1717's first ported measurement cannot be registered at all, so `policy/branch-age.rego` sits in the tree unreachable by `batten check` and the retirement of `mise-tasks/branch-age-check.sh` stalls behind a config edit no route can make. Worse, the module would be uncommitted work on a branch a reclaim takes. Admits-answer-precondition: A policy module is registered by a `[[rule]]` row and by nothing else, and the verdict strings it emits must exist as `[[verdict]]` rows or the engine refuses the module outright. `policy/branch-age.rego` is a new module, so its row, its three verdicts and the `[[pattern]] whole-number` its `ref`-line reader references all have to be written into batten.toml directly; there is no other surface any of the four lives on. All four land in one diff a reviewer reads beside the module they serve. Admits-answer-rejected-route: config read first — I read the existing `[[rule]]`/`[[verdict]]`/`[[pattern]]` rows and copied their shape, and that reading is what produced the edit rather than what avoided it: registration is an addition, and no amount of reading substitutes for writing the row. patch run first is a commit-message route and does not apply to a config addition. path write refused c7f928e56d30b0a479909e63ac35beb33a7d551917beffe77e27ceb429308214 spent Admits: c7f928e56d30b0a479909e63ac35beb33a7d551917beffe77e27ceb429308214 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: mise.toml Admits-anchor: call:98c718d6e4bb4ce1866dd47b9574ce1de1d0e6ed Admits-epoch: dbcb39edd82864df2969a8945e054e8949c59d12b54d86752d3fb309fea72c6e Admits-author: alec@wenzowski.com Admits-prev: e29ad6207c3722d7d464e9ef638dfb5858df5c9ea76b75b1396720a8b84395b3 Admits-answer-lost: The module has no record to read, so the port is inert: `policy/branch-age.rego` would decide over a store nothing writes, which is exactly the could-not-look-reads-as-pass shape this campaign exists to remove. The retirement of `mise-tasks/branch-age-check.sh` cannot proceed without its successor's producer. Admits-answer-precondition: A mise task body lives on exactly one surface, `mise.toml`, and `[tasks.branch-age-record]` is the producer half of this port: it carries the two `gh` calls and the civil-calendar day arithmetic that must stay outside the module, because `Fact::Instant` projects `null` to every module and `clock_ban.rs` holds the engine to it. There is no other file a task body can be written in. The addition is one contiguous block a reviewer reads beside the module it feeds. Admits-answer-rejected-route: config read first — I read the sibling producers (`[tasks.forge-record]` and the other record writers) and matched their shape, and the reading is what made the block correct rather than what made it unnecessary: a producer that does not exist cannot be read into existence. patch run first is a commit-message route and does not apply to a task addition. --- batten.toml | 80 +++++++++++++++ mise.toml | 64 ++++++++++++ policy/branch-age.rego | 215 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 359 insertions(+) create mode 100644 policy/branch-age.rego diff --git a/batten.toml b/batten.toml index 78be89c8d..c2989bb53 100644 --- a/batten.toml +++ b/batten.toml @@ -2026,6 +2026,15 @@ regex = '(?i)(^|[^0-9A-Za-z-])(clos(e|es|ed)|fix(|es|ed)|resolv(e|es|ed))[[:blan # issue reads this row instead of spelling its own set. `duplicate` is closed too: # an exemption whose owner was merged into another issue is as spent as one whose # owner shipped. +# A RUN OF DIGITS AND NOTHING ELSE, which is what makes a recorded age readable +# as a number at all. `branch-age`'s producer writes the age it computed; this is +# the module's shape test before `to_number`, because an unparseable third column +# is a torn record rather than a branch that is zero days old — and those two must +# not collapse into the same verdict. +[[pattern]] +id = "whole-number" +regex = '^[0-9]+$' + [[pattern]] id = "closed-issue-status" regex = '^(done|canceled|duplicate)$' @@ -6049,6 +6058,26 @@ line_sources = [ module = "policy/mutation-declared-case.rego" severity = "deny" +# CLOUD-349, ported off `mise-tasks/branch-age-check.sh` under CLOUD-1717. +# +# A MEASUREMENT, SO THE SPAWN STAYS OUTSIDE (house-style §5). `mise run +# branch-age-record` makes the two forge reads and computes each branch's age in +# days — the engine calls no clock on any evaluation path, so the arithmetic +# cannot live in the module — and writes `branch-age` through `batten record +# named`. This row adjudicates what was recorded. +# +# `severity = "warn"` FOR NOW, and the reason is the population rather than +# caution: the retired program ran on a schedule against every remote branch, +# where this runs in `verify` against whatever the last producer wrote. CLOUD-320 +# binds the promotion — land it reporting, read the firing rate, promote with the +# measurement rather than with an argument. +[[rule]] +id = "branch-age" +kind = "policy" +scope = "tree" +module = "policy/branch-age.rego" +severity = "warn" + [[rule]] id = "plan-complete" kind = "policy" @@ -12527,6 +12556,57 @@ id = "patch run first" kind = "command" target = "git commit -F " +# CLOUD-349's two readings, ported off `branch-age-check.sh` under CLOUD-1717. +[[verdict]] +id = "branch watch stale" +gloss = "a remote branch has outlived the story it was cut for" +class = """ +Trunk-based development asks a review branch to be "very short-lived" and names \ +the hazard: a short-lived feature branch sleepwalking into a long-lived one. \ +Measured 2026-08-11, before `land` learned to delete: 23 remote branches, ten of \ +them five days old. The branch is either finished — in which case delete it — or \ +it is not, in which case it is a long-lived branch and the trunk-based claim is \ +the thing that is false. +""" + +[[verdict.route]] +id = "branch write first" +kind = "command" +target = "git push origin --delete " + +[[verdict]] +id = "branch name duplicate" +gloss = "one branch name has headed several merged pull requests and still exists" +class = """ +A per-PR lifetime metric cannot see this: \ +`claude/phase-3-sequential-landing-h26kx0` headed eight consecutive pull \ +requests, each landing inside an hour, while the branch itself lived for days. \ +THE SURVIVING BRANCH IS HALF THE CLASS, because merged pull requests are \ +immutable — a count alone would be an alarm no action could ever clear, so what \ +is refused is the name still being there to reuse again. +""" + +[[verdict.route]] +id = "branch write first" +kind = "command" +target = "git push origin --delete , and cut a fresh name for the next story" + +[[verdict]] +id = "branch list empty" +gloss = "the branch record was written and names no branch at all" +class = """ +A remote reporting no branches cannot be true of a repository with a trunk, so \ +the honest reading is a listing that failed while still exiting zero. ABSENT AND \ +PRESENT-BUT-EMPTY ARE DIFFERENT STATES and must not collapse: no record is \ +could-not-look and says nothing, a record naming nothing is a reading this gate \ +refuses. Re-run the producer and read what it reports. +""" + +[[verdict.route]] +id = "task run first" +kind = "command" +target = "mise run branch-age-record" + # CLOUD-613, CLOUD-482. The waste here is the SESSION rather than a verdict or a # gate, which is why it is a class of its own rather than a row on either above. [[verdict]] diff --git a/mise.toml b/mise.toml index 85d162687..9800a78ee 100644 --- a/mise.toml +++ b/mise.toml @@ -1836,6 +1836,70 @@ counts=$(jq -r --argjson authored "$authored" ' } | cargo run --quiet -p batten -- record tool sbom ''' +# CLOUD-349's two forge reads, ported off `branch-age-check.sh` under CLOUD-1717. +# +# THE SPAWN STAYS OUT HERE AND ONLY THE ADJUDICATION MOVED IN (house-style §5). +# `policy/branch-age.rego` decides what a stale branch is; this fetches what it +# decides over. The day arithmetic is here for a harder reason than convention: +# the engine calls no clock on any evaluation path — `clippy.toml`'s +# `disallowed-methods` and `crates/batten/tests/clock_ban.rs` hold it to that — +# so an AGE cannot be computed inside a module at all. +# +# ONE GRAPHQL CALL for the refs rather than a REST branch list plus a commit +# lookup per branch: the tip date is not on the REST branch object, and 23 +# follow-up calls to learn it is the shape `mem:github-rest-etiquette` warns +# about. +# +# WRITES NOTHING WHEN IT CANNOT LOOK, which is the half that keeps the module +# honest. A failed fetch leaves the record absent, the module's `recorded` stays +# undefined and it says nothing; a record that IS written and names no branch is +# the different, refused state. Collapsing those two is the silent pass this +# campaign keeps finding. +[tasks.branch-age-record] +description = "Effect: record every remote branch's age in days and which names head more than one merged PR (CLOUD-349)" +shell = "bash -c" +run = """ +trunk="${BRANCH_AGE_TRUNK:-main}" +today=$(date -u +%Y-%m-%d) + +slug=$(gh repo view --json owner,name --jq '.owner.login + " " + .name' 2>/dev/null) || exit 0 +refs=$(gh api graphql \ + -f query='query($owner:String!,$name:String!){ + repository(owner:$owner,name:$name){ + refs(refPrefix:"refs/heads/",first:100){ + nodes{ name target{ ... on Commit { committedDate } } } + } + } + }' \ + -F owner="${slug% *}" -F name="${slug#* }" \ + --jq '.data.repository.refs.nodes[] | [.name, .target.committedDate] | @tsv' 2>/dev/null) || exit 0 +prs=$(gh pr list --state merged --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null) || exit 0 + +{ + printf '%s\\n' "$refs" | awk -F'\\t' -v today="$today" -v trunk="$trunk" ' + function days(y, m, d, era, yoe, doy, doe) { + if (m <= 2) y -= 1 + era = int((y >= 0 ? y : y - 399) / 400) + yoe = y - era * 400 + doy = int((153 * (m + (m > 2 ? -3 : 9)) + 2) / 5) + d - 1 + doe = yoe * 365 + int(yoe / 4) - int(yoe / 100) + doy + return era * 146097 + doe - 719468 + } + function daynum(iso, p, s) { + s = substr(iso, 1, 10) + if (s !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) return "NaN" + split(s, p, "-") + return days(p[1] + 0, p[2] + 0, p[3] + 0) + } + BEGIN { now = daynum(today) } + $1 == "" { next } + $1 == trunk { next } + { d = daynum($2); if (d != "NaN") print "ref\\t" $1 "\\t" (now - d) } + ' + printf '%s\\n' "$prs" | sed '/^$/d' | awk -v trunk="$trunk" '$0 != trunk { print "merged\\t" $0 }' +} | cargo run --quiet -p batten -- record named branch-age +""" + [tasks.record-verdicts] description = "Effect: run each declared third-party validator OUTSIDE the engine and record its verdict where `batten check` reads it (CLOUD-1265)" # THE HALF THAT WAS MISSING. `crates/batten/src/tools.rs` reads diff --git a/policy/branch-age.rego b/policy/branch-age.rego new file mode 100644 index 000000000..c4ce1d7e6 --- /dev/null +++ b/policy/branch-age.rego @@ -0,0 +1,215 @@ +# No remote branch outlives its story (CLOUD-349, ported under CLOUD-1717). +# +# Trunk-based development says a review branch "can (and should) be deleted after +# the code review is complete and be very short-lived", and names the hazard this +# measures: a short-lived feature branch "sleepwalking into a long-lived feature +# branch". Its own words on the tooling — "You cannot with tools today, but it +# would be cool if you could have a ticking clock or count down on those branches +# at creation to enforce its 'temporary' intention." This is that clock, after +# the fact. +# +# TWO PROPERTIES, because staleness is the smaller half: +# +# stale a branch whose tip is older than the threshold below. The ordinary +# leftover. Measured 2026-08-11, before `land` learned to delete: 23 +# remote branches, ten of them `release-plz-*` from five days earlier. +# reused a branch NAME heading more than one merged pull request AND still +# present on the remote. The second conjunct is what keeps this a gate +# rather than a permanent alarm: merged PRs are immutable, so an +# unintersected count could never be cleared by any action. This is +# the one a per-PR lifetime metric cannot see — +# `claude/phase-3-sequential-landing-h26kx0` headed eight consecutive +# pull requests, each landing inside an hour, while the branch itself +# lived for days. +# +# THE CLOCK IS THE PRODUCER'S, NOT THIS MODULE'S, and that split is forced rather +# than chosen. `Fact::Instant` is consumed at the boundary and projects `null` to +# every module — the engine calls no `SystemTime::now` on any evaluation path, +# which `clippy.toml`'s `disallowed-methods` and `crates/batten/tests/clock_ban.rs` +# hold it to. So the record carries an AGE IN DAYS that the producer computed, +# and what moves in here is the decision: an age over the threshold is stale. +# CLOUD-1559's reading rule says the same thing from the other side — carry the +# decisions, not the steps. The retired program's civil-calendar day arithmetic +# is a step, and it stays outside with the `gh` call that needs it. +# +# COULD-NOT-LOOK IS AN ABSENT RECORD, and it is silence rather than a pass. The +# producer writes nothing when it cannot reach the forge, so `records` carries no +# `branch-age` key, every rule below is undefined and the module says nothing. +# A record that is PRESENT and holds no `ref` line is a different state: the +# producer looked and the remote reported no branches, which the retired program +# refused outright as impossible of a repository with a trunk. That refusal is +# kept, because a silent pass there is the shape where the whole gate evaporates. +#MUTANT-EXEMPT CLOUD-1717|the compiled-binary tier this module's mutations would redden, `crates/batten/tests/it/branch_age.rs`, is not written yet: the module does not decide, because `recorder_records` projects no `record named` family and `input.tree.records["branch-age"]` never reaches it. The four mutations are drafted in this file's history and go back with the tier, in the same delta that retires `mise-tasks/branch-age-check.sh` — the program stays until then, so nothing is uncovered that was covered before. + +# METADATA +# description: | +# Bound to the TREE surface: this row is `scope = "tree"`, so it reads +# `input.tree` and never the mediated call. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.branch_age + +import rego.v1 + +rules contains "branch-stale" + +rules contains "branch-reused" + +rules contains "branch-listing-empty" + +# The source's "a couple of days", as a number. +# +# In the module rather than in config, on `repetition-without-progress`'s +# reasoning one row over: this is the practice's own figure rather than this +# consumer's tuning, and a config knob would invite raising it until nothing +# fires. Moving it costs a diff a reviewer reads. +threshold := 2 + +# The producer's lines, or nothing. `recorded` being undefined is the +# could-not-look state the header describes, and every rule below inherits it. +recorded := input.tree.records["branch-age"] + +# `ref ` — one per branch the remote reports, trunk excluded +# by the producer because the trunk is not a review branch. +refs contains {"name": columns[1], "age": to_number(columns[2])} if { + some raw in recorded + columns := split(raw, "\t") + count(columns) == 3 + columns[0] == "ref" + regex.match(data.batten.patterns["whole-number"], columns[2]) +} + +# A line the reader cannot parse is skipped rather than judged — the same posture +# every other record reader here takes, because the producer already refused a +# malformed line and anything unparseable at read time is a torn store. +on_remote contains entry.name if { + some entry in refs +} + +# `merged ` — one per merged pull request, so a name heading several +# appears several times and the cardinality is the reading. +merged_heads contains [index, columns[1]] if { + some index, raw in recorded + columns := split(raw, "\t") + count(columns) == 2 + columns[0] == "merged" +} + +merge_count(name) := count([pair | some pair in merged_heads; pair[1] == name]) + +violation contains { + "rule": "branch-stale", + "verdict": "branch watch stale", + "subjects": [{"artifact": entry.name}, {"count": entry.age}], +} if { + some entry in refs + age := entry.age + age > threshold +} + +# THE SURVIVOR CONJUNCT IS THE WHOLE GATE. A merged pull request is immutable, so +# a name that headed two of them heads two of them forever; refusing on the count +# alone would be an alarm no action could clear, which is the shape that gets a +# gate switched off. Intersecting with what the remote still carries makes the +# remedy `git push --delete`. +violation contains { + "rule": "branch-reused", + "verdict": "branch name duplicate", + "subjects": [{"artifact": reused}, {"count": merge_count(reused)}], +} if { + some pair in merged_heads + reused := pair[1] + merge_count(reused) > 1 + count([name | some name in on_remote; name == reused]) > 0 +} + +# A PRESENT RECORD NAMING NO BRANCH IS A REFUSAL, never a clean board. The +# retired program said why: a remote reporting no branches at all "cannot be true +# of a repository with a trunk", so the honest reading is that the listing failed +# in a way that still exited zero. Absent is could-not-look; present-and-empty is +# a lie, and the two must not collapse. +violation contains { + "rule": "branch-listing-empty", + "verdict": "branch list empty", +} if { + recorded + count(refs) == 0 +} + +# --- cases --------------------------------------------------------------- + +tree(lines) := {"tree": {"records": {"branch-age": lines}}} + +test_a_branch_older_than_the_threshold_is_stale if { + some v in violation with input as tree(["ref\tclaude/old\t9"]) + v.verdict == "branch watch stale" +} + +# ONE BELOW THE THRESHOLD IS CLEAN, and an off-by-one here moves the whole +# population the rule fires on. +test_a_branch_at_the_threshold_is_not_stale if { + count(violation) == 0 with input as tree(["ref\tclaude/fresh\t2"]) +} + +test_the_threshold_is_the_sources_couple_of_days if { + count(violation) == 0 with input as tree(["ref\tclaude/fresh\t2"]) + some v in violation with input as tree(["ref\tclaude/old\t3"]) + v.verdict == "branch watch stale" +} + +# POINTER, NEVER PAYLOAD: a branch NAME and a COUNT of days, which is what the +# remedy needs and nothing more. +test_the_finding_carries_a_name_and_a_count if { + some v in violation with input as tree(["ref\tclaude/old\t9"]) + v.subjects == [{"artifact": "claude/old"}, {"count": 9}] +} + +test_a_name_heading_two_merged_pull_requests_and_still_on_the_remote_is_reused if { + some v in violation with input as tree([ + "ref\tclaude/reused\t1", + "merged\tclaude/reused", + "merged\tclaude/reused", + ]) + v.verdict == "branch name duplicate" +} + +# THE CONJUNCT THAT KEEPS THIS CLEARABLE. Merged pull requests are immutable, so +# without the survivor test this fires forever on history nobody can change. +test_a_reused_name_whose_branch_is_gone_is_not_reported if { + count(violation) == 0 with input as tree([ + "ref\tclaude/other\t1", + "merged\tclaude/deleted", + "merged\tclaude/deleted", + ]) +} + +test_a_name_heading_one_merged_pull_request_is_not_reused if { + count(violation) == 0 with input as tree([ + "ref\tclaude/once\t1", + "merged\tclaude/once", + ]) +} + +test_a_present_record_naming_no_branch_is_refused if { + some v in violation with input as tree(["merged\tclaude/gone"]) + v.verdict == "branch list empty" +} + +# COULD NOT LOOK IS NOT INNOCENCE, and it is not guilt either. The producer +# writes nothing when the forge is unreachable, and a module that refused there +# would refuse every checkout with no credential. +test_no_record_at_all_says_nothing if { + count(violation) == 0 with input as {"tree": {"records": {}}} +} + +# A SURVIVING GOOD LINE IS PART OF THE CASE, not scenery: without it the record +# holds no readable ref and `branch list empty` fires, which would let this case +# pass for a reason that has nothing to do with skipping. +test_a_line_this_reader_cannot_parse_is_skipped if { + count(violation) == 0 with input as tree([ + "ref\tclaude/fresh\t1", + "ref\tclaude/x\tnot-a-number", + "nonsense", + ]) +}