From cedbe06141a4b495cea57794f82c4b97fbde6d21 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 12:26:56 +0000 Subject: [PATCH 01/22] feat(hook): enumerate the four hook sources and their event vocabularies (CLOUD-1728) CLOUD-777's invariant was derived for the harness alone, because `Event` enumerates the harness and nothing enumerated the CLI, the git hook surface or CI. `HookSource::ALL` and `HookSource::vocabulary` make the taxonomy declared data: the harness half derived from `Wiring::registrations`, the CLI half from `surface::SURFACE`, githooks(5) for git 2.43 and the forge reads as tables, each name carrying one `HookDisposition`. Declarative only: no runtime path calls `vocabulary()` yet; CLOUD-2075's emitter census is its first consumer. Five column-0 mutants under `engine-hook` show each completeness case can fail. `$MUTANT_GATES` gains `engine-hookcost` and `engine-contract` for later bundle rows (`engine-refusal` was already listed). Refs: CLOUD-1728 --- crates/batten/src/doctor.rs | 16 ++ crates/batten/src/hook.rs | 203 +++++++++++++++++++++++++ crates/batten/tests/it/hook_sources.rs | 163 ++++++++++++++++++++ crates/batten/tests/it/main.rs | 1 + mise.toml | 2 +- 5 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 crates/batten/tests/it/hook_sources.rs diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index d53c509b6..3961ccedc 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -2892,6 +2892,22 @@ mod tests { use super::*; + /// CLOUD-1728. `COMMIT_HOOKS` stays the runtime authority for which git + /// hooks batten links; the declared git vocabulary must register exactly + /// those, read through the public accessor every consumer reaches. + #[test] + fn the_git_vocabulary_registers_exactly_the_commit_hooks() { + let registered: std::collections::BTreeSet = crate::hook::HookSource::Git + .vocabulary() + .into_iter() + .filter(|(_, d)| matches!(d, crate::hook::HookDisposition::Registered { .. })) + .map(|(name, _)| name) + .collect(); + let authority: std::collections::BTreeSet = + COMMIT_HOOKS.iter().map(|h| (*h).to_owned()).collect(); + assert_eq!(registered, authority); + } + /// CLOUD-1683. The declared table is read in every spelling it really uses — /// a plain string pin, an inline table, and a backend-prefixed quoted key — /// because the difference between them is entirely on the right-hand side. diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index d65285bb5..3fc8a6753 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -1931,6 +1931,209 @@ impl Harness { } } +/// Where an adjudication event reaches batten (CLOUD-1728). +/// +/// CLOUD-777's invariant — batten hooked exactly once on every available hook +/// surface — was derived for the harness alone, because [`Event`] enumerates the +/// harness and nothing enumerated the other three. A source with no declared +/// vocabulary cannot be checked for completeness, so each was rediscovered from a +/// symptom. This is the outer index: four sources, each with a vocabulary and a +/// disposition per name. +/// +/// Named `HookSource`, never bare `Source`: `resolve::Source` and `race::Source` +/// exist, and `SessionStart`'s `source` field (startup/resume/clear/compact) is a +/// different axis entirely. +/// +/// **It ports nothing.** No runtime path calls [`HookSource::vocabulary`]; its +/// value is the completeness test over it, and its first consumer is CLOUD-2075's +/// emitter census, which iterates [`HookSource::ALL`] rather than keeping its own +/// list of where batten's text reaches an agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookSource { + /// The engine invoked directly; registration is the install itself. + Cli, + /// A vendor's agent harness — [`Harness`] crossed with [`Event`]. + Harness, + /// The git hook surface, runner-agnostic: hk today, but lefthook, husky or a + /// bare `core.hooksPath` are the same surface. + Git, + /// The forge, read by ETag-conditional polling. Not a process hook. + Ci, +} + +/// What batten does about one name in a [`HookSource`]'s vocabulary. +/// +/// Named `HookDisposition`, never bare `Disposition`: +/// `findings::Disposition` and `selfwrite::Disposition` are public already. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookDisposition { + /// Batten is registered for it; `check` is the declared verb that checks the + /// registration on a live checkout. + Registered { + /// A `surface::SURFACE` path. + check: &'static str, + }, + /// Deliberately not registered; `reason` is a stable kebab id. + Unsupported { + /// Why, as a kebab id rather than prose. + reason: &'static str, + }, +} + +#[rustfmt::skip] +const BY_GATE: HookDisposition = HookDisposition::Registered { check: "doctor gate" }; +const BY_HOOKS: HookDisposition = HookDisposition::Registered { + check: "doctor hooks", +}; +const BY_MEDIATOR: HookDisposition = HookDisposition::Registered { + check: "doctor mediator", +}; +const BY_FORGE: HookDisposition = HookDisposition::Registered { + check: "doctor forge", +}; + +const fn unsupported(reason: &'static str) -> HookDisposition { + HookDisposition::Unsupported { reason } +} + +/// githooks(5) for git 2.43, one row per name, alphabetical. +/// +/// **A SNAPSHOT**: a hook a later git adds is not caught automatically. The two +/// registered rows must equal `doctor::COMMIT_HOOKS`, which stays the runtime +/// authority for which git hooks batten links. +/// +/// Every reason is a stable id: `after-the-fact` refuses nothing that has not +/// already happened (the [`Event::PostTool`] argument), `server-side` runs on a +/// remote, `adjudicated-elsewhere` is the push — decided at the mediated call and +/// by `land` — and `no-predicate` is a hook nothing here has a rule for yet. +/// `pre-merge-commit` stays `no-predicate`: fast-forward landing makes no merge +/// commits. Registering a further hook is per-source work, not this table's. +pub const GIT_HOOKS: &[(&str, HookDisposition)] = &[ + ("applypatch-msg", unsupported("no-predicate")), + ("commit-msg", BY_GATE), + ("fsmonitor-watchman", unsupported("not-a-gate")), + ("p4-changelist", unsupported("no-predicate")), + ("p4-post-changelist", unsupported("after-the-fact")), + ("p4-pre-submit", unsupported("no-predicate")), + ("p4-prepare-changelist", unsupported("no-predicate")), + ("post-applypatch", unsupported("after-the-fact")), + ("post-checkout", unsupported("after-the-fact")), + ("post-commit", unsupported("after-the-fact")), + ("post-index-change", unsupported("after-the-fact")), + ("post-merge", unsupported("after-the-fact")), + ("post-receive", unsupported("server-side")), + ("post-rewrite", unsupported("after-the-fact")), + ("post-update", unsupported("server-side")), + ("pre-applypatch", unsupported("no-predicate")), + ("pre-auto-gc", unsupported("no-predicate")), + ("pre-commit", BY_GATE), + ("pre-merge-commit", unsupported("no-predicate")), + ("pre-push", unsupported("adjudicated-elsewhere")), + ("pre-rebase", unsupported("no-predicate")), + ("pre-receive", unsupported("server-side")), + ("prepare-commit-msg", unsupported("no-predicate")), + ("proc-receive", unsupported("server-side")), + ("push-to-checkout", unsupported("server-side")), + ("reference-transaction", unsupported("no-predicate")), + ("sendemail-validate", unsupported("no-predicate")), + ("update", unsupported("server-side")), +]; + +/// What batten reads from the forge, and the two ingress shapes it refuses to be +/// (CLOUD-204: no server, and a webhook's silence is not success). +const CI_READS: &[(&str, HookDisposition)] = &[ + ("poll:pull-request", BY_FORGE), + ("poll:check-run", BY_FORGE), + ("poll:workflow-run", BY_FORGE), + ("webhook", unsupported("no-server")), + ("socket", unsupported("no-server")), +]; + +fn owned(table: &[(&str, HookDisposition)]) -> Vec<(String, HookDisposition)> { + table + .iter() + .map(|(name, d)| ((*name).to_owned(), *d)) + .collect() +} + +//MUTANT source-unenumerated|s@^ HookSource::Git,$@@|every_hook_source_is_enumerated_with_a_nonempty_vocabulary +//MUTANT git-vocabulary-emptied|s@^ HookSource::Git => owned(GIT_HOOKS),$@ HookSource::Git => Vec::new(),@|the_git_vocabulary_is_githooks5_with_one_disposition_each +//MUTANT check-verb-undeclared|s@^const BY_GATE: HookDisposition = HookDisposition::Registered { check: "doctor gate" };$@const BY_GATE: HookDisposition = HookDisposition::Registered { check: "doctor commit-gate" };@|every_registered_disposition_names_a_declared_verb +//MUTANT harness-registration-ignored|s@^ } else if registered.contains(event) {$@ } else if false {@|the_harness_vocabulary_agrees_with_wiring_registrations +//MUTANT commit-msg-demoted|s@^ ("commit-msg", BY_GATE),$@ ("commit-msg", unsupported("no-predicate")),@|the_git_vocabulary_registers_exactly_the_commit_hooks +impl HookSource { + /// Every source. The `match` in [`HookSource::vocabulary`] is exhaustive, so + /// a fifth source does not compile until it declares a vocabulary. + pub const ALL: &'static [HookSource] = &[ + HookSource::Cli, + HookSource::Harness, + HookSource::Git, + HookSource::Ci, + ]; + + /// The stable token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + HookSource::Cli => "cli", + HookSource::Harness => "harness", + HookSource::Git => "git", + HookSource::Ci => "ci", + } + } + + /// Every name this source defines, each with its one disposition. + /// + /// The harness half is DERIVED from [`Wiring::registrations`], never + /// restated; the CLI half is every `surface::SURFACE` path as declared. + #[must_use] + pub fn vocabulary(self) -> Vec<(String, HookDisposition)> { + match self { + HookSource::Cli => crate::surface::SURFACE + .iter() + .map(|row| (row.path.to_owned(), BY_MEDIATOR)) + .collect(), + HookSource::Harness => harness_vocabulary(), + HookSource::Git => owned(GIT_HOOKS), + HookSource::Ci => owned(CI_READS), + } + } +} + +/// `:` for every harness and every event that is a moment. +fn harness_vocabulary() -> Vec<(String, HookDisposition)> { + let mut entries = Vec::new(); + for harness in Harness::ALL { + let wiring = harness.wiring(); + let registered: Vec = wiring + .as_ref() + .map(|w| { + w.registrations(*harness) + .into_iter() + .map(|(event, _)| event) + .collect() + }) + .unwrap_or_default(); + for event in Event::ALL + .iter() + .filter(|event| **event != Event::Unrecognized) + { + let disposition = if wiring.is_none() { + unsupported("no-wiring-surface") + } else if registered.contains(event) { + BY_HOOKS + } else { + unsupported("not-wired") + }; + entries.push(( + format!("{}:{}", harness.as_str(), event.as_str()), + disposition, + )); + } + } + entries +} + /// The lifecycle events the core normalizes, whatever a host spells them. /// /// A vocabulary enum with a `const ALL`, the shape [`Harness`], diff --git a/crates/batten/tests/it/hook_sources.rs b/crates/batten/tests/it/hook_sources.rs new file mode 100644 index 000000000..763917f4b --- /dev/null +++ b/crates/batten/tests/it/hook_sources.rs @@ -0,0 +1,163 @@ +//! CLOUD-1728: the hook surface is four sources, each with a declared vocabulary. +//! +//! Every case reads through `HookSource::ALL` and `HookSource::.vocabulary()`, +//! never the tables behind them: a case reading `GIT_HOOKS` directly would +//! survive a mutation of the `vocabulary()` arm, which is the accessor every +//! consumer reaches. + +use std::collections::BTreeSet; + +use batten::hook::{Event, Harness, HookDisposition, HookSource}; +use batten::surface::SURFACE; + +fn is_kebab_id(reason: &str) -> bool { + !reason.is_empty() + && reason + .split('-') + .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_lowercase())) +} + +#[test] +fn every_hook_source_is_enumerated_with_a_nonempty_vocabulary() { + let tokens: BTreeSet<&str> = HookSource::ALL.iter().map(|s| s.as_str()).collect(); + assert_eq!( + tokens, + BTreeSet::from(["cli", "harness", "git", "ci"]), + "the four sources are the owner's split; a missing one reports clean today" + ); + assert_eq!(HookSource::ALL.len(), 4, "no source is listed twice"); + for source in HookSource::ALL { + let vocabulary = source.vocabulary(); + assert!( + !vocabulary.is_empty(), + "{} declares no vocabulary", + source.as_str() + ); + let names: BTreeSet<&str> = vocabulary.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!( + names.len(), + vocabulary.len(), + "{} names a hook twice", + source.as_str() + ); + } +} + +/// githooks(5) as git 2.43 defines it. A SNAPSHOT: a hook a later git adds is +/// not caught automatically — this set is what was reproduced against the +/// installed binary when the table was written, nothing more. +const GITHOOKS_5: [&str; 28] = [ + "applypatch-msg", + "commit-msg", + "fsmonitor-watchman", + "p4-changelist", + "p4-post-changelist", + "p4-pre-submit", + "p4-prepare-changelist", + "post-applypatch", + "post-checkout", + "post-commit", + "post-index-change", + "post-merge", + "post-receive", + "post-rewrite", + "post-update", + "pre-applypatch", + "pre-auto-gc", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "pre-receive", + "prepare-commit-msg", + "proc-receive", + "push-to-checkout", + "reference-transaction", + "sendemail-validate", + "update", +]; + +#[test] +fn the_git_vocabulary_is_githooks5_with_one_disposition_each() { + let vocabulary = HookSource::Git.vocabulary(); + let names: Vec<&str> = vocabulary.iter().map(|(n, _)| n.as_str()).collect(); + let unique: BTreeSet<&str> = names.iter().copied().collect(); + assert_eq!(unique.len(), names.len(), "a git hook named twice"); + assert_eq!(unique, BTreeSet::from(GITHOOKS_5)); + for (name, disposition) in &vocabulary { + if let HookDisposition::Unsupported { reason } = disposition { + assert!( + is_kebab_id(reason), + "{name}: reason {reason:?} is not a kebab id" + ); + } + } +} + +#[test] +fn every_registered_disposition_names_a_declared_verb() { + let verbs: BTreeSet<&str> = SURFACE.iter().map(|row| row.path).collect(); + let mut registered = 0; + for source in HookSource::ALL { + for (name, disposition) in source.vocabulary() { + match disposition { + HookDisposition::Registered { check } => { + registered += 1; + assert!( + verbs.contains(check), + "{}:{name} is checked by {check:?}, which no surface row declares", + source.as_str() + ); + } + HookDisposition::Unsupported { reason } => { + assert!(is_kebab_id(reason), "{name}: {reason:?}"); + } + } + } + } + assert!( + registered > 0, + "a census with nothing registered checks nothing" + ); +} + +#[test] +fn the_harness_vocabulary_agrees_with_wiring_registrations() { + let vocabulary = HookSource::Harness.vocabulary(); + let moments = Event::ALL + .iter() + .filter(|event| **event != Event::Unrecognized) + .count(); + assert_eq!(vocabulary.len(), Harness::ALL.len() * moments); + for harness in Harness::ALL { + let prefix = format!("{}:", harness.as_str()); + let own: Vec<(&str, HookDisposition)> = vocabulary + .iter() + .filter_map(|(name, d)| name.strip_prefix(&prefix).map(|event| (event, *d))) + .collect(); + assert_eq!(own.len(), moments, "{prefix} is not one entry per moment"); + match harness.wiring() { + None => assert!( + own.iter().all(|(_, d)| *d + == HookDisposition::Unsupported { + reason: "no-wiring-surface" + }), + "{prefix} has no wiring surface, so nothing on it is registered" + ), + Some(wiring) => { + let want: BTreeSet<&str> = wiring + .registrations(*harness) + .into_iter() + .map(|(event, _)| event.as_str()) + .collect(); + let got: BTreeSet<&str> = own + .iter() + .filter(|(_, d)| matches!(d, HookDisposition::Registered { .. })) + .map(|(event, _)| *event) + .collect(); + assert!(!want.is_empty(), "{prefix} registers nothing"); + assert_eq!(got, want, "{prefix} disagrees with its wiring"); + } + } + } +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 21f475611..9e363d379 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -222,6 +222,7 @@ mod hook_cost; mod hook_pin_check; mod hook_profile; mod hook_skip_local; +mod hook_sources; mod hook_worktree_root; mod identity_churn; mod identity_precedence; diff --git a/mise.toml b/mise.toml index 92d226a59..66025cc2c 100644 --- a/mise.toml +++ b/mise.toml @@ -647,7 +647,7 @@ CI_VERDICT_STEPS = "Run mise run ,Run mise exec -- " # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs" +MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract" # The file inline tasks are declared in, for `mutate`'s `task-` route (CLOUD-1909). # The crate may not spell a consumer's filename (non-negotiable rule 1), so the # manifest is named here, beside the set it serves. Unset, a `task-` gate resolves From c4bbde0cfa451c9f61cf7e1ab014def51189d24c Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 12:37:14 +0000 Subject: [PATCH 02/22] fix(refusal)!: bind the pointers a mediated refusal prints (CLOUD-1826) A reader pastes the refusal line into `override request --subject`, and the binding did not match it: `path write refused` printed `batten.toml Write` and bound `batten.toml`; `history drop unpushed` printed `1 ` and bound ``; a refusal naming nothing bound the raw class token, a spelling no request could store. `verdict::bound_subject` is now the one spelling function, and `refusal::admission_bindings` binds (a) the printed pointers through it, (b) the first path where it differs, (c) the class token in request spelling when there are no subjects. `admit_mediated` asks the store about each. `subject_as_bound` delegates with its output unchanged, so the tree surface's `finding.path` admissions do not move. BREAKING CHANGE: `Refusal::subject() -> Option<&str>` is replaced by `Refusal::bindings() -> &[String]`. Refs: CLOUD-1826 --- .serena/memories/workflow/landing-loop.md | 3 +- crates/batten/src/admission.rs | 19 +-- crates/batten/src/hook.rs | 88 +++++++------ crates/batten/src/lib.rs | 51 +++----- crates/batten/src/refusal.rs | 129 ++++++++----------- crates/batten/src/rules.rs | 14 +- crates/batten/src/verdict.rs | 33 +++++ crates/batten/tests/it/admission.rs | 3 + crates/batten/tests/it/common/mod.rs | 20 +++ crates/batten/tests/it/history_drop.rs | 31 ++--- crates/batten/tests/it/mediated_admission.rs | 33 +++++ crates/batten/tests/it/punt_receipt.rs | 18 --- 12 files changed, 234 insertions(+), 208 deletions(-) diff --git a/.serena/memories/workflow/landing-loop.md b/.serena/memories/workflow/landing-loop.md index 8cc082e35..acd65bd00 100644 --- a/.serena/memories/workflow/landing-loop.md +++ b/.serena/memories/workflow/landing-loop.md @@ -190,7 +190,8 @@ exist in no other clone, so `history-drop` reads the reset as discarding work an stops it. Measured 2026-09-19, and the class's own override did not open it: a `history drop unpushed` admission was requested, answered and spent TWICE — once against the commit sha, once against the subject its refusal line prints — and -the reset was refused unchanged after each. CLOUD-1871 owns that gap. The rebase +the reset was refused unchanged after each. Fixed by CLOUD-1826: paste the +pointers the refusal prints (`1 `), not the sha alone. The rebase went through on the first try. Take the second anyway, on the rare tree where the guard lets it, and two things diff --git a/crates/batten/src/admission.rs b/crates/batten/src/admission.rs index 572898ffe..4dcabf8b6 100644 --- a/crates/batten/src/admission.rs +++ b/crates/batten/src/admission.rs @@ -786,21 +786,14 @@ pub fn consume( /// The subject a caller typed, in the spelling an admission binds (CLOUD-1997). /// -/// A refusal whose subjects are artifacts binds them joined by `,` -/// (`refusal::admission_subject`), while its pointer line prints them joined by -/// a space — so `turn mint ahead` refuses as `… verify commit …` and binds -/// `verify,commit`. A reader copies the line; five admissions spent that way -/// were honoured by nothing. So a subject carrying whitespace and no path -/// separator is read as the artifact list the line printed, and rejoined. -/// A path subject is returned untouched: a path is bound as itself. +/// [`crate::verdict::bound_subject`] is the one spelling function, and the +/// binding side (`refusal::admission_bindings`) spells what a refusal prints +/// through it too (CLOUD-1826) — so a subject pasted off the line binds as the +/// refusal does. Its `/` exception exists for the tree surface, whose admission +/// is matched against `finding.path` verbatim. #[must_use] -//MUTANT rendered-subject-unmatched|s@^ trimmed.split_whitespace().collect::>().join(",")$@ trimmed.to_owned()@|a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does pub fn subject_as_bound(subject: &str) -> String { - let trimmed = subject.trim(); - if trimmed.contains('/') || !trimmed.contains(char::is_whitespace) { - return trimmed.to_owned(); - } - trimmed.split_whitespace().collect::>().join(",") + crate::verdict::bound_subject(subject) } /// The five fields a caller can know without holding the record. diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 3fc8a6753..6fafd0ad4 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -14012,10 +14012,10 @@ deny contains "refused by themodule" if { /// The subset of those the ENGINE ITSELF raises at the mediated boundary. /// /// **The surface is the whole distinction, and the gate's first run taught it.** - /// `Refusal::subject()` has exactly one consumer, `admit_mediated`, so the + /// `Refusal::bindings()` has exactly one consumer, `admit_mediated`, so the /// binding is what a MEDIATED refusal needs. A tree-scoped class is admitted /// through `apply_admissions`, which is anchored by the finding's fingerprint - /// and keyed on the finding's own path — `subject()` never enters it. The + /// and keyed on the finding's own path — `bindings()` never enters it. The /// landing-loop preset's `head grade twice` is one of those: its module emits /// `subjects: [{"artifact": sha}]` and it is admitted through the finding path, /// which is why demanding a boundary binding of it would fail a class that is @@ -14072,22 +14072,15 @@ deny contains "refused by themodule" if { ) } - /// A class that advertises an override must carry something to bind it to - /// (CLOUD-1871). + /// A class that advertises an override must bind the spelling it PRINTS + /// (CLOUD-1871, CLOUD-1826). /// - /// a declared route is the only way through for any class - /// declaring an override route with a precondition, on the stated ground that - /// such a class "already has a way through that leaves a record … and - /// `admit_mediated` honours the spent admission". `admit_mediated` returns - /// early unless the refusal carries a subject. So the two must agree, and that - /// function's own doc says what it costs when they do not: *"a disagreement - /// here would mean a class the hatch stopped opening and no admission could - /// open either, which is the wall in its worst form."* - /// - /// They disagreed. `history drop unpushed` names a count and per-commit - /// artifacts; `receipt read other` names artifacts and deliberately no path. - /// Both were unadmittable and unbypassable — measured by two admissions spent - /// against a `git reset --hard` that refused unchanged after each. + /// A declared override route is the only way through such a class, and + /// `admit_mediated` asks the store about `bindings()` alone. The first binding + /// must be the printed pointers in request spelling, because a reader pastes + /// the line: the protected sample `rm .serena/memories/core.md` prints + /// `.serena/memories/core.md rm`, and binding the bare path left that paste + /// honoured by nothing. #[test] fn every_class_declaring_an_override_route_can_be_bound() { let overridable = mediated_classes_with_an_override_route(); @@ -14103,43 +14096,49 @@ deny contains "refused by themodule" if { Some(class.as_str()), "a sample must carry the class it stands for" ); - assert!( - refusal.subject().is_some(), - "{class} declares an override route, which takes the hatch away, and \ - its refusal carries nothing an admission can bind — so the class has \ - no way through at all: {}", + let printed = refusal + .reason() + .strip_prefix(class.as_str()) + .expect("a declared reason leads with its token") + .trim(); + let want = + crate::verdict::bound_subject(if printed.is_empty() { class } else { printed }); + assert_eq!( + refusal.bindings().first(), + Some(&want), + "{class} must bind the pointers its line prints: {}", refusal.render() ); } } - /// The anti-vacuity arm, and it is load-bearing (CLOUD-1871). + /// A refusal naming nothing binds its class in the spelling a request + /// stores (CLOUD-1826). /// - /// Without it the case above is satisfied by "every refusal names a subject", - /// which is a different and FALSE claim. A class that keeps its hatch has a - /// way through already and owes no binding: `call count over` names two counts - /// because two numbers are what a reader acts on, and a count is not an - /// identity — binding one would let an admission for "1 commit" fit a - /// different single commit. + /// The raw token `call name refused` was a binding no `override request` + /// could produce, since the request rejoins whitespace with `,`. The ceiling + /// class keeps its hatch and declares no override route, which is why a + /// count-only binding there stays inert. #[test] - fn a_class_that_keeps_its_hatch_owes_no_binding() { - let ceiling = ceiling_refusal(&ceiling_row("c", "Task", 10), 11, 10); + fn a_refusal_naming_nothing_binds_its_class_as_a_request_spells_it() { assert!( !mediated_classes_with_an_override_route() .iter() .any(|class| class == crate::verdict::Native::CeilingExceeded.id()), - "this arm is about a class that keeps its hatch; if the ceiling class \ - gained an override route it belongs in the case above instead" + "the ceiling class keeps its hatch; if it gained an override route its \ + count binding would no longer be inert" ); - assert!( - ceiling.subject().is_none(), - "a count is not an identity, so it must not become a binding an \ - admission could harvest: {}", - ceiling.render() + let refusal = shape_refusal(&shape("r", "x", None)); + assert_eq!( + refusal.bindings(), + [crate::admission::subject_as_bound("call name refused")], + "{}", + refusal.render() ); + assert_eq!(refusal.bindings(), ["call,name,refused"]); } - /// Every artifact the refusal names travels in the binding (CLOUD-1871). + /// Every pointer the refusal prints travels in the binding (CLOUD-1871). /// /// Binding one of several would let an admission earned for one commit admit a /// later reset discarding that commit AND another — @@ -14149,15 +14148,14 @@ deny contains "refused by themodule" if { fn a_refusal_naming_several_artifacts_binds_all_of_them() { let one = history_drop_refusal(&["aaaaaaa".to_owned()]); let two = history_drop_refusal(&["aaaaaaa".to_owned(), "bbbbbbb".to_owned()]); - assert_eq!(one.subject(), Some("aaaaaaa")); + assert_eq!(one.bindings(), ["1,aaaaaaa"]); assert_eq!( - two.subject(), - Some("aaaaaaa,bbbbbbb"), + two.bindings(), + ["2,aaaaaaa,bbbbbbb"], "every commit the refusal names travels in the binding" ); - assert_ne!( - one.subject(), - two.subject(), + assert!( + one.bindings().iter().all(|b| !two.bindings().contains(b)), "an admission for one commit must not fit a reset discarding two" ); } diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 402fdaa20..70288c5a5 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -16758,27 +16758,10 @@ fn admit_mediated(decision: hook::Decision, out: &mut dyn Write) -> Result Result, + bindings: Vec, } -/// The subject an admission binds to, for a refusal naming `subjects`. +/// Every spelling a mediated refusal of `token` naming `subjects` binds, in +/// order (CLOUD-1826). /// -/// A path first, and every artifact when there is no path (CLOUD-1871). +/// - **(a) The printed spelling, always first** — +/// [`crate::verdict::bound_subject`] of exactly the pointers +/// [`crate::verdict::render_line`] prints, counts included. A reader pastes +/// the line into `override request --subject`, which spells through the same +/// function, so the pasted line binds as the refusal does. +/// - **(b) The first path**, when it differs from (a): the `--subject ` +/// spelling the protected-path route documents. +/// - **(c) The class token in bound spelling**, only when there are no subjects: +/// `call name refused` binds `call,name,refused`, which is exactly what a +/// request for that subject stores. The raw token it replaces was a spelling no +/// request could produce. /// -/// # A path-only answer made two classes unadmittable +/// Every entry is a fixed point of `bound_subject`, so any spelling the boundary +/// asks about is one a request can store. /// -/// This used to return the first path-bearing subject and `None` for anything -/// else, on the reasoning that "an artifact is not a path, so an admission bound -/// to it would name something the store cannot compare against the tree". That -/// is true of a TREE finding, which is anchored by fingerprint and whose subject -/// is compared against the tree — and it is the wrong question for a MEDIATED -/// refusal, which [`crate::admission::Anchor::Call`] already anchors to a head. -/// There the subject is simply what the refusal named, and a commit id or a -/// check name is a perfectly comparable binding. +/// # A count binds only inside the whole printed spelling /// -/// The cost of the narrower answer was not theoretical. `admit_mediated` returns -/// early with no subject, so `history drop unpushed` (a `Count` and one -/// `Artifact` per commit) and `receipt read other` (artifacts, its subject -/// deliberately unnamed to keep payload out of a refusal) could never be -/// admitted — while both declare an `override` route, which makes -/// leave their declared routes as the only way through. -/// No hatch and no admission is the state that function's own doc calls "the -/// wall in its worst form" and asserts cannot happen. It had happened, to two of -/// the three classes that declare such a route; `path write refused` escaped -/// only because it leads with a path. +/// So an admission for `1 ` cannot fit `2 ` — the harvest hole +/// CLOUD-1871 closed stays closed. A count-only class with no override route +/// stays inert, because `override request` refuses such a class. /// -/// # EVERY artifact, not the first +/// # Why not `rules::first_pointer` /// -/// The join is what keeps the binding honest. One artifact out of several would -/// let an admission earned for commit A admit a later reset discarding A **and** -/// B — the harvesting hole `an_admission_bound_to_another_subject_is_refused` -/// exists to close, reopened one variant over. Rendered order, so the value is -/// byte-stable for a given refusal. +/// That is the TREE pointer: one path slot, anchored by fingerprint. Forcing it +/// to equal this binding would either widen tree output or drop pointers here. /// -/// # A count is not an identity -/// -/// Counts stay out: they are derivable from what the refusal already names, and -/// binding one would make an admission for "1 commit" fit a different single -/// commit. A class whose subjects are ONLY counts therefore still has no -/// binding — which is not silently tolerated: `every_admissible_class_can_be_ -/// bound` refuses a class that declares an override route and cannot produce one. -/// -/// Rule 4 is untouched either way. This binds only what the refusal already -/// renders; it puts no new byte on any channel, and `subject` is -/// `skip_serializing`, so `-J` output and `schema/*.json` do not move. +/// Rule 4 is untouched: this binds only what the refusal already renders, and +/// `bindings` is `skip_serializing`, so `-J` output and `schema/*.json` do not +/// move. //MUTANT-SUITE crates/batten/src/hook.rs -//MUTANT artifact-binding-dropped|s@^ (!artifacts.is_empty()).then(\x7c\x7c artifacts.join(","))@ None@|every_class_declaring_an_override_route_can_be_bound -fn admission_subject(subjects: &[crate::verdict::Subject]) -> Option { - if let Some(path) = subjects.iter().find_map(|subject| match subject { - crate::verdict::Subject::Path { path } | crate::verdict::Subject::Line { path, .. } => { - Some(path.clone()) - } - crate::verdict::Subject::Count { .. } | crate::verdict::Subject::Artifact { .. } => None, - }) { - return Some(path); - } - let artifacts: Vec<&str> = subjects - .iter() - .filter_map(|subject| match subject { - crate::verdict::Subject::Artifact { artifact } => Some(artifact.as_str()), - crate::verdict::Subject::Count { .. } - | crate::verdict::Subject::Path { .. } - | crate::verdict::Subject::Line { .. } => None, - }) - .collect(); - // The restored defect, exactly: a path-only answer, which is what left two - // classes with an override route nobody could reach. - (!artifacts.is_empty()).then(|| artifacts.join(",")) +//MUTANT printed-spelling-dropped|s@^ let mut spellings = vec!\[bound_subject(&render_subjects(subjects))\];$@ let mut spellings: Vec = Vec::new();@|a_subject_copied_from_the_refusal_line_admits_the_write +//MUTANT path-spelling-dropped|s@^ let path = subjects.iter().find_map(crate::verdict::Subject::path).map(bound_subject);$@ let path: Option = None;@|a_spent_admission_admits_the_write_it_was_taken_for +//MUTANT class-spelling-raw|s@^ return vec!\[bound_subject(token)\];$@ return vec![token.to_owned()];@|a_refusal_naming_nothing_binds_its_class_as_a_request_spells_it +fn admission_bindings(token: &str, subjects: &[crate::verdict::Subject]) -> Vec { + use crate::verdict::{bound_subject, render_subjects}; + if subjects.is_empty() { + return vec![bound_subject(token)]; + } + let mut spellings = vec![bound_subject(&render_subjects(subjects))]; + // One line, because `path-spelling-dropped` anchors on it whole. + #[rustfmt::skip] + let path = subjects.iter().find_map(crate::verdict::Subject::path).map(bound_subject); + spellings.extend(path.filter(|path| !spellings.contains(path))); + spellings } /// What [`Fix::None`] renders as: the gap, stated, plus the general recourse. @@ -491,9 +467,9 @@ impl Refusal { reason: reason.into(), fix, // A consumer-composed refusal carries no declared class, so there is - // no token an admission could bind (`rules.rs`'s own words) — and a - // subject with nothing to bind it to would read as admissible. - subject: None, + // nothing a request could name — and a binding with no class behind + // it would read as admissible. + bindings: Vec::new(), } } @@ -556,14 +532,15 @@ impl Refusal { verdict: Some(token.to_owned()), reason: crate::verdict::render_line(registry, token, subjects), fix, - subject: admission_subject(subjects), + bindings: admission_bindings(token, subjects), } } - /// The canonical subject an admission binds to, when this refusal names one. + /// Every spelling a mediated admission binds to, printed spelling first + /// ([`admission_bindings`]). Empty only for a consumer-composed refusal. #[must_use] - pub fn subject(&self) -> Option<&str> { - self.subject.as_deref() + pub fn bindings(&self) -> &[String] { + &self.bindings } /// The declared class, or `None` for a refusal composed from consumer prose. diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index a3e434dcb..099b3e8b4 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -10186,9 +10186,9 @@ fn policy_rule( // left the reader to find the file themselves. A `subjects` entry IS // a pointer, which is the whole reason the field is tagged rather // than free, so the first path-bearing one is what the finding - // carries. A class whose subjects are counts or artifacts still - // falls back to the bundle root, which is the honest pointer when - // the finding is about a set rather than a file. + // carries. A path-less list carries its first rendered subject + // (`first_pointer`); the bundle root is reached only for an empty + // list, where the finding has nothing narrower to point at. path: pointer.clone().unwrap_or_else(|| { rule.bundle .clone() @@ -10293,6 +10293,14 @@ fn policy_remediation( /// [`crate::verdict::Subject::render`]'s, so a count says the same thing here as /// it does on the mediated path, and a reader cannot mistake `2 file(s)` for a /// file they could open. +/// +/// # The tree pointer, deliberately not the mediated binding +/// +/// This is the tree finding's pointer and its admission subject, anchored by +/// fingerprint. A mediated refusal binds every pointer it prints instead +/// (`refusal::admission_bindings`, CLOUD-1826): a tree finding has one `path` +/// slot, and forcing the two to agree would widen tree output or drop pointers +/// from the mediated binding. fn first_pointer(subjects: &[crate::verdict::Subject]) -> (Option, Option) { for subject in subjects { match subject { diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 28713b6ae..44e454d10 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -425,6 +425,15 @@ impl Subject { } } + /// The path this subject names, for a `Path` or a `Line`; `None` otherwise. + #[must_use] + pub fn path(&self) -> Option<&str> { + match self { + Subject::Path { path } | Subject::Line { path, .. } => Some(path), + Subject::Count { .. } | Subject::Artifact { .. } => None, + } + } + /// Read one subject off a policy module's `subjects` array. /// /// `None` is could-not-look, never an empty subject: a member whose shape @@ -476,6 +485,30 @@ pub fn render_subjects(subjects: &[Subject]) -> String { .join(" ") } +/// The spelling an admission binds a subject in — the one function both sides +/// of the mediated surface spell through (CLOUD-1826, CLOUD-1997). +/// +/// A refusal PRINTS its pointers joined by a space ([`render_subjects`]); a +/// reader copies that line into `override request --subject`. So whitespace is +/// rejoined with `,`, and the binding side (`refusal::admission_bindings`) runs +/// what it prints through this same function — which is what makes a pasted line +/// bind as the refusal does. +/// +/// **The `/` exception exists for the TREE surface**: a tree admission is matched +/// against `finding.path` verbatim, and a path like `docs/a b.md` rejoined would +/// name nothing any finding carries. Idempotent: a `/`-bearing text comes back +/// trimmed, and any other text has no whitespace left after the join. +//MUTANT rendered-subject-unmatched|s@^ trimmed.split_whitespace().collect::>().join(",")$@ trimmed.to_owned()@|a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does +//MUTANT path-subject-split|s@^ if trimmed.contains('/') {$@ if false {@|a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does +#[must_use] +pub fn bound_subject(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.contains('/') { + return trimmed.to_owned(); + } + trimmed.split_whitespace().collect::>().join(",") +} + /// What a refusal says on the hot path: the token and its pointers, and stops /// (CLOUD-1053, narrowed by CLOUD-1286). /// diff --git a/crates/batten/tests/it/admission.rs b/crates/batten/tests/it/admission.rs index 0ea6699ab..9527552e8 100644 --- a/crates/batten/tests/it/admission.rs +++ b/crates/batten/tests/it/admission.rs @@ -167,6 +167,9 @@ fn a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does() { admission::subject_as_bound("policy/agent-spawn.rego"), "policy/agent-spawn.rego" ); + // The tree surface matches `finding.path` verbatim, so a path with a space + // must not be rejoined into a name no finding carries (CLOUD-1826). + assert_eq!(admission::subject_as_bound("docs/a b.md"), "docs/a b.md"); let root = fixture("copied-subject"); let issued = admission::issue( &root, diff --git a/crates/batten/tests/it/common/mod.rs b/crates/batten/tests/it/common/mod.rs index c3147f2cc..2aee62433 100644 --- a/crates/batten/tests/it/common/mod.rs +++ b/crates/batten/tests/it/common/mod.rs @@ -149,6 +149,26 @@ fn scan_declared_patterns() -> String { rows } +/// The pointers a refusal of `class` printed, between the class and the `rule` +/// that refused, exactly as a reader would copy them — trimmed, NOT rejoined +/// (CLOUD-1826). +/// +/// `said` is a run's stdout followed by its stderr. The one place a binary case +/// reads the refusal line's grammar, so a change to that grammar re-points this. +/// +/// # Panics +/// +/// When no line carries the class, naming what was said. +pub(crate) fn printed_pointers(said: &str, class: &str, rule: &str) -> String { + said.lines() + .find_map(|line| { + let rest = line.split(&format!("{class} ")).nth(1)?; + let pointers = rest.split(&format!(" {rule}")).next()?; + Some(pointers.trim().to_owned()) + }) + .unwrap_or_else(|| panic!("no line refuses as `{class}`: {said}")) +} + pub(crate) fn at_root(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/crates/batten/tests/it/history_drop.rs b/crates/batten/tests/it/history_drop.rs index 69283c5ca..80f7a87d7 100644 --- a/crates/batten/tests/it/history_drop.rs +++ b/crates/batten/tests/it/history_drop.rs @@ -196,20 +196,6 @@ fn the_refusal_carries_a_count_and_shas_and_no_content() { /// The rule id, beside [`CLASS`], for the admission cases below. const RULE: &str = "history-drop"; -/// Every short sha the refusal names, in the order it named them. -/// -/// **Read off the rendered refusal rather than computed from the fixture**, and -/// that is the point: it is what an AGENT can do. The binding is the commits the -/// refusal listed, so a case that recomputed them with `git rev-parse` could pass -/// while the only spelling a reader has access to did not fit. -fn shas_in(cause: &str) -> Vec { - cause - .split_whitespace() - .filter(|token| token.len() >= 7 && token.chars().all(|char| char.is_ascii_hexdigit())) - .map(str::to_owned) - .collect() -} - /// Answer the class's three declared questions and return the issued address. fn request(dir: &Path, subject: &str) -> String { let answers = "precondition=the commits are a duplicate of what is already pushed, \ @@ -273,19 +259,23 @@ fn spend(dir: &Path, admission: &str, subject: &str) -> bool { /// `admit_mediated` returned early on a refusal naming a count and commits, and /// the class had no way through at all. Measured before the fix by two admissions /// spent against a reset that refused unchanged after each. +/// +/// **The subject is pasted off the rendered refusal**, which is what an AGENT can +/// do: `1 `, count included. The binding used to drop the count, so the +/// paste bound `1,` against `` and admitted nothing (CLOUD-1826). #[test] -fn a_spent_admission_admits_the_reset_it_was_taken_for() { +fn a_subject_copied_from_the_refusal_line_admits_the_reset() { let dir = fixture("history-drop-admits"); unpushed(&dir, "local.txt"); let (code, cause) = adjudicate(&dir, "git reset --hard HEAD~1"); assert_eq!(code, Some(2), "the premise\n{cause}"); - let subject = shas_in(&cause).join(","); + let subject = common::printed_pointers(&cause, CLASS, RULE); assert!( - !subject.is_empty(), - "the refusal must name the commits it is about, or an asker has no \ - subject to bind\n{cause}" + subject.starts_with("1 "), + "the refusal must name a count and the commit it is about, or an asker \ + has no subject to bind\n{cause}" ); let admission = request(&dir, &subject); @@ -311,7 +301,8 @@ fn an_admission_for_one_commit_does_not_admit_a_deeper_reset() { let (code, shallow) = adjudicate(&dir, "git reset --hard HEAD~1"); assert_eq!(code, Some(2), "the premise\n{shallow}"); - let one = shas_in(&shallow).join(","); + let one = common::printed_pointers(&shallow, CLASS, RULE); + assert!(one.starts_with("1 "), "the premise\n{shallow}"); let admission = request(&dir, &one); assert!(spend(&dir, &admission, &one), "spend must consume it"); diff --git a/crates/batten/tests/it/mediated_admission.rs b/crates/batten/tests/it/mediated_admission.rs index 4ad15903a..c3ff83ffb 100644 --- a/crates/batten/tests/it/mediated_admission.rs +++ b/crates/batten/tests/it/mediated_admission.rs @@ -181,6 +181,39 @@ fn a_spent_admission_admits_the_write_it_was_taken_for() { ); } +/// The spelling a reader has is the LINE, so pasting its pointers must admit +/// (CLOUD-1826). The line prints `batten.toml Write`; the binding used to be the +/// bare path, so the paste bound `batten.toml,Write` and admitted nothing. +#[test] +fn a_subject_copied_from_the_refusal_line_admits_the_write() { + let dir = fixture("mediated-admission-pasted"); + let refused = run_with_stdin( + &dir, + &["adjudicate", "--harness", "exit-code"], + &write_payload(GUARDED), + ); + assert_eq!(refused.status.code(), Some(2), "the premise"); + let said = format!( + "{}{}", + String::from_utf8_lossy(&refused.stdout), + String::from_utf8_lossy(&refused.stderr) + ); + let pasted = common::printed_pointers(&said, CLASS, RULE); + assert!( + pasted.contains(' '), + "the line must print more than one pointer, or this case cannot tell the \ + printed spelling from the bare path: {pasted:?}" + ); + + let admission = request(&dir, &pasted, "pasted straight off the refusal line"); + assert!(spend(&dir, &admission, &pasted), "spend must consume it"); + assert_eq!( + verdict(&dir, GUARDED), + Some(0), + "a subject copied off the refusal line must admit the write it refused" + ); +} + /// An ISSUED admission does not admit — only a spent one does. /// /// `admission.rs` calls this "the whole economy": a mint that suppressed on its diff --git a/crates/batten/tests/it/punt_receipt.rs b/crates/batten/tests/it/punt_receipt.rs index f04103a82..e05e3b546 100644 --- a/crates/batten/tests/it/punt_receipt.rs +++ b/crates/batten/tests/it/punt_receipt.rs @@ -419,24 +419,6 @@ fn override_as(dir: &Path, class: &str, verb: &[&str], stdin: &str) -> std::proc run_with_stdin(dir, &args, stdin) } -// CLOUD-1889's declared mutation, and why the row is in THIS file. -// -// `test name undefined` reads the declared file for a line carrying -// `MUTANT |`, and its `line_sources` cover `crates/batten/tests/**` and not -// `crates/batten/src/**` — so the row lives here although the expression it applies -// belongs to `lib.rs`'s `admit_mediated`. It reinstates the early return on a -// path-less refusal, which is the defect exactly. -// -// INERT UNDER THE SWEEP, as `rebase.rs` records for its own rows (CLOUD-1486): -// `mutate::apply` seds the file that DECLARED the row, so this row rewrites this -// file and never reaches the engine. The kill was demonstrated BY HAND at -// implementation — the expression applied to `lib.rs`, the case below observed -// red, the file restored — and this paragraph is the only record of it. -/* -#MUTANT-SUITE crates/batten/tests/it/punt_receipt.rs -#MUTANT admission-not-honoured|s@ let subject = refusal.subject().unwrap_or(class);@ let Some(subject) = refusal.subject() else { return Ok(decision); };@|a_spent_admission_clears_a_superseded_receipt -*/ - #[test] fn a_spent_admission_clears_a_superseded_receipt() { // CLOUD-1889 — the half this file never had. The three cases above prove the From a875555485835b60dbc5cf44c2b9c74a97618983 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 12:40:51 +0000 Subject: [PATCH 03/22] fix(override): refuse a request whose subject no refusal of the rule binds (CLOUD-1996) `override request` issued an admission for any subject, so `verify` or the class token was issued, spent, reported `spent`, and admitted nothing. A receipt row's subjects depend on the row alone, so `hook::bindable_subjects` lists them by calling `receipt_refusal` itself and collecting `Refusal::bindings()`; a request outside that set is refused before stdin is read, naming the rule, the class, the subject and every bindable spelling. Absorbs CLOUD-1932, closed as its duplicate. Refs: CLOUD-1996 --- crates/batten/src/hook.rs | 68 ++++++++++++++++++++++++++ crates/batten/src/lib.rs | 19 +++++++ crates/batten/tests/it/punt_receipt.rs | 50 +++++++++++++++++++ 3 files changed, 137 insertions(+) diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 6fafd0ad4..5ec7c6b71 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -5880,6 +5880,49 @@ fn receipt_refusal( Refusal::declared(&rule.id, native, &subjects, fix) } +//MUTANT override-subject-unchecked|s@^ if rule.kind != RuleKind::Receipt {$@ if true {@|an_override_request_naming_a_subject_no_refusal_binds_is_refused +//MUTANT bindable-subjects-empty|s@^ Some(bindable)$@ Some(Vec::new())@|a_spent_admission_clears_a_superseded_receipt +//MUTANT class-filter-dropped|s@^ if refusal.verdict() == Some(class) {$@ if true {@|a_receipt_rows_age_bound_binds_only_under_the_expiry_class +/// Every subject a refusal of `class` by this RECEIPT row binds, or `None` for +/// a row of any other kind (CLOUD-1996). +/// +/// A receipt row's subjects depend on the row alone — the check, the key kind, +/// and `max_age` under expiry — so the set can be listed from config by calling +/// [`receipt_refusal`] itself, which is what keeps this from being a second +/// authority over a receipt refusal's subject. Every other mediated kind takes +/// its subject from the call and cannot be listed here. +/// +/// `override request` refuses a subject outside this set, because an admission +/// bound to it would be issued, spent, and admit nothing. A future [`Validity`] +/// missing from the list below fails closed: its class gets an empty set, and a +/// request is refused naming zero subjects rather than issued silently. An +/// alternation refusal carries no class, so it contributes nothing. +#[must_use] +pub(crate) fn bindable_subjects(rule: &Rule, class: &str) -> Option> { + const NON_VALID: [Validity; 5] = [ + Validity::Missing, + Validity::Expired, + Validity::Refuted, + Validity::StaleHead, + Validity::StaleMain, + ]; + if rule.kind != RuleKind::Receipt { + return None; + } + let mut bindable: Vec = Vec::new(); + for check in rule.checks.iter().flatten() { + for validity in NON_VALID { + let refusal = receipt_refusal(rule, check, validity, None); + if refusal.verdict() == Some(class) { + bindable.extend_from_slice(refusal.bindings()); + } + } + } + bindable.sort_unstable(); + bindable.dedup(); + Some(bindable) +} + /// The id-free half of the pipeline verdict: which shape a command commits. /// /// Three causes rather than three rules, on [`receipt_refusal`]'s precedent — the @@ -14138,6 +14181,31 @@ deny contains "refused by themodule" if { assert_eq!(refusal.bindings(), ["call,name,refused"]); } + /// A receipt row's bindable subjects are listed per class, and its age + /// bound binds only under the expiry class it is the measure for + /// (CLOUD-1996). A row of any other kind has no listable set. + #[test] + fn a_receipt_rows_age_bound_binds_only_under_the_expiry_class() { + let mut rule = shape("r", "unused", None); + rule.kind = RuleKind::Receipt; + rule.checks = Some(vec!["verify".to_owned()]); + rule.max_age = Some(300); + assert_eq!( + bindable_subjects(&rule, "receipt read late"), + Some(vec!["verify,commit,300s".to_owned()]) + ); + assert_eq!( + bindable_subjects(&rule, "receipt read other"), + Some(vec!["verify,commit".to_owned()]) + ); + assert_eq!( + bindable_subjects(&rule, "receipt read missing"), + Some(vec!["verify,commit".to_owned()]) + ); + rule.kind = RuleKind::Shape; + assert_eq!(bindable_subjects(&rule, "receipt read other"), None); + } + /// Every pointer the refusal prints travels in the binding (CLOUD-1871). /// /// Binding one of several would let an admission earned for one commit admit a diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 70288c5a5..ea855ce5b 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8581,6 +8581,25 @@ fn run_override_request( resolved.id ))); }; + // A RECEIPT ROW'S SUBJECTS ARE LISTABLE FROM CONFIG, so a request naming one + // no refusal of that row binds is refused here rather than issued, spent and + // honoured by nothing (CLOUD-1996). `subject` is already in bound spelling. + if let Some(row) = config.rules.iter().find(|row| row.id == rule) + && let Some(bound) = hook::bindable_subjects(row, &resolved.id) + && !bound.iter().any(|spelling| spelling == subject) + { + let listed = if bound.is_empty() { + "none".to_owned() + } else { + bound.join(", ") + }; + return Err(error::UsageError::raise(format!( + "no `{rule}` refusal under `{}` binds subject `{subject}`, so an admission bound \ + to it would admit nothing; that rule binds {} subject(s) here: {listed}", + resolved.id, + bound.len() + ))); + } // BEFORE THE ARTICULATION IS ASKED FOR, never after it has been written // (CLOUD-1551). Resolving the anchor can now REFUSE — a rule that fired diff --git a/crates/batten/tests/it/punt_receipt.rs b/crates/batten/tests/it/punt_receipt.rs index e05e3b546..355cd85d8 100644 --- a/crates/batten/tests/it/punt_receipt.rs +++ b/crates/batten/tests/it/punt_receipt.rs @@ -419,6 +419,56 @@ fn override_as(dir: &Path, class: &str, verb: &[&str], stdin: &str) -> std::proc run_with_stdin(dir, &args, stdin) } +/// A subject no refusal of the row binds is refused at REQUEST, before an +/// admission is issued that would be spent and honoured by nothing (CLOUD-1996). +/// +/// The subjects are typed, not read off the line: `verify` and the class token +/// are the two wrong spellings measured spent and refused on real PRs. +#[test] +fn an_override_request_naming_a_subject_no_refusal_binds_is_refused() { + let dir = superseded("punt-unbindable-subject"); + for typed in ["verify", "receipt read other"] { + let requested = run_with_stdin( + &dir, + &[ + "override", + "request", + "--rule", + "turn mint ahead", + "--verdict", + "receipt read other", + "--subject", + typed, + ], + ANSWERS, + ); + let said = stderr(&requested); + assert_eq!(requested.status.code(), Some(1), "{typed}: {said}"); + assert!( + requested.stdout.is_empty(), + "{typed}: no address may be issued: {}", + String::from_utf8_lossy(&requested.stdout) + ); + for needle in [ + "`turn mint ahead`", + "`receipt read other`", + "1 subject(s)", + "verify,commit", + ] { + assert!( + said.contains(needle), + "{typed}: {needle} missing from {said}" + ); + } + if typed == "verify" { + assert!( + said.contains("`verify`"), + "the bound subject is named: {said}" + ); + } + } +} + #[test] fn a_spent_admission_clears_a_superseded_receipt() { // CLOUD-1889 — the half this file never had. The three cases above prove the From ac37b6807cb4c58ad4cc5afd4d04597e41fcc056 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 12:47:22 +0000 Subject: [PATCH 04/22] feat(verdict): admit a shape deny through a recorded override (CLOUD-1806) `call name refused` routed only to `read batten.toml`, back to the file the refusing row lives in, and declared no override, so nothing got past a shape deny without a committed config change. The class now routes to `batten policy rule ''`, the row's own remedy, plus `SHAPE_ADMIT_ROUTE`: every plain shape row is admissible through an admission bound to the row id at HEAD. `config read first` no longer routes this class. A plain shape row's refusal reads nothing from the call, so `bindable_subjects` lists its binding and `override request` refuses a subject it could never bind. The admission fixture's mediated row is keyed so it stays outside that population. Refs: CLOUD-1806 --- batten.toml | 14 +- crates/batten/src/hook.rs | 27 +++- crates/batten/src/refusal.rs | 12 +- crates/batten/src/verdict.rs | 18 ++- crates/batten/tests/it/admission.rs | 7 + crates/batten/tests/it/mediated_admission.rs | 134 ++++++++++++++++++- crates/batten/tests/it/refusal_ceiling.rs | 39 +++++- rules/toolchain.md | 8 +- 8 files changed, 234 insertions(+), 25 deletions(-) diff --git a/batten.toml b/batten.toml index a7444c96c..e292a6add 100644 --- a/batten.toml +++ b/batten.toml @@ -601,7 +601,8 @@ sha256 = "b2c822742e8cbf355ba0cb4cc690c3cd8fdc9ec1916c8148f27bd9098cb7aee4" # # `reason` is required on a shape rule, unlike on a file rule: a mediated deny # reaches a model as the entire explanation, so a refusal that named only its id -# would be un-actionable (CLOUD-122). The crate appends the bypass hatch. +# would be un-actionable (CLOUD-122). It is the row's remedy, which the refusal +# reaches through `batten policy rule ''`. # THESE FOUR WOULD DECLARE `bypass_env = "BATTEN_GH_GUARD_BYPASS"` AND DO NOT YET # (CLOUD-437, deferred to CLOUD-1027). They are the ported `gh-guard`, so that # name is TRUE of them where it was a fossil everywhere else, and declaring it @@ -616,12 +617,11 @@ sha256 = "b2c822742e8cbf355ba0cb4cc690c3cd8fdc9ec1916c8148f27bd9098cb7aee4" # one. Asserting it in the change that performs it is the exact shape §8 refuses. # # So until that row is groomed, these four declare no per-row hatch. That is a -# statement about the column, NOT a remedy: the residual global hook hatch still -# technically reaches them only because their class, `call name refused`, -# declares no override route yet (CLOUD-1806), and CLOUD-1357 makes every class -# that DOES declare one unsuppressible by it. A reader looking for the way past a -# refusal reads the class's routes with `batten policy explain ""` and, -# where one is an override, walks `batten override request`/`spend`. This comment +# statement about the column, NOT a remedy: their class, `call name refused`, +# declares an override route (CLOUD-1806). A reader looking for the way past a +# refusal reads the row's remedy with `batten policy rule ''`, and where that +# remedy cannot perform the change, walks `batten override request`/`spend` — +# the recorded way through, bound to the row id at the current commit. This comment # used to say these rows "take the general" hatch "like every other row", which an # agent read as advice and proposed for a class it could not have suppressed. [[rule]] diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 5ec7c6b71..0f0bdd458 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -4837,8 +4837,9 @@ fn command_line_gates(policy: &Policy, envelope: &Envelope, receipts: &ReceiptFa /// through `batten policy explain `, and the rule id through /// `batten policy rule `. The id is not redundant with the class — 66 /// `[[rule]]` rows declare no class of their own and raise their kind's native -/// one, so fourteen `shape` rows all raise `call name refused` and the id is the -/// only thing that says which fired. It stays on the repeat arm too, which is +/// one, so every plain `shape` row (eleven in this config when CLOUD-1806 +/// counted) raises `call name refused` and the id is the only thing that says +/// which fired. It stays on the repeat arm too, which is /// also what keeps the repeat a byte prefix of the first sighting. /// /// **What goes is the consumer `reason` reaching the line as [`Fix::Run`].** It @@ -5883,6 +5884,7 @@ fn receipt_refusal( //MUTANT override-subject-unchecked|s@^ if rule.kind != RuleKind::Receipt {$@ if true {@|an_override_request_naming_a_subject_no_refusal_binds_is_refused //MUTANT bindable-subjects-empty|s@^ Some(bindable)$@ Some(Vec::new())@|a_spent_admission_clears_a_superseded_receipt //MUTANT class-filter-dropped|s@^ if refusal.verdict() == Some(class) {$@ if true {@|a_receipt_rows_age_bound_binds_only_under_the_expiry_class +//MUTANT shape-subject-unchecked|s@^ if rule.kind == RuleKind::Shape && rule.max.is_none() && rule.requires_key.is_none() {$@ if false {@|a_shape_admission_for_an_unbindable_subject_is_refused /// Every subject a refusal of `class` by this RECEIPT row binds, or `None` for /// a row of any other kind (CLOUD-1996). /// @@ -5906,6 +5908,18 @@ pub(crate) fn bindable_subjects(rule: &Rule, class: &str) -> Option> Validity::StaleHead, Validity::StaleMain, ]; + // A PLAIN SHAPE ROW BINDS WHAT THE ROW ALONE DETERMINES (CLOUD-1806): + // `shape_refusal` reads nothing from the call, so the spelling a request + // must name is computable here exactly as a receipt row's is. + if rule.kind == RuleKind::Shape && rule.max.is_none() && rule.requires_key.is_none() { + let refusal = shape_refusal(rule); + let fits = refusal.verdict() == Some(class); + return Some(if fits { + refusal.bindings().to_vec() + } else { + Vec::new() + }); + } if rule.kind != RuleKind::Receipt { return None; } @@ -14091,6 +14105,9 @@ deny contains "refused by themodule" if { if class == crate::verdict::Native::HistoryDropUnpushed.id() { return history_drop_refusal(&["aaaaaaa".to_owned()]); } + if class == crate::verdict::Native::ShapeRefused.id() { + return shape_refusal(&shape("r", "x", None)); + } if class == crate::verdict::Native::ReceiptSuperseded.id() { return receipt_refusal( &shape("r", "unused", None), @@ -14202,7 +14219,13 @@ deny contains "refused by themodule" if { bindable_subjects(&rule, "receipt read missing"), Some(vec!["verify,commit".to_owned()]) ); + // A plain shape row only ever raises `call name refused` (CLOUD-1806). rule.kind = RuleKind::Shape; + assert_eq!( + bindable_subjects(&rule, "receipt read other"), + Some(Vec::new()) + ); + rule.max = Some(1); assert_eq!(bindable_subjects(&rule, "receipt read other"), None); } diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index 5fe675163..febc9a9be 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -362,10 +362,11 @@ const NO_DECLARED_FIX: &str = /// is what gets explained. That holds only where a class has one raiser, and it /// does not for the native-kind population: `[[rule]]` rows of kind `shape`, /// `receipt`, `forbid`, `pipeline`, `command`, `ratchet` and `secrets` declare no -/// class of their own and raise the kind's native one, so fourteen `shape` rows -/// all raise `call name refused` and ten `receipt` rows share four classes. Under -/// a token key the first `shape` row to fire consumed the sighting for all -/// fourteen, and the next row's FIRST firing rendered as a repeat with its +/// class of their own and raise the kind's native one, so every plain `shape` +/// row (eleven in this config when CLOUD-1806 counted) raises `call name +/// refused` and ten `receipt` rows share four classes. Under a token key the +/// first `shape` row to fire consumed the sighting for all of them, and the +/// next row's FIRST firing rendered as a repeat with its /// rule-specific remedy never pointed at. CLOUD-1637's second amendment is where /// that was corrected, and it prescribed the rule id. /// @@ -670,7 +671,8 @@ impl Refusal { /// is 66 of the 128 `[[rule]]` rows in this repository's own config. Rows of /// kind `shape`, `receipt`, `forbid`, `pipeline`, `command`, `ratchet` and /// `secrets` declare no class of their own and raise their kind's native one - /// — fourteen `shape` rows all raise `call name refused`, ten `receipt` rows + /// — every plain `shape` row (eleven in this config when CLOUD-1806 counted) + /// raises `call name refused`, ten `receipt` rows /// share four classes. So the id is not a tie-breaker for a rare collision, /// it is the only discriminator for half the population, and it renders on /// BOTH arms rather than on the first sighting alone. diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 44e454d10..705d0b4ce 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1722,6 +1722,15 @@ re-running it cannot change its answer, and the work the receipt was taken about pushed", ); +/// CLOUD-1806's route: the recorded way through every plain `shape` row. +//MUTANT shape-class-inadmissible|s@^const SHAPE_ADMIT_ROUTE: VendoredRoute = admit($@const SHAPE_ADMIT_ROUTE: VendoredRoute = run(@|a_shape_deny_is_admissible_through_its_class_override +//MUTANT shape-route-circular|s@^ run("rule read first", "batten policy rule ''"),$@ read("config read first", "batten.toml"),@|a_shape_first_sighting_names_the_rows_remedy_verb +const SHAPE_ADMIT_ROUTE: VendoredRoute = admit( + "articulate the call", + "the remedy `batten policy rule` prints for this row cannot perform the change this call \ +makes, and you can name what the call changes and where a reviewer will see its effect", +); + /// Every class the BINARY ships: the native ones and the vendored presets'. /// /// # Why the presets' vocabulary ships with the presets @@ -2141,8 +2150,13 @@ non-negotiable rule 4 decided at the composer rather than at the report.", gloss: "the mediated call matches a command shape the config refuses", class: "A `shape` row declares a command spelling that is refused outright. The \ refusal names the row rather than echoing the command, because the command is the caller's \ -own text and could carry anything. What to run instead is the row's declared remedy.", - routes: &[read("config read first", "batten.toml")], +own text and could carry anything. What to run instead is the row's declared remedy, which \ +`batten policy rule` prints; where that remedy cannot perform the change, the class is \ +admissible through a recorded admission bound to the row id at the current commit.", + routes: &[ + run("rule read first", "batten policy rule ''"), + SHAPE_ADMIT_ROUTE, + ], applicability: Applicability::Advice, }, // ─── the repaired arms (CLOUD-1639) ────────────────────────────────────── diff --git a/crates/batten/tests/it/admission.rs b/crates/batten/tests/it/admission.rs index 9527552e8..83cb1f903 100644 --- a/crates/batten/tests/it/admission.rs +++ b/crates/batten/tests/it/admission.rs @@ -872,12 +872,19 @@ severity = "deny" # A SECOND ROW UNDER A SECOND SCOPE, and the subject deliberately names a file # the tree rule also refuses. That collision is what # `a_mint_for_a_mediated_rule_anchors_the_call_not_a_tree_finding` measures. +# +# KEYED, so it is not a PLAIN shape row: a plain one only ever raises `call name +# refused`, and `override request` refuses a subject it could never bind under +# any other class (CLOUD-1806). A keyed row's refusal is not listable from the +# row, so this mint is still issued — which is all these cases need of it. [[rule]] id = "mediated-refuses" kind = "shape" scope = "mediated_call" severity = "deny" pattern = "never-matches-anything" +requires_key = 'NEVER-[0-9]+' +base = "origin/main" reason = "the fixture's mediated row; it exists to be minted against, never to fire" [[verdict]] diff --git a/crates/batten/tests/it/mediated_admission.rs b/crates/batten/tests/it/mediated_admission.rs index c3ff83ffb..563b764b7 100644 --- a/crates/batten/tests/it/mediated_admission.rs +++ b/crates/batten/tests/it/mediated_admission.rs @@ -20,6 +20,13 @@ //! cases are about. Without it a fixture whose `protected` glob silently matched //! nothing would pass the admission case for the wrong reason — the gate never //! fired, so nothing needed admitting. +//! +//! # The shape fixture +//! +//! `shape_fixture` holds two plain deny `shape` rows, the population +//! `call name refused` covers. Its cases pin that the class is admissible +//! (CLOUD-1806), that the rule id keeps one row's admission off another's call, +//! and that a request naming a subject the row cannot bind is refused. // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -100,6 +107,11 @@ fn verdict(dir: &Path, path: &str) -> Option { /// and the request is NOT interactive: it reads `=` lines from stdin, /// which is what makes an override reachable from an autonomous session at all. fn request(dir: &Path, subject: &str, reason: &str) -> String { + request_as(dir, RULE, CLASS, subject, reason) +} + +/// [`request`] for any rule and class. +fn request_as(dir: &Path, rule: &str, class: &str, subject: &str, reason: &str) -> String { let answers = format!( "precondition=the owning surface is the file being refused, so it cannot express this\n\ lost={reason}\n\ @@ -111,9 +123,9 @@ fn request(dir: &Path, subject: &str, reason: &str) -> String { "override", "request", "--rule", - RULE, + rule, "--verdict", - CLASS, + class, "--subject", subject, ], @@ -133,6 +145,11 @@ fn request(dir: &Path, subject: &str, reason: &str) -> String { /// Spend an issued admission against the situation it was issued for. fn spend(dir: &Path, admission: &str, subject: &str) -> bool { + spend_as(dir, admission, RULE, CLASS, subject) +} + +/// [`spend`] for any rule and class. +fn spend_as(dir: &Path, admission: &str, rule: &str, class: &str, subject: &str) -> bool { run( dir, &[ @@ -141,9 +158,9 @@ fn spend(dir: &Path, admission: &str, subject: &str) -> bool { "--admission", admission, "--rule", - RULE, + rule, "--verdict", - CLASS, + class, "--subject", subject, ], @@ -152,6 +169,115 @@ fn spend(dir: &Path, admission: &str, subject: &str) -> bool { .success() } +/// Two plain deny `shape` rows, the population `call name refused` covers +/// (CLOUD-1806). A shape row alone makes the policy adjudicable, and the fixture +/// is committed because `admit_mediated` binds to HEAD. +const SHAPE_CONFIG: &str = "version = 1\n\n\ + [[rule]]\nid = \"no-merge\"\nkind = \"shape\"\nscope = \"mediated_call\"\n\ + pattern = \"gh pr merge\"\nreason = \"land by fast-forward\"\nseverity = \"deny\"\n\n\ + [[rule]]\nid = \"no-rebase\"\nkind = \"shape\"\nscope = \"mediated_call\"\n\ + pattern = \"git rebase\"\nreason = \"let land replay the branch\"\nseverity = \"deny\"\n"; + +const SHAPE_CLASS: &str = "call name refused"; + +fn shape_fixture(name: &str) -> PathBuf { + Fixture::new(name) + .config(SHAPE_CONFIG) + .git() + .base_commit() + .build() +} + +/// Adjudicate one shell command, returning its exit code and stdout. +fn shell(dir: &Path, command: &str) -> (Option, String) { + let escaped = serde_json::to_string(command).expect("a command is encodable"); + let output = run_with_stdin( + dir, + &["adjudicate", "--harness", "exit-code"], + &format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + \"tool_input\":{{\"command\":{escaped}}}}}" + ), + ); + (output.status.code(), stdout(&output)) +} + +/// A plain shape deny is admissible through its class's override (CLOUD-1806). +#[test] +fn a_shape_deny_is_admissible_through_its_class_override() { + let dir = shape_fixture("mediated-admission-shape"); + assert_eq!(shell(&dir, "gh pr merge 5").0, Some(2), "the premise"); + let admission = request_as( + &dir, + "no-merge", + SHAPE_CLASS, + SHAPE_CLASS, + "the remedy cannot perform this one merge", + ); + assert!( + spend_as(&dir, &admission, "no-merge", SHAPE_CLASS, SHAPE_CLASS), + "spend must consume it" + ); + let (code, said) = shell(&dir, "gh pr merge 5"); + assert_eq!(code, Some(0), "a spent admission admits the call: {said}"); + assert!( + said.contains("batten: call name refused admitted by"), + "and names the record that admitted it: {said}" + ); +} + +/// The rule id is the only thing separating two rows that share the class +/// subject, so one row's admission must not admit another's call. +#[test] +fn a_shape_admission_does_not_admit_another_shape_row() { + let dir = shape_fixture("mediated-admission-shape-other"); + let admission = request_as( + &dir, + "no-merge", + SHAPE_CLASS, + SHAPE_CLASS, + "taken for the merge row only", + ); + assert!(spend_as( + &dir, + &admission, + "no-merge", + SHAPE_CLASS, + SHAPE_CLASS + )); + assert_eq!( + shell(&dir, "git rebase origin/main").0, + Some(2), + "an admission for one shape row must not reach another" + ); +} + +/// A shape row's subject is computable from the row, so a request naming one +/// no refusal binds is refused before it is issued (CLOUD-1806). +#[test] +fn a_shape_admission_for_an_unbindable_subject_is_refused() { + let dir = shape_fixture("mediated-admission-shape-unbindable"); + let requested = run( + &dir, + &[ + "override", + "request", + "--rule", + "no-merge", + "--verdict", + SHAPE_CLASS, + "--subject", + "no-merge", + ], + ); + let said = String::from_utf8_lossy(&requested.stderr); + assert_eq!(requested.status.code(), Some(1), "{said}"); + assert!(requested.stdout.is_empty(), "no address may be issued"); + for needle in ["1 subject(s)", "call,name,refused"] { + assert!(said.contains(needle), "{needle} missing from {said}"); + } +} + /// THE PREMISE. Every case below is about admitting this refusal, so a fixture /// where it never fires would pass them vacuously. #[test] diff --git a/crates/batten/tests/it/refusal_ceiling.rs b/crates/batten/tests/it/refusal_ceiling.rs index facf8f9d2..8b180af95 100644 --- a/crates/batten/tests/it/refusal_ceiling.rs +++ b/crates/batten/tests/it/refusal_ceiling.rs @@ -311,6 +311,40 @@ fn a_first_sighting_carries_the_gloss_and_its_route_by_kind() { ); } +/// A shape deny's first sighting names the verb that prints the ROW's remedy, +/// never the file that refused it (CLOUD-1806). +/// +/// The class's only route used to be `read batten.toml` — back to the config the +/// refusing row lives in. The `read batten.toml` negation and the `policy +/// explain` assertion stay here even once the line carries the row's real id, +/// because those are what discriminate a regression to the circular route. +#[test] +fn a_shape_first_sighting_names_the_rows_remedy_verb() { + let repo = fixture("shape-first-sighting-remedy"); + let line = fires(&repo, "gh pr merge 5"); + for needle in [ + "call name refused", + "commit ship other", + "batten policy rule '", + ] { + assert!(line.contains(needle), "{needle} missing: {line}"); + } + assert!( + !line.contains("read batten.toml"), + "the route must not send the reader back to the refusing file: {line}" + ); + let explained = common::run(&repo, &["policy", "explain", "call name refused"]); + let said = String::from_utf8_lossy(&explained.stdout); + assert!( + said.contains("articulate the call"), + "the class declares its override route: {said}" + ); + assert!( + !said.contains("batten.toml"), + "and no route names the config file: {said}" + ); +} + /// The repeat is compact, and a byte PREFIX of the first sighting. /// /// The prefix property is what makes the two arms one line rather than two @@ -367,8 +401,9 @@ fn the_sightings_store_is_written_by_a_first_firing() { /// **The second amendment's correction, as a case.** The store digested the CLASS /// token for its whole life, so under a shared class the first row to fire /// consumed the sighting for all of them and the next row's first firing rendered -/// as a repeat — its rule-specific remedy never pointed at. Fourteen `shape` rows -/// in this config raise `call name refused`; two of them are enough to decide it. +/// as a repeat — its rule-specific remedy never pointed at. Every plain `shape` +/// row (eleven in this config when CLOUD-1806 counted) raises `call name +/// refused`; two of them are enough to decide it. #[test] fn a_second_row_of_a_shared_class_still_gets_its_definition() { let repo = fixture("shared-class-two-rows"); diff --git a/rules/toolchain.md b/rules/toolchain.md index 47c5cf72b..550bf2225 100644 --- a/rules/toolchain.md +++ b/rules/toolchain.md @@ -441,9 +441,11 @@ mediating. policy explain ""` on the CLASS token (not the rule id) lists its routes, and where one is an override, `batten override request` then `batten override spend` is the way through that leaves a record. These four - rows render under `call name refused`, which declares no override route yet — - CLOUD-1806 owns that gap, and the residual hatch surviving there is the - migration row's business, not a remedy this file prescribes. + rows render under `call name refused`, which declares an override route + (CLOUD-1806): read the row's remedy with `batten policy rule ''` first, + and where it cannot perform the change, `batten override request` then + `spend` is the recorded way through, bound to the row id at the current + commit. - **`memory-guard` is retired** (CLOUD-442), and what it denied is now the engine's protected-path gate: `.serena/memories/**` in `protected` crossed with the `[[verb]]` table, which covers the Write/Edit tools and a command's From fc9e7912ae69abc7ae917b21f0c6c852792424f9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 12:51:07 +0000 Subject: [PATCH 05/22] test(verdict): pin the admission that keeps classes routed into protected paths reachable (CLOUD-1893) A starter consumer protects `batten.toml`, and most vendored classes route only into it, so one admission on `path write refused` is all that keeps them from being a deadlock. The behaviour was pinned; the dependency was not, so a withdrawal that also removed its cases would pass every gate. `verdict::routed_only_into_protection` computes the graph edge from the registry and a path set; the case asserts it is empty over `vendored()` and the starter's protected set, and that removing the blocker's admission surfaces the whole CLOUD-1357 family, derived rather than counted. Refs: CLOUD-1893 --- crates/batten/src/verdict.rs | 66 +++++++++++++++++++- crates/batten/tests/it/refusal_ceiling.rs | 74 +++++++++++++++++++++++ 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 705d0b4ce..5900159a5 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1655,9 +1655,9 @@ pub struct VendoredVerdict { /// What the boundary may do about it (CLOUD-1639). /// /// A plain field with no default, unlike the consumer table's: a `const` - /// initialiser cannot omit one, and spelling `Applicability::Advice` on each - /// vendored row is what makes any that is NOT advice visible - /// in a diff rather than inferred from an absence. + /// initialiser cannot omit one, and spelling `Applicability::Advice` on every + /// vendored row is what makes the ones that are NOT advice visible in a diff + /// rather than inferred from an absence. pub applicability: Applicability, } @@ -1696,6 +1696,7 @@ pub const fn read(id: &'static str, target: &'static str) -> VendoredRoute { } } +//MUTANT write-admission-dropped|s@^ kind: RouteKind::Override,$@ kind: RouteKind::Document,@|every_class_routed_only_into_a_protected_path_is_admissible /// An `override`-kind route, which is the only kind whose precondition is /// REQUIRED rather than optional. /// @@ -2660,6 +2661,65 @@ pub fn declared_from(entry: &VendoredVerdict) -> DeclaredVerdict { } } +//MUTANT protected-route-unread|s@protects(&route[.]target)@protects("")@|every_class_routed_only_into_a_protected_path_is_admissible +//MUTANT own-override-ignored|s@ !declares_override(entry))$@ !entry.id.is_empty())@|every_class_routed_only_into_a_protected_path_is_admissible +fn declares_override(entry: &DeclaredVerdict) -> bool { + entry + .routes + .iter() + .any(|route| route.kind == RouteKind::Override) +} + +/// Every live class whose only ways out end in a path `protects` covers, and +/// that has no admission of its own — empty whenever `path write refused`, the +/// class blocking those paths, declares one (CLOUD-1893). +/// +/// A refusal whose only way out is an action another refusal blocks is a +/// deadlock with every gate green; CLOUD-1051/1357 shipped it once. This is the +/// graph edge that names it, computed from the registry and a path set. +/// +/// - A `document` route into a protected path is read as asking for a CHANGE, +/// because the kind cannot tell a read from an edit. That reading is safe: +/// this refuses nothing, and its only discharge is an admission that exists. +/// - `command` and `issue` routes count as unblocked — whether running one +/// clears the refusal is a model verdict (rule 3), which +/// `policy/verdict-routes-resolve.rego` disclaims too. +/// - There is no load-time arm. The blocker is vendored and a consumer cannot +/// redeclare it, so the predicate over `consumer ∪ vendored()` is empty +/// exactly when it is empty over `vendored()`; a consumer load refused over +/// the binary's own table would be the wrongly-refusing gate. +/// - One override per class is rejected: it would be one admission in many +/// costumes, the shape CLOUD-680 measured. +#[must_use] +pub fn routed_only_into_protection( + registry: &[DeclaredVerdict], + protects: impl Fn(&str) -> bool, +) -> Vec { + let blocker = Native::ProtectedMutation.id(); + if registry + .iter() + .any(|entry| entry.id == blocker && declares_override(entry)) + { + return Vec::new(); + } + #[rustfmt::skip] + let blocked = |route: &Route| route.kind == RouteKind::Document && protects(&route.target); + registry + .iter() + .filter(|entry| entry.successor.is_none() && entry.withdrawn.is_none()) + .filter(|entry| !declares_override(entry)) + .filter(|entry| { + let ways: Vec<&Route> = entry + .routes + .iter() + .filter(|route| route.kind != RouteKind::Override) + .collect(); + !ways.is_empty() && ways.iter().all(|route| blocked(route)) + }) + .map(|entry| entry.id.clone()) + .collect() +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/tests/it/refusal_ceiling.rs b/crates/batten/tests/it/refusal_ceiling.rs index 8b180af95..1fb627436 100644 --- a/crates/batten/tests/it/refusal_ceiling.rs +++ b/crates/batten/tests/it/refusal_ceiling.rs @@ -423,6 +423,80 @@ fn a_second_row_of_a_shared_class_still_gets_its_definition() { ); } +/// Every vendored class whose only ways out end in a protected path stays +/// admissible, through its own override or through the blocker's (CLOUD-1893). +/// +/// A starter consumer protects `batten.toml`, and dozens of vendored classes +/// route only into it; one admission on `path write refused` is what keeps them +/// from being a deadlock. The population is DERIVED, never counted, so a row +/// moving a class in or out of the family touches neither side. +#[test] +fn every_class_routed_only_into_a_protected_path_is_admissible() { + use batten::verdict::{RouteKind, routed_only_into_protection, vendored}; + let starter = batten::config::parse(batten::init::STARTER, batten::config::CONFIG_FILE) + .expect("the starter parses"); + let sets = batten::rules::Sets::from_config(&starter).expect("the starter's sets compile"); + let protects = |path: &str| sets.protected.contains(path); + assert!( + protects(batten::config::CONFIG_FILE), + "the starter no longer protects batten.toml, so this gate covers nothing — re-scope it" + ); + + let registry = vendored(); + let stuck = routed_only_into_protection(®istry, protects); + assert!( + stuck.is_empty(), + "classes routed only into a protected path with no admission: {stuck:?}" + ); + + let blocker = batten::verdict::Native::ProtectedMutation.id(); + let mut without = registry.clone(); + let entry = without + .iter_mut() + .find(|entry| entry.id == blocker) + .expect("the blocker is vendored"); + let before = entry.routes.len(); + entry + .routes + .retain(|route| route.kind != RouteKind::Override); + assert!( + entry.routes.len() < before, + "the blocker declares an admission to remove" + ); + + let historical: Vec = registry + .iter() + .filter(|entry| entry.successor.is_none() && entry.withdrawn.is_none()) + .filter(|entry| { + entry.routes.len() == 1 + && entry.routes[0].kind == RouteKind::Document + && entry.routes[0].target == batten::config::CONFIG_FILE + }) + .map(|entry| entry.id.clone()) + .collect(); + let got = routed_only_into_protection(&without, protects); + assert!(!historical.is_empty(), "the CLOUD-1357 family is not empty"); + for id in &historical { + assert!( + got.contains(id), + "{id} routes only into batten.toml: {got:?}" + ); + } + for id in &got { + let entry = without + .iter() + .find(|entry| &entry.id == id) + .expect("a returned id names a class"); + assert!( + entry.routes.iter().all(|route| !matches!( + route.kind, + RouteKind::Override | RouteKind::Command | RouteKind::Issue + )), + "{id} has a way out of its own and was returned anyway" + ); + } +} + /// Every class this config declares can render a route on a first sighting. /// /// The completeness arm the row asks for, and the one that would have caught the From ac3318d5d2dea80cb199c0bfc45475fa43714e50 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 12:57:57 +0000 Subject: [PATCH 06/22] feat(hook)!: carry every route and every non-blocking advisory on an allowed call (CLOUD-1470) The advisory channel rendered only a class's first command route and kept only the strongest violation, so a class whose way out is a document said nothing, and a second co-firing pointer was shed. It now carries every non-blocking module's line, each with every route by kind, and `allow` is off on the advisory and Stop paths. Six preset command routes no longer repeat the `run` they render under. The consumer's `forge read first` row (warn, `policy/forge-read-first.rego`) hands a code-host call its access memory: `gh`, a forge `git` verb, `mise run land`, a GitHub MCP tool, `add_repo`. BREAKING CHANGE: hook::policy_advice returns Vec Refs: CLOUD-1470 --- batten.toml | 23 +++ crates/batten/src/hook.rs | 105 ++++++++++---- crates/batten/src/lib.rs | 22 +-- crates/batten/src/preset.rs | 38 +++-- crates/batten/tests/it/forge_read_first.rs | 132 +++++++++++++++++ crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/policy_severity.rs | 149 ++++++++++++++++++- mise.toml | 2 +- policy/forge-read-first.rego | 159 +++++++++++++++++++++ 9 files changed, 580 insertions(+), 51 deletions(-) create mode 100644 crates/batten/tests/it/forge_read_first.rs create mode 100644 policy/forge-read-first.rego diff --git a/batten.toml b/batten.toml index e292a6add..6865a1523 100644 --- a/batten.toml +++ b/batten.toml @@ -8251,6 +8251,19 @@ severity = "warn" id = "unanswered-human-calls" count = "unanswered-human-calls" +# A code-host call is handed the memory documenting this host's GitHub access +# (CLOUD-1470). Measured twice: 2026-08-31 an `add_repo` detour the memory says is +# blocked (CLOUD-1259), and 2026-09-05 six turns spent telling the owner the +# lander could not run before `mise run land` drove the loop first try. `warn`, +# so the call is allowed and the pointer rides it on the advisory channel. +[[rule]] +id = "forge read first" +no_fix_reason = "class 3 (a call rewrite): the subject is a tool call refused before it runs, so nothing persists for a command to repair; the refusal's remedy text is the route" +kind = "policy" +scope = "mediated_call" +module = "policy/forge-read-first.rego" +severity = "warn" + # A weaker spelling of a task this project already defines (CLOUD-856, the # successor shape for `run-shape-guard`'s `cargo-substitutes-for-a-task`). # @@ -13397,6 +13410,16 @@ id = "module read first" kind = "document" target = "policy/answer-the-operator.rego" [[verdict]] +id = "forge read first" +gloss = "a code-host call; read the memory documenting this host's GitHub access first" +class = """ +A call reaching the code host was made with the memory documenting this host's GitHub access unread at the moment it mattered: on 2026-08-31 a session took an `add_repo` detour that memory says is blocked, and on 2026-09-05 one spent six turns claiming the lander could not run before `mise run land` drove the loop first try. The pointer rides the allowed call; the remedy is to read the memory before concluding the host is unreachable. +""" +[[verdict.route]] +id = "memory read first" +kind = "document" +target = ".serena/memories/github-access.md" +[[verdict]] id = "forge check red" gloss = "the forge judged this commit and its fan-in check did not pass" class = """ diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 0f0bdd458..1d1733214 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -6977,39 +6977,48 @@ fn policy_rules(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Deci } } -/// The advisory half of [`policy_rules`]: what a NON-blocking module violation -/// says (CLOUD-1131). -/// -/// `None` for silence, for a blocking violation — that one is the caller's -/// `Decision` and saying it twice would put one finding on two channels — and for -/// an event with no advisory channel, which is the host capability table's answer -/// rather than this function's. -/// -/// **Assembled rather than `Refusal::render`ed**, for `stop_advice`'s reason: that -/// projection opens `Refused by`, and nothing here refuses. The id, the cause and -/// the remedy all travel, so a reader still gets the class and the way out. +/// The advisory half of [`policy_rules`]: every NON-blocking, non-`allow` module +/// violation, one per bundle (CLOUD-1131, CLOUD-1470). +/// +/// Empty for silence, for a blocking violation — that one is the caller's +/// `Decision` and saying it twice would put one finding on two channels — and at +/// `Stop`, where [`stop_advice`] stays the sole module producer (CLOUD-888's +/// one-nudge bound). Every advisory is returned rather than the strongest, +/// because two pointers on one call are two things a reader needs, and keeping +/// only the first-declared shed the second silently. +/// +/// Returned as [`Refusal`]s so the renderer is the caller's choice. +//MUTANT stop-advice-multiplied|s@^ let turn_end = envelope.event == Event::Stop;$@ let turn_end = false;@|a_stop_carries_one_module_nudge_whatever_the_warn_count #[must_use] -pub fn policy_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Option { - let (severity, refusal) = policy_refusal(policy, envelope, facts)?; - if blocks(severity, policy.fail_on_warning) { - return None; +pub fn policy_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Vec { + let turn_end = envelope.event == Event::Stop; + if turn_end { + return Vec::new(); } - Some(render_advice(&refusal)) + policy_advisories(policy, envelope, facts) } /// One refusal's text on the advisory channel, with no word that claims a verdict. -fn render_advice(refusal: &Refusal) -> String { - format!( - "{}: {} {}", - refusal.rule(), - refusal.reason(), +/// +/// A declared class renders EVERY route by kind (`read …` / `run …` / `see …`), +/// because a class whose only way out is a document carried no pointer at all +/// when this rendered the first command route alone (CLOUD-1470). An undeclared +/// refusal keeps its fix. +//MUTANT advice-routes-dropped|s@^ let tail = if routes.is_empty() {$@ let tail = if true {@|a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call +#[must_use] +pub(crate) fn render_advice(refusal: &Refusal) -> String { + let routes = refusal.routes(); + let tail = if routes.is_empty() { match refusal.fix() { crate::refusal::Fix::Run(text) => text.clone(), crate::refusal::Fix::None => String::new(), } - ) - .trim_end() - .to_owned() + } else { + format!("— {}", routes.join("; ")) + }; + format!("{}: {} {tail}", refusal.rule(), refusal.reason()) + .trim_end() + .to_owned() } /// The STRONGEST violation any enabled bundle raises, with the severity its @@ -7029,6 +7038,11 @@ fn render_advice(refusal: &Refusal) -> String { /// So the scan is total and the strongest wins. Declaration order survives as the /// tie-break, which is what keeps output byte-stable between two rows of equal /// force — the property first-match-wins was actually buying. +/// +/// An `allow` row is OFF and is skipped (CLOUD-1470): it never blocks, so this +/// changes no decision, and it stops [`stop_advice`] nudging for a rule the +/// consumer switched off. +//MUTANT allow-row-nudged|s@^ if severity == RuleSeverity::Allow {$@ if false {@|an_allow_row_nudges_nothing_at_stop fn policy_refusal( policy: &Policy, envelope: &Envelope, @@ -7072,6 +7086,9 @@ fn policy_refusal( // from the class's first `command` route, so "a refusal names a way // out" holds by construction rather than by each module's care. let severity = bundle.severity_for(violation.rule.as_deref()); + if severity == RuleSeverity::Allow { + continue; + } // STRICTLY GREATER, so an equal severity leaves the incumbent in // place and declaration order remains the tie-break. if strongest.as_ref().is_none_or(|(held, _)| severity > *held) { @@ -7105,6 +7122,46 @@ fn policy_refusal( strongest } +/// Every non-blocking, non-`allow` module violation over this call, the first +/// per bundle in declaration order (CLOUD-1470). +/// +/// A blocking violation empties the result: it is the call's decision, and +/// saying it twice would put one finding on two channels. +//MUTANT advice-strongest-only|s@^ advised$@ advised.into_iter().take(1).collect()@|two_warn_advisories_on_one_call_both_arrive +//MUTANT allow-row-advised|s@^ if severity == RuleSeverity::Allow {$@ if false {@|an_allow_row_advises_nothing +fn policy_advisories(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Vec { + if policy.bundles.is_empty() { + return Vec::new(); + } + let Ok(input) = call_document(envelope, facts) else { + return Vec::new(); + }; + let mut advised = Vec::new(); + for bundle in &policy.bundles { + let crate::facts::Look::Is(denials) = crate::policy::deny(bundle, &input) else { + continue; + }; + let Some(violation) = denials.first() else { + continue; + }; + let severity = bundle.severity_for(violation.rule.as_deref()); + if blocks(severity, policy.fail_on_warning) { + return Vec::new(); + } + if severity == RuleSeverity::Allow { + continue; + } + advised.push(Refusal::from_class( + bundle.attribute(violation), + &policy.verdicts, + &violation.verdict, + &violation.subjects, + Fix::None, + )); + } + advised +} + /// The first pre-approval any module grants this call, as the host's reason /// text (CLOUD-1949). /// diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index ea855ce5b..5555ab24b 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -17009,16 +17009,18 @@ fn fill_turn_advice( // single tool call the agent is making right now: it is about the call in // hand rather than about the turn, and suppressing it because something else // already spoke would make the signal arrive at some calls and not others for - // reasons the reader cannot see. At `Stop` the two producers render the same - // violation through the same function, so the equality test is what keeps one - // finding from arriving twice rather than a second rule about which one wins. - if let Some(signal) = hook::policy_advice(policy, envelope, facts) - && !advice.iter().any(|entry| entry.text == signal) - { - advice.push(advisory::Advice::new( - severity::AdvisoryTier::Warning, - signal, - )); + // reasons the reader cannot see. `policy_advice` is empty at `Stop` (the + // block above owns that moment), and returns every non-blocking module's + // line on a call (CLOUD-1470); the equality test drops a line two bundles + // rendered identically. + for refusal in hook::policy_advice(policy, envelope, facts) { + let signal = hook::render_advice(&refusal); + if !advice.iter().any(|entry| entry.text == signal) { + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Warning, + signal, + )); + } } } diff --git a/crates/batten/src/preset.rs b/crates/batten/src/preset.rs index 4df5a0cd9..2a2d82ce0 100644 --- a/crates/batten/src/preset.rs +++ b/crates/batten/src/preset.rs @@ -679,7 +679,7 @@ upward forever. Nothing is broken and no branch is at fault: re-derive the numbe new comment.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -692,7 +692,7 @@ above the committed budget. Raise it before it starts failing healthy runs — t that turns into a red job nobody caused.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -705,7 +705,7 @@ to defend is the one move a budget exists to forbid, so this reports that the de convertible and a deliberate commit does the converting.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -721,7 +721,7 @@ release job on it. A family present without exactly one closing line was torn by than its producer, which writes whole or removes, and reads the same way.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -1149,7 +1149,7 @@ invocation. The refusal names the task because the mapping it reads is task to a remedy is in the finding rather than a file the reader has to go and search.", routes: &[run( "task run first", - "run the task the refusal names, through the task runner", + "the task the refusal names, through the task runner", )], applicability: crate::verdict::Applicability::Advice, }, @@ -1229,10 +1229,7 @@ it runs a different version, or the same version without the variables the proje like a wrong invocation. Measured on one consumer: sixty runs of a test suite died on an \ unset variable instead of on the assertion, and the report that followed was published \ as three claims about the tree, all false.", - routes: &[run( - "task run first", - "run the declared task, or invoke the program through the pin", - )], + routes: &[run("task run first", "mise exec -- ")], applicability: crate::verdict::Applicability::Advice, }, // THE PROBE HALF, AND A SEPARATE CLASS ON PURPOSE (CLOUD-1256). @@ -1946,6 +1943,29 @@ mod tests { use std::collections::BTreeSet; + /// No `command` route's target begins with the verb it renders under + /// (CLOUD-1470). `render_route` prefixes every command target with `run `, + /// so a target that already began `run ` rendered as `run run …` once the + /// advisory channel started printing routes. + #[test] + fn no_command_route_repeats_the_verb_it_renders_under() { + let doubled: Vec<&str> = MANIFESTS + .iter() + .flat_map(|manifest| manifest.verdicts.iter()) + .filter(|entry| { + entry.routes.iter().any(|route| { + route.kind == crate::verdict::RouteKind::Command + && route.target.starts_with("run ") + }) + }) + .map(|entry| entry.id) + .collect(); + assert!( + doubled.is_empty(), + "routes render as `run run …`: {doubled:?}" + ); + } + /// Every class a preset's modules raise is declared by its own manifest. /// /// The direction a consumer module already gets from `check_verdicts_are_declared`, diff --git a/crates/batten/tests/it/forge_read_first.rs b/crates/batten/tests/it/forge_read_first.rs new file mode 100644 index 000000000..2d14beb43 --- /dev/null +++ b/crates/batten/tests/it/forge_read_first.rs @@ -0,0 +1,132 @@ +//! The compiled-binary tier for `policy/forge-read-first.rego` (CLOUD-1470). +//! +//! The module's own `test_` rules pin the predicate against a hand-written +//! `programs` list; only this tier shows the ENGINE resolves the program the +//! predicate reads, and that the class's document route reaches the reader on an +//! ALLOWED call. The bench registers only this module, with the committed module +//! bytes, so nothing else in the repository's config can refuse the call first. + +// 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, run_with_stdin, stderr, stdout}; + +const POINTER: &str = "read .serena/memories/github-access.md"; + +/// The repository root, whose committed `batten.toml` registers the module. +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// A fixture copy of the committed row and class, over the committed module. +fn bench(name: &str) -> PathBuf { + let module = std::fs::read_to_string(root().join("policy/forge-read-first.rego")) + .expect("the module is readable"); + Fixture::new(name) + .config( + "version = 1\n\n\ + [[rule]]\nid = \"forge read first\"\nkind = \"policy\"\n\ + scope = \"mediated_call\"\nmodule = \"policy/forge-read-first.rego\"\n\ + severity = \"warn\"\n\n\ + [[verdict]]\nid = \"forge read first\"\n\ + gloss = \"a code-host call; read the memory documenting this host's GitHub access first\"\n\ + class = \"A fixture copy of the committed class.\"\n\n\ + [[verdict.route]]\nid = \"memory read first\"\nkind = \"document\"\n\ + target = \".serena/memories/github-access.md\"\n", + ) + .file("policy/forge-read-first.rego", &module) + .git() + .base_commit() + .build() +} + +fn bash(command: &str) -> String { + serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": command}, + }) + .to_string() +} + +fn tool(name: &str) -> String { + serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": name, + "tool_input": {}, + }) + .to_string() +} + +/// Everything the door said on either stream, and its exit code. +fn adjudicate(dir: &Path, payload: &str) -> (Option, String) { + let answer = run_with_stdin(dir, &["adjudicate", "--harness", "claude-code"], payload); + ( + answer.status.code(), + format!("{}{}", stdout(&answer), stderr(&answer)), + ) +} + +#[test] +fn a_forge_call_is_handed_its_memory() { + let dir = bench("forge-read-first-selected"); + for payload in [ + bash("gh pr view 1"), + bash("cd /tmp && mise exec -- gh api repos/o/r"), + bash("git push origin HEAD"), + bash("mise run land"), + tool("mcp__github__get_me"), + tool("mcp__claude-code-remote__add_repo"), + ] { + let (code, said) = adjudicate(&dir, &payload); + assert_eq!( + code, + Some(0), + "an advisory refuses nothing: {payload} {said}" + ); + assert!(!said.contains("permissionDecision"), "{payload}: {said}"); + assert!( + said.contains(POINTER), + "{payload} carries no pointer: {said}" + ); + } +} + +#[test] +fn a_non_forge_call_hears_nothing() { + let dir = bench("forge-read-first-unselected"); + for command in [ + "ls -la", + "git status", + "git commit -m push", + "cd /tmp && echo gh", + ] { + let (_, said) = adjudicate(&dir, &bash(command)); + assert!(!said.contains("forge read first"), "{command}: {said}"); + } +} + +/// The COMMITTED table, which the bench cannot pin: the row, the class and its +/// route, and no doubled verb on any co-firing route. +#[test] +fn the_committed_policy_hands_a_gh_read_its_memory() { + let explained = run(&root(), &["policy", "explain", "forge read first"]); + let table = stdout(&explained); + assert!( + table.contains("memory read first") + && table.contains("document") + && table.contains(".serena/memories/github-access.md"), + "the class declares the memory route: {table}" + ); + let (code, said) = adjudicate(&root(), &bash("gh pr view 1")); + assert_eq!(code, Some(0), "a gh read is allowed here: {said}"); + assert!(said.contains(POINTER), "{said}"); + assert!( + !said.contains("run run "), + "no route repeats its verb: {said}" + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 9e363d379..35ae29f3d 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -193,6 +193,7 @@ mod fixture_repos; mod forced_push; mod forge_facts; mod forge_query; +mod forge_read_first; mod forge_window; mod frontmatter_gates; mod fuzz_corpus; diff --git a/crates/batten/tests/it/policy_severity.rs b/crates/batten/tests/it/policy_severity.rs index 75307e090..c72c79d41 100644 --- a/crates/batten/tests/it/policy_severity.rs +++ b/crates/batten/tests/it/policy_severity.rs @@ -31,8 +31,8 @@ //! //! Which events a host can be advised on. That is the capability table's answer //! (`Harness::capabilities`), it is evidence-backed per surface, and this file -//! reads it rather than restating it: `PostToolBatch` is one Claude Code declares -//! and has been probed on, `PreToolUse` is not one at all. +//! reads it rather than restating it: Claude Code's `delivered_on` row carries +//! both `PostToolBatch` and `PreToolUse`, each by measurement (CLOUD-1131). #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -199,11 +199,11 @@ fn a_warn_row_allows_the_same_call() { /// The demotion is not a discard: where the host declares an advisory channel, /// the same violation arrives as context. /// -/// `PostToolBatch` is the event chosen because the capability table already -/// carries it as probed for Claude Code. `PreToolUse` is not an advisory surface -/// on this host at all, which is the finding CLOUD-1131 recorded rather than the -/// gap this test papers over: a `warn` module has a reader at the batch boundary -/// and none at the tool call. +/// `PostToolBatch` is the event chosen because the capability table carries it +/// as probed for Claude Code. `PreToolUse` is an advisory surface on this host +/// too — the capability row records the measurement — and +/// `a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call` +/// covers that event. #[test] fn a_warn_violation_reaches_the_advisory_channel_where_the_host_has_one() { let dir = repo("policy-severity-advisory", "warn"); @@ -356,6 +356,141 @@ fn equal_force_leaves_declaration_order_as_the_tie_break() { ); } +// --------------------------------------------------------------------------- +// EVERY ROUTE, EVERY ADVISORY, AND `allow` IS OFF (CLOUD-1470). +// --------------------------------------------------------------------------- + +/// The fixture class with ONLY a document route, the shape a memory pointer has. +fn document_config(severity: &str) -> String { + format!( + r#"version = 1 + +[[verdict]] +id = "fixture severity probe" +gloss = "the fixture predicate matched" +class = """ +A fixture class whose one way out is a document to read. +""" + +[[verdict.route]] +id = "fixture probe probe" +kind = "document" +target = "notes/fixture.md" + +[[rule]] +id = "fixture-severity" +kind = "policy" +scope = "mediated_call" +module = "policy/fixture-severity.rego" +severity = "{severity}" +"# + ) +} + +/// A class with no command route carried no pointer on the advisory channel, +/// because only the first command route rendered there. +#[test] +fn a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call() { + let dir = scratch_repo("policy-severity-document-route"); + fs::write(dir.join("batten.toml"), document_config("warn")).expect("write config"); + fs::create_dir_all(dir.join("policy")).expect("policy dir"); + fs::write(dir.join("policy/fixture-severity.rego"), MODULE).expect("write module"); + let output = hook(&dir, &command_payload("PreToolUse", CALL)); + let stdout = stdout_of(&output); + assert_eq!(output.status.code(), Some(0), "{stdout}"); + assert!(!stdout.contains("permissionDecision"), "{stdout}"); + assert!( + stdout.contains("read notes/fixture.md"), + "the class's document route reaches the reader: {stdout}" + ); +} + +/// Two non-blocking modules over one call each deliver their own line; keeping +/// only the first-declared shed the second silently. +#[test] +fn two_warn_advisories_on_one_call_both_arrive() { + let dir = pair_repo("policy-severity-two-advisories", "warn", "warn"); + let output = hook(&dir, &command_payload("PreToolUse", CALL)); + let stdout = stdout_of(&output); + for class in ["fixture severity probe", "fixture severity other"] { + assert!(stdout.contains(class), "{class} must arrive: {stdout}"); + } +} + +/// `allow` means the rule is off, so it says nothing on the call either. +#[test] +fn an_allow_row_advises_nothing() { + let dir = repo("policy-severity-allow", "allow"); + let output = hook(&dir, &command_payload("PreToolUse", CALL)); + let stdout = stdout_of(&output); + assert!(!stdout.contains("fixture severity probe"), "{stdout}"); + assert!(!stdout.contains("additionalContext"), "{stdout}"); +} + +/// The two fixture modules, firing at `Stop` rather than on a command. +const STOP_MODULE: &str = r#"package batten.fixture_severity + +import rego.v1 + +rules contains "fixture-severity" + +violation contains { + "rule": "fixture-severity", + "verdict": "fixture severity probe", + "subjects": [{"path": "fixture"}], +} if { + input.call.event == "stop" +} +"#; + +const STOP_OTHER_MODULE: &str = r#"package batten.fixture_severity_other + +import rego.v1 + +rules contains "fixture-severity-other" + +violation contains { + "rule": "fixture-severity-other", + "verdict": "fixture severity other", + "subjects": [{"path": "fixture"}], +} if { + input.call.event == "stop" +} +"#; + +fn stop_pair_repo(name: &str, first: &str, second: &str) -> PathBuf { + let dir = scratch_repo(name); + fs::write(dir.join("batten.toml"), pair_config(first, second)).expect("write config"); + fs::create_dir_all(dir.join("policy")).expect("policy dir"); + fs::write(dir.join("policy/fixture-severity.rego"), STOP_MODULE).expect("write module"); + fs::write( + dir.join("policy/fixture-severity-other.rego"), + STOP_OTHER_MODULE, + ) + .expect("write second module"); + dir +} + +const STOP: &str = r#"{"hook_event_name":"Stop","session_id":"s-1","stop_hook_active":false}"#; + +/// An `allow` row nudges nothing at the end of a turn either. +#[test] +fn an_allow_row_nudges_nothing_at_stop() { + let dir = stop_pair_repo("policy-severity-allow-stop", "allow", "allow"); + let stdout = stdout_of(&hook(&dir, STOP)); + assert!(!stdout.contains("fixture severity"), "{stdout}"); +} + +/// `Stop` keeps CLOUD-888's one-nudge bound however many modules warn: the new +/// collector speaks on calls, never at the end of a turn. +#[test] +fn a_stop_carries_one_module_nudge_whatever_the_warn_count() { + let dir = stop_pair_repo("policy-severity-warn-stop", "warn", "warn"); + let stdout = stdout_of(&hook(&dir, STOP)); + assert!(stdout.contains("fixture severity probe"), "{stdout}"); + assert!(!stdout.contains("fixture severity other"), "{stdout}"); +} + /// And a `deny` row is NOT demoted at the same event: it is the decision, and /// `adjudicated` allows at a post-tool event for its own reason, so nothing is /// emitted twice. diff --git a/mise.toml b/mise.toml index 66025cc2c..ad6ff96bb 100644 --- a/mise.toml +++ b/mise.toml @@ -647,7 +647,7 @@ CI_VERDICT_STEPS = "Run mise run ,Run mise exec -- " # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract" +MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first" # The file inline tasks are declared in, for `mutate`'s `task-` route (CLOUD-1909). # The crate may not spell a consumer's filename (non-negotiable rule 1), so the # manifest is named here, beside the set it serves. Unset, a `task-` gate resolves diff --git a/policy/forge-read-first.rego b/policy/forge-read-first.rego new file mode 100644 index 000000000..1a049306f --- /dev/null +++ b/policy/forge-read-first.rego @@ -0,0 +1,159 @@ +#MUTANT-SUITE crates/batten/tests/it/forge_read_first.rs +#MUTANT forge-call-unselected|s@^\tprogram.name == "gh"$@\tfalse@|a_forge_call_is_handed_its_memory +# A call that reaches the code host is handed the memory documenting this host's +# GitHub access (CLOUD-1470). +# +# MEASURED TWICE, and both are why this exists. 2026-08-31 (CLOUD-1259): an agent +# took an `add_repo` detour the memory itself says is blocked. 2026-09-05: a +# session spent six turns telling the owner the lander could not run — every +# measurement true, the conclusion false — and `mise run land` drove the loop +# first try. The memory opens with exactly that warning, and nothing said to read +# it at the moment it mattered. +# +# A WARN ROW, NEVER A DENY. The call is allowed; the pointer rides it on the +# advisory channel, where the class's one `document` route renders as `read …`. +# +# SELECTED ON THE PROGRAM, never on a segment's first word (CLOUD-1382), so +# `cd /tmp && mise exec -- gh …` resolves to `gh`. `git` is judged on its FIRST +# argument on purpose: `git commit -m push` is not a forge call. `git -C +# push` is therefore not selected — a stated narrowing, not a miss. +# +# `pre-tool` only, so one call gets one pointer and the batch boundary does not +# repeat it. The tool names are this host's, which is why they live in consumer +# data rather than in the engine. +# METADATA +# description: | +# Bound to the mediated-call surface: this module is `scope = "mediated_call"`, +# so it reads `{call, facts}` and NOT the tree document. +# schemas: +# - input: schema["policy-call.schema"] +package batten.forge_read_first + +import rego.v1 + +rules contains "forge read first" + +forge_verbs := {"push", "fetch", "pull", "clone", "ls-remote"} + +reached contains program.name if { + some program in input.call.programs + program.name == "gh" +} + +reached contains program.name if { + some program in input.call.programs + program.name == "git" + program.arguments[0] in forge_verbs +} + +reached contains program.name if { + some program in input.call.programs + program.name == "mise" + program.arguments[0] == "run" + program.arguments[1] == "land" +} + +reached contains input.call.tool if startswith(input.call.tool, "mcp__github__") + +reached contains input.call.tool if input.call.tool == "mcp__claude-code-remote__add_repo" + +violation contains { + "rule": "forge read first", + "verdict": "forge read first", + "subjects": [{"artifact": name}], +} if { + input.call.event == "pre-tool" + some name in reached +} + +# Every case passes `programs`, and at least one is compound: `batten policy +# test` refuses a mediated-call suite of bare commands (CLOUD-857). These hand the +# predicate a resolution the engine is supposed to produce; +# `crates/batten/tests/it/forge_read_first.rs` is the tier that drives the engine. +test_a_gh_read_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "gh", "name": "gh", "arguments": ["pr", "view", "1"], "mediated": false}], + }} +} + +test_gh_through_the_pin_in_a_compound_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "command": "cd /tmp && mise exec -- gh api repos/o/r", + "programs": [ + {"program": "cd", "name": "cd", "arguments": ["/tmp"], "mediated": false}, + {"program": "gh", "name": "gh", "arguments": ["api", "repos/o/r"], "mediated": true}, + ], + }} +} + +test_a_git_push_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "git", "name": "git", "arguments": ["push", "origin", "HEAD"], "mediated": false}], + }} +} + +test_land_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "mise", "name": "mise", "arguments": ["run", "land"], "mediated": false}], + }} +} + +test_a_github_tool_is_selected if { + some _ in violation with input as {"call": {"event": "pre-tool", "tool": "mcp__github__get_me", "programs": []}} +} + +test_add_repo_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "tool": "mcp__claude-code-remote__add_repo", + "programs": [], + }} +} + +test_a_listing_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "ls", "name": "ls", "arguments": ["-la"], "mediated": false}], + }} +} + +test_a_local_git_call_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "git", "name": "git", "arguments": ["status"], "mediated": false}], + }} +} + +test_a_commit_message_saying_push_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "git", "name": "git", "arguments": ["commit", "-m", "push"], "mediated": false}], + }} +} + +test_an_echo_naming_gh_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "command": "cd /tmp && echo gh", + "programs": [ + {"program": "cd", "name": "cd", "arguments": ["/tmp"], "mediated": false}, + {"program": "echo", "name": "echo", "arguments": ["gh"], "mediated": false}, + ], + }} +} + +test_a_gh_call_at_stop_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "stop", + "programs": [{"program": "gh", "name": "gh", "arguments": ["pr", "view", "1"], "mediated": false}], + }} +} + +deny contains message if { + some v in violation + message := v.verdict +} From 95c94f182e640839b0823ae88e1efe664fed31db Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 13:56:58 +0000 Subject: [PATCH 07/22] feat(refusal)!: one projection, labelled, every pointer on every firing, full once per context per compaction cycle (CLOUD-2075) Every finding renders through `Refusal::render_finding`: `verdict ''` and `rule ''` labels, the subjects, every route (override routes as the ready request that admits) and the `policy rule` hop on both arms; the full arm adds the gloss, the row's own remedy, each override's precondition and the `policy explain` hop. The sightings store is keyed per context (session plus agent id) and per definition; a compaction's SessionStart re-delivers the cycle's full arms, any other source forgets that context alone. Advice is classed and never suppressed at the channel ceiling; the drain's cap, budget and flap filter report and withhold nothing; `check`, drain, waiver and repair lines carry the rule label. A PostToolUse boundary marks full arms read from tool output and rewrites them only where a host is measured to honour `updatedToolOutput` (every host is `Unknown` until probed). `hookcost` counts repeats per SessionStart-bounded segment and per finding. BREAKING CHANGE: removed `Refusal::line`, `Refusal::render`, `hook::deny_text`, `hook::ask_text`; changed `refusal::first_sighting` and `forget_sightings` signatures, `hook::policy_advice`/`stop_advice` return refusals, `perf::refusal_render` takes no ceiling and `RenderRecord` loses its unbounded columns; changed fields of `advisory::Advice`, `drain::Drained` and `hook::Capabilities`. Refs: CLOUD-2075 --- batten.toml | 90 +- bench/refusal-render/RESULTS.md | 38 +- bench/tokens/RESULTS.md | 4 +- .../batten/examples/refusal-render-bench.rs | 10 +- crates/batten/src/advisory.rs | 70 +- crates/batten/src/bypass.rs | 1 + crates/batten/src/completion.rs | 2 + crates/batten/src/drain.rs | 540 ++++-------- crates/batten/src/hook.rs | 781 ++++++------------ crates/batten/src/hookcost.rs | 86 +- crates/batten/src/lib.rs | 271 ++++-- crates/batten/src/perf.rs | 122 +-- crates/batten/src/refusal.rs | 681 +++++++++++---- crates/batten/src/rules.rs | 32 +- crates/batten/src/secrets.rs | 4 +- crates/batten/src/selfwrite.rs | 1 + crates/batten/src/session.rs | 18 + crates/batten/src/stop.rs | 16 +- crates/batten/src/transcript.rs | 60 +- crates/batten/src/verdict.rs | 6 +- crates/batten/src/waiver.rs | 19 +- .../repos/attribution-appeal/expected.in | 2 +- .../repos/document-no-frontmatter/expected.in | 2 +- .../repos/document-node-differs/expected.in | 2 +- .../fixtures/repos/forbid-deny/expected.in | 2 +- .../forbid-quote-load-bearing/expected.in | 2 +- .../repos/forbid-regex-cluster/expected.in | 4 +- .../fixtures/repos/forbid-warn/expected.in | 2 +- crates/batten/tests/it/advisory_drain.rs | 259 ++---- crates/batten/tests/it/ask_disposition.rs | 23 +- crates/batten/tests/it/board_receipts.rs | 16 +- crates/batten/tests/it/cli.rs | 146 ++-- crates/batten/tests/it/common/mod.rs | 14 +- crates/batten/tests/it/config_trust.rs | 6 +- crates/batten/tests/it/connector_verbs.rs | 39 +- crates/batten/tests/it/emission_census.rs | 102 +++ crates/batten/tests/it/fail_on_warning.rs | 2 +- crates/batten/tests/it/fixture_repos.rs | 2 +- crates/batten/tests/it/forge_read_first.rs | 61 +- crates/batten/tests/it/harness_wiring.rs | 10 +- crates/batten/tests/it/init.rs | 5 +- crates/batten/tests/it/mediated_admission.rs | 38 +- crates/batten/tests/it/mediated_verbs.rs | 2 +- crates/batten/tests/it/one_pr.rs | 3 +- crates/batten/tests/it/pointer_only.rs | 12 +- crates/batten/tests/it/punt_receipt.rs | 23 +- crates/batten/tests/it/refusal_ceiling.rs | 351 +++++++- .../batten/tests/it/refusal_render_bench.rs | 89 +- crates/batten/tests/it/release_assets.rs | 2 +- crates/batten/tests/it/release_due.rs | 6 +- crates/batten/tests/it/released.rs | 4 +- crates/batten/tests/it/report_only.rs | 2 +- crates/batten/tests/it/review_answered.rs | 35 +- crates/batten/tests/it/secrets_kind.rs | 6 +- ...__snapshots__pointer_output_is_frozen.snap | 4 +- crates/batten/tests/it/waivers.rs | 16 +- crates/batten/tests/it/zero_config.rs | 2 +- mise.toml | 2 +- 58 files changed, 2297 insertions(+), 1853 deletions(-) diff --git a/batten.toml b/batten.toml index 6865a1523..776af80ae 100644 --- a/batten.toml +++ b/batten.toml @@ -5700,64 +5700,41 @@ files = [".serena/memories/*.md", ".serena/memories/**/*.md"] max_bytes_per_file = 40960 max_tokens = 110000 -# What ONE emitted mediated refusal line may cost (CLOUD-1286). -# -# The neighbouring budget above bounds what loads once per session. This bounds -# what is emitted ~300 times in one, so it is the ceiling that actually -# compounds: measured live on 2026-09-01, a `no-tool-substitution` refusal was 88 -# words / ~115 tokens, and ~300 firings of it is ~34,500 tokens against a ~175k -# window — 20%, which is CLOUD-417's headline figure arrived at independently -# from the other direction. -# -# 24 is chosen against the longest line the tree can actually emit rather than -# against the shortest: a three-word class is ~3 tokens and the pointers are the -# rest, so a deeply-nested `path:line` plus an artifact fits with room, while the -# ~43-token rendered form this row retires does not. A ceiling only the shortest -# class clears would fire on correct output, and the first person it fires on -# switches it off (CLOUD-418). +# What ONE emitted mediated refusal line may cost (CLOUD-1286, re-declared by +# CLOUD-2075). +# +# This bounds the POINTER arm — what every firing after the first in a context +# emits. CLOUD-2075 put both labels, the subjects, every route (override routes +# included, as the ready request that admits) and the `policy rule` hop on that +# arm, because a pointer a ruling needs is never shed. So the number is the +# measured widest pointer the tree emits, rounded up to a multiple of 16, and +# nothing reads it at runtime: `refusal_ceiling.rs`'s corpus case REPORTS an +# over-ceiling line rather than any renderer truncating one. Measured 2026-10-03 +# with `every_arm_the_corpus_emits_is_within_its_declared_ceiling` and the +# routed-read and render-bench cases (estimated tokens, `budget.rs`'s len/4): +# +# 84 pointer branch write unsafe (render bench, two routes + override) +# 78 pointer path read routed (longest committed memory name) +# 49 pointer the Bash corpus (`tool run loose`) # # Declared here rather than as a constant in `crates/batten` for the reason # non-negotiable rule 1 gives: a consumer whose harness renders wider cannot move # a number compiled into the engine. [refusal] -max_tokens = 24 -# What ONE FIRST SIGHTING may cost (CLOUD-1637). -# -# The key above prices prose a reader has already met. This prices the one firing -# where they have not: the same line plus the class gloss and every non-override -# route, which is what turns a three-word pointer into something actionable. Two -# keys rather than one number doing both jobs, because the quantities differ by -# design — and bounding the long arm by the short arm's 24 would be a ceiling no -# line that arm can compose could ever meet, which `refusal::validate` now refuses -# outright rather than leaving as an arm that silently never renders. -# -# 64 IS READ OFF A MEASUREMENT PLUS THE DECLARED MAXIMA, not chosen. Fired in a -# fixture whose sightings store started empty, 2026-09-08, one command per -# distinct refusing row (estimated tokens, `budget.rs`'s len/4): -# -# 36 verdict read dropped verdict-not-discarded ... read rules/toolchain.md -# 35 tool run loose no-tool-substitution ... read rules/scanning.md -# 34 trunk push forced no-force-push ... run git push --force-with-lease -# 28 call name refused no-bare-cargo ... read batten.toml -# -# So the tree emits 36 at its widest today. The headroom above that is not slack: -# `GLOSS_MAX` admits a 120-character gloss, which is ~30 estimated tokens on its -# own, and a class may declare several routes. 64 clears every line this tree can -# spell while still catching a route list that has run away — the only thing this -# ceiling sheds, since the gloss is undroppable and the first route is the floor. -# A ceiling that fired on correct output would be switched off by the first person -# it fired on (CLOUD-418), and `refusal_ceiling.rs` asserts the corpus stays under -# it in the same direction the repeat arm's anti-vacuity case does. -# -# NOT AN `[epoch]` FLOOR BUMP, and that is a decision rather than an omission. -# `min_batten_version` is compared against the RUNNING build, which in this -# repository is the one built from this tree, so naming the next release makes the -# repository refuse its own config for the whole life of the PR (measured -# 2026-08-11: floor 0.0.62 against a 0.0.61 build, exit 1). The floor can only -# name a version that already exists, so the raise belongs at the release. What -# `[epoch]` actually wants — an older binary REFUSING this key rather than -# ignoring it — `deny_unknown_fields` on `[refusal]` already delivers. -first_sighting_max_tokens = 64 +max_tokens = 96 +# What ONE FULL ARM may cost (CLOUD-1637, re-declared by CLOUD-2075). +# +# The full arm is the pointer arm plus ` — ` and the definition: the class gloss +# (or an undeclared row's reason), the ROW'S OWN REMEDY, each override's +# precondition and the `policy explain` hop. It is delivered once per context per +# compaction cycle. Measured 2026-10-03 the same way as the key above: +# +# 159 full tool run loose / tool select other (the row's reason is the bulk) +# +# 176 is that rounded up to a multiple of 16. `refusal::validate` still refuses a +# value at or below `max_tokens`. NOT AN `[epoch]` FLOOR BUMP, for the reason the +# history of this key gives: the floor can only name a release that exists. +first_sighting_max_tokens = 176 # What the whole advisory CHANNEL may cost on one boundary (CLOUD-896). # @@ -5851,6 +5828,13 @@ family = "unix" # `mise run hook-cost`, which is why the figure ships as a command rather than as # a number in an issue body. # +# READ PER COMPACTION CYCLE (CLOUD-2075). A finding's full arm is delivered once +# per context per cycle, and a `SessionStart` opens the next cycle — so +# `hookcost::measure` counts repeats per `SessionStart`-bounded segment, judges a +# labelled finding by its key rather than by the bytes around it, and never counts +# a pointer arm: pointing at the first copy is what a repeat is supposed to do. +# Unlabelled output keeps the per-segment `(hook, digest)` reading. +# # Declared here rather than in `crates/batten` for non-negotiable rule 1's # reason: how loud a repository's hooks may be is a property of that repository's # hooks, and a consumer cannot move a number compiled into the engine. diff --git a/bench/refusal-render/RESULTS.md b/bench/refusal-render/RESULTS.md index 56f8bb31e..b6a125294 100644 --- a/bench/refusal-render/RESULTS.md +++ b/bench/refusal-render/RESULTS.md @@ -8,34 +8,34 @@ Generated by `mise run refusal-render-bench`. A REPORT, never a gate: no task in Characters are what is emitted. The token column is `budget::estimate_tokens`, the estimator `policy-budget` already holds instruction files to, rather than a new approximation minted for this table. -**The declared `[refusal] max_tokens` is 24, and on this tree it withholds NOTHING**: every emitted figure below equals its unbounded one, so the ceiling is declared and inert over these classes rather than shaping the numbers. The `unbounded` columns are kept because that is a fact about today's registry, not a property of the bound. +**Nothing is shed.** No renderer takes a ceiling: `[refusal]`'s keys are measured by the corpus case and report an over-ceiling line rather than truncating one, so every figure below is the whole arm. ## `branch write unsafe` (rule `leased-push`) -| strategy | residency | first sighting | emitted characters | emitted tokens | unbounded characters | unbounded tokens | -| ---------------------- | --------- | -------------- | ------------------ | -------------- | -------------------- | ---------------- | -| `Current` | `Cold` | true | 243 | 61 | 243 | 61 | -| `Current` | `Warm` | false | 31 | 7 | 31 | 7 | -| `FullEveryTime` | `Cold` | true | 243 | 61 | 243 | 61 | -| `FullEveryTime` | `Warm` | true | 243 | 61 | 243 | 61 | -| `FirstFullThenCompact` | `Cold` | true | 243 | 61 | 243 | 61 | -| `FirstFullThenCompact` | `Warm` | false | 31 | 7 | 31 | 7 | +| strategy | residency | first sighting | emitted characters | emitted tokens | +| ---------------------- | --------- | -------------- | ------------------ | -------------- | +| `Current` | `Cold` | true | 654 | 164 | +| `Current` | `Warm` | false | 339 | 84 | +| `FullEveryTime` | `Cold` | true | 654 | 164 | +| `FullEveryTime` | `Warm` | true | 654 | 164 | +| `FirstFullThenCompact` | `Cold` | true | 654 | 164 | +| `FirstFullThenCompact` | `Warm` | false | 339 | 84 | ## `tool run loose` (rule `no-tool-substitution`) -| strategy | residency | first sighting | emitted characters | emitted tokens | unbounded characters | unbounded tokens | -| ---------------------- | --------- | -------------- | ------------------ | -------------- | -------------------- | ---------------- | -| `Current` | `Cold` | true | 123 | 31 | 123 | 31 | -| `Current` | `Warm` | false | 35 | 8 | 35 | 8 | -| `FullEveryTime` | `Cold` | true | 123 | 31 | 123 | 31 | -| `FullEveryTime` | `Warm` | true | 123 | 31 | 123 | 31 | -| `FirstFullThenCompact` | `Cold` | true | 123 | 31 | 123 | 31 | -| `FirstFullThenCompact` | `Warm` | false | 35 | 8 | 35 | 8 | +| strategy | residency | first sighting | emitted characters | emitted tokens | +| ---------------------- | --------- | -------------- | ------------------ | -------------- | +| `Current` | `Cold` | true | 232 | 58 | +| `Current` | `Warm` | false | 123 | 30 | +| `FullEveryTime` | `Cold` | true | 232 | 58 | +| `FullEveryTime` | `Warm` | true | 232 | 58 | +| `FirstFullThenCompact` | `Cold` | true | 232 | 58 | +| `FirstFullThenCompact` | `Warm` | false | 123 | 30 | ## The margin -- **`branch write unsafe`** — a warm repeat emits 31 characters (7 tokens) today against 243 (61 tokens) delivered in full every time: **212 characters saved per repeat firing**. Unbounded, the same comparison is 31 against 243: **212 characters** — what a residency protocol would have to deliver, and withhold, per firing. -- **`tool run loose`** — a warm repeat emits 35 characters (8 tokens) today against 123 (31 tokens) delivered in full every time: **88 characters saved per repeat firing**. Unbounded, the same comparison is 35 against 123: **88 characters** — what a residency protocol would have to deliver, and withhold, per firing. +- **`branch write unsafe`** — a warm repeat emits 339 characters (84 tokens) today against 654 (164 tokens) delivered in full every time: **315 characters saved per repeat firing**. +- **`tool run loose`** — a warm repeat emits 123 characters (30 tokens) today against 232 (58 tokens) delivered in full every time: **109 characters saved per repeat firing**. **Every class here pays a real margin**, so a residency protocol has something to withhold on each of them: a first sighting carries the class's own explanation and a repeat carries the bare line. That was not true of every renderer this benchmark has measured — a first sighting used to append `command` routes ONLY, which left a document-route class rendering the identical line cold and warm — so the margin is a property of the renderer under measurement rather than of the strategy table. diff --git a/bench/tokens/RESULTS.md b/bench/tokens/RESULTS.md index 28433532a..5fee33d8d 100644 --- a/bench/tokens/RESULTS.md +++ b/bench/tokens/RESULTS.md @@ -43,8 +43,8 @@ count for the task is the step count above. | arm | steps | bytes | est. tokens | USD / 1k tasks (fresh) | USD / 1k tasks (cache read) | exit | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | baseline | 1 | 2705 | 677 | 1.3540 | 0.1354 | 0 | -| batten | 1 | 805 | 202 | 0.4040 | 0.0404 | 2 | -| **ratio** | | **3.36×** | **3.35×** | | | | +| batten | 1 | 945 | 237 | 0.4740 | 0.0474 | 2 | +| **ratio** | | **2.86×** | **2.86×** | | | | ### build-warning — exec output predicate diff --git a/crates/batten/examples/refusal-render-bench.rs b/crates/batten/examples/refusal-render-bench.rs index 5ed22e0e5..9a1b22ec1 100644 --- a/crates/batten/examples/refusal-render-bench.rs +++ b/crates/batten/examples/refusal-render-bench.rs @@ -35,12 +35,10 @@ fn main() -> anyhow::Result<()> { // baseline is the crate version plus these declared class ids. let config = batten::config::load(Path::new("batten.toml"))?; let registry = batten::policy::registry_for(&config.verdicts)?; - // The committed `[refusal]` ceiling travels too, because it is part of the - // shipped rendering contract: a first sighting whose routes would take the - // line over it falls back to the compact form, and a bench that passed `None` - // would report a rendering the harness never emits. - let records = refusal_render(®istry, config.refusal.as_ref())?; - let report = refusal_render_report(&records, config.refusal.as_ref()); + // No ceiling travels: no renderer takes one (CLOUD-2075), so the shipped + // rendering is the whole arm. + let records = refusal_render(®istry)?; + let report = refusal_render_report(&records); let dir = Path::new("bench/refusal-render"); std::fs::create_dir_all(dir)?; diff --git a/crates/batten/src/advisory.rs b/crates/batten/src/advisory.rs index 06fa63d9a..912cfeaa9 100644 --- a/crates/batten/src/advisory.rs +++ b/crates/batten/src/advisory.rs @@ -20,6 +20,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::refusal::Refusal; use crate::severity::AdvisoryTier; /// One producer's contribution, with the latency its content demands. @@ -33,17 +34,49 @@ use crate::severity::AdvisoryTier; pub struct Advice { /// How soon this must be answered. pub tier: AdvisoryTier, - /// The pointer text, already composed by its producer. + /// The pointer text, already composed by its producer. Empty on a classed + /// entry until `sight_advice` renders its `finding` at emission. pub text: String, + /// The finding this entry projects, rendered only when it is emitted, so + /// advice dropped beside a verdict is never marked seen (CLOUD-2075). + pub finding: Option>, + /// A classed entry carries a pointer the ruling needs, so no ceiling + /// suppresses it; only unclassed text is admitted by tier. + pub classed: bool, } impl Advice { - /// One entry. + /// One unclassed entry. #[must_use] pub fn new(tier: AdvisoryTier, text: impl Into) -> Advice { Advice { tier, text: text.into(), + finding: None, + classed: false, + } + } + + /// One classed entry, rendered through the finding projection at emission. + #[must_use] + pub fn finding(tier: AdvisoryTier, refusal: Refusal) -> Advice { + Advice { + tier, + text: String::new(), + finding: Some(Box::new(refusal)), + classed: true, + } + } + + /// An already-rendered, already-marked classed entry: the eager re-delivery + /// a compaction's `SessionStart` carries. + #[must_use] + pub fn delivered(tier: AdvisoryTier, text: impl Into) -> Advice { + Advice { + tier, + text: text.into(), + finding: None, + classed: true, } } } @@ -129,6 +162,13 @@ pub fn suppressed_line(suppressed: usize, ceiling: usize) -> String { /// the only thing said — a report about a report. The overflow is still counted, /// so the reader learns the ceiling is too small for its own content rather than /// hearing silence. +/// +/// # A classed entry is never suppressed +/// +/// It carries a pointer the ruling needs (CLOUD-2075): the ceiling bounds only +/// text that carries no class, and a classed entry over it is still emitted. +//MUTANT-SUITE crates/batten/src/advisory.rs +//MUTANT classed-advice-suppressed|s@^ if entry.classed {$@ if false {@|a_classed_entry_is_never_suppressed_at_the_ceiling #[must_use] pub fn admit(entries: Vec, ceiling: Option<&Channel>) -> Emission { let Some(ceiling) = ceiling else { @@ -145,6 +185,10 @@ pub fn admit(entries: Vec, ceiling: Option<&Channel>) -> Emission { let mut admitted: Vec = Vec::new(); let mut suppressed = 0; for entry in ordered { + if entry.classed { + admitted.push(entry); + continue; + } let candidate = joined_with(&admitted, &entry); if admitted.is_empty() || crate::budget::estimate_tokens(&candidate) <= ceiling.max_tokens { admitted.push(entry); @@ -285,4 +329,26 @@ mod tests { ); assert_eq!(emission.text, "alpha\n\nbeta"); } + + #[test] + fn a_classed_entry_is_never_suppressed_at_the_ceiling() { + // CLOUD-2075: a classed entry carries a pointer the ruling needs, so the + // ceiling bounds only unclassed text — which is still counted. + let emission = admit( + vec![ + Advice::delivered(AdvisoryTier::Warning, "w".repeat(80)), + Advice::delivered(AdvisoryTier::Advisory, "a".repeat(80)), + entry(AdvisoryTier::Caution, &"c".repeat(80)), + ], + Some(&Channel { max_tokens: 1 }), + ); + assert!(emission.text.contains(&"w".repeat(80)), "{}", emission.text); + assert!(emission.text.contains(&"a".repeat(80)), "{}", emission.text); + assert!( + !emission.text.contains(&"c".repeat(80)), + "{}", + emission.text + ); + assert_eq!(emission.suppressed, 1); + } } diff --git a/crates/batten/src/bypass.rs b/crates/batten/src/bypass.rs index 4744c5ada..38ed9b5b4 100644 --- a/crates/batten/src/bypass.rs +++ b/crates/batten/src/bypass.rs @@ -291,6 +291,7 @@ pub fn scan(stream: &Stream) -> Vec { // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this // predicate has nothing to say about it. | Event::HookOutput { .. } + | Event::SessionBoundary | Event::AssistantText => {} } } diff --git a/crates/batten/src/completion.rs b/crates/batten/src/completion.rs index 5d5cf1508..660736151 100644 --- a/crates/batten/src/completion.rs +++ b/crates/batten/src/completion.rs @@ -270,6 +270,8 @@ pub fn signal(stream: &Stream) -> Option { // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this // predicate has nothing to say about it. | Event::HookOutput { .. } + // Machinery too (CLOUD-2075): a session start is a cycle boundary. + | Event::SessionBoundary | Event::AssistantText => {} } } diff --git a/crates/batten/src/drain.rs b/crates/batten/src/drain.rs index d9353e741..c751e329f 100644 --- a/crates/batten/src/drain.rs +++ b/crates/batten/src/drain.rs @@ -91,24 +91,21 @@ //! that ran and found nothing. That is the false green this engine exists to //! catch, in a place nobody would look. //! -//! # Emission is bounded twice, and the two bounds measure different things +//! # Emission is reported twice, and never shed (CLOUD-2075) //! -//! CLOUD-82's contract, and the reason [`cycle`] selects before it renders: a -//! payload the agent cannot read is not information. +//! Every in-scope identity is a pointer the ruling needs, so no bound withholds +//! one; each REPORTS instead. //! -//! * The **per-rule cardinality cap** ([`DrainConfig::cardinality_cap`]) bounds -//! how many distinct identities one rule may spend lines on. A rule over it -//! contributes one `rule R: K+ findings` summary line and no entries, and the -//! identities it withheld are journalled as [`NotShown::OverCardinalityCap`]. -//! That is a statement about the **rule** — a check firing on eleven distinct -//! identities inside one changed scope is a rule-health signal, not a to-do -//! list — which is why it is the reason that feeds CLOUD-78's sampled review. -//! * The **token budget** ([`DrainConfig::token_budget`]) bounds the payload as -//! a whole, measured with [`crate::budget::estimate_tokens`] rather than a -//! second estimator. What it drops is journalled as -//! [`NotShown::DrainSuppressed`], because that is a statement about **this -//! boundary**: the finding is unchanged, the drain simply had no room for it -//! this time, and the next drain reconsiders it. +//! * The **per-rule cardinality cap** ([`DrainConfig::cardinality_cap`]): a rule +//! over it adds `rule '': findings, over the cardinality cap of ` +//! beside its entries. That is a statement about the **rule** — a rule-health +//! signal, not a to-do list. +//! * The **token budget** ([`DrainConfig::token_budget`]), measured with +//! [`crate::budget::estimate_tokens`]: a payload over it closes with +//! `budget: tokens over the declared `. +//! +//! The scope filter is the one withholding left, journalled as +//! [`NotShown::DrainSuppressed`]. //! //! Between the two, lines are ordered **salient-first** — by tier, then rule, //! then fingerprint. The occurrence count is deliberately *not* a sort key: @@ -506,20 +503,11 @@ pub struct Drained { /// The pointer lines to emit, ordered salient-first and deterministically, /// so the payload is byte-stable. pub lines: Vec, - /// Identities the scope filter withheld. + /// Identities the scope filter withheld: the ONE withholding left + /// (CLOUD-2075), because an out-of-scope finding is not a pointer the change + /// in hand needs. The cardinality cap, the token budget and the flap filter + /// now report and never withhold. pub scope_filtered: Vec, - /// Identities withheld because their rule was over the cardinality cap. A - /// property of the rule, and so the reason rule-health telemetry reads. - pub capped: Vec, - /// Identities withheld because the payload had no room for them **this - /// boundary**. A property of the payload, and so retried on the next drain. - pub over_budget: Vec, - /// Identities withheld because they are flapping and have spent their - /// re-emit budget for the window (CLOUD-165). A property of the SIGNAL, which - /// is a third thing again: the scope filter is about the tree, the cap about - /// the rule, the budget about this payload, and this about whether the - /// identity's own history makes another line informative. - pub flap_suppressed: Vec, /// Flapping identities per rule, for the rule-health annotation. Pointer-only: /// a rule id and a count, never a finding's content. pub flapping: BTreeMap, @@ -546,18 +534,17 @@ struct Surfaced<'a> { /// One thing the payload can say: a pointer, or a rule's cardinality summary. /// -/// Both are subject to the token budget, which is why they are one type — a -/// summary line that escaped the clamp would be a payload the budget did not -/// actually bound. +/// One type because both are ordered together; neither is ever dropped +/// (CLOUD-2075): the summary REPORTS a rule over its cap beside every entry. #[derive(Debug, Clone, Copy)] enum Item<'a> { /// One identity's pointer line. Entry(Surfaced<'a>), - /// One rule's summary, standing for the identities the cap withheld. + /// One rule's summary, reporting that it is over the cardinality cap. Summary { rule: &'a str, tier: AdvisoryTier, - withheld: usize, + count: usize, }, } @@ -580,15 +567,6 @@ impl Item<'_> { Item::Summary { rule, tier, .. } => (std::cmp::Reverse(*tier), rule, String::new()), } } - - /// How many findings this item stands for, for the withheld count the budget - /// line reports. - const fn weight(&self) -> usize { - match self { - Item::Entry(_) => 1, - Item::Summary { withheld, .. } => *withheld, - } - } } /// Render one line for a record, given the instance to point at and what the @@ -602,7 +580,8 @@ impl Item<'_> { /// A count that **rose** renders as `old->new`: the identity is the same, and /// the delta is the news. A count that fell renders plainly — a ratchet is not a /// re-raise, because re-raising on incremental fixing punishes the fix. The line -/// stays four space-separated fields whichever branch is taken. +/// stays one labelled rule and three space-separated fields whichever branch is +/// taken: `rule '' at [:] ` (CLOUD-2075). fn render_line(record: &FindingRecord, instance: &Instance, previous: Option) -> String { let count = match instance.occurrences { Observation::Observed(count) => match previous { @@ -615,25 +594,26 @@ fn render_line(record: &FindingRecord, instance: &Instance, previous: Option format!("{}:{line}", instance.path), None => instance.path.clone(), }; + let rule = crate::refusal::label(crate::refusal::Label::Rule, &record.rule); format!( - "{} {} {at} {count}", - record.identity.fingerprint.to_hex(), - record.rule + "{rule} at {} {at} {count}", + record.identity.fingerprint.to_hex() ) } -/// The one line a rule over the cardinality cap gets, in place of its entries. -fn cap_summary(rule: &str, cap: usize) -> String { - format!("rule {rule}: {cap}+ findings") +/// The line a rule over the cardinality cap adds BESIDE its entries. +fn cap_summary(rule: &str, count: usize, cap: usize) -> String { + let rule = crate::refusal::label(crate::refusal::Label::Rule, rule); + format!("{rule}: {count} findings, over the cardinality cap of {cap}") } -/// The one line closing a payload the token budget clamped. -fn budget_summary(withheld: usize) -> String { - format!("budget: {withheld} findings withheld") +/// The line closing a payload over the token budget. It reports; nothing was +/// withheld (CLOUD-2075). +fn budget_summary(over: usize, budget: usize) -> String { + format!("budget: {over} tokens over the declared {budget}") } -/// Whether `lines` plus `candidate` — and the closing summary a later clamp -/// would owe — still fits the budget. +/// Whether `lines` plus `candidate` still fits the budget. /// /// Measured over the joined payload with [`crate::budget::estimate_tokens`], /// which is the same estimator `[budget]` gates instruction files with. A second @@ -676,28 +656,18 @@ pub fn cycle( log: &[journal::Entry], ) -> Drained { let assessment = emission::assess(log, config.flap_window, config.flap_percent); - let selected = select(records, changed, context, &assessment, config.emit_cap); + let selected = select(records, changed, context); // The state lines are taken BEFORE the cap consumes the surfaced set, so the - // digest covers every identity this cycle looked at rather than only the ones - // that got a line. See [`state_lines`] for why that is the difference between - // a report id and a set hash. + // digest covers every identity this cycle looked at. See [`state_lines`] for + // why that is the difference between a report id and a set hash. let state = state_lines(&selected.shown); - let (items, capped) = cap(selected.shown, config.cardinality_cap); + let items = cap(selected.shown, config.cardinality_cap); let clamped = clamp(&items, config, previous); - let result_id = result_fingerprint( - &clamped, - &state, - &capped, - &selected.scope_filtered, - &selected.flap_suppressed, - ); + let result_id = result_fingerprint(&clamped, &state, &selected.scope_filtered); Drained { lines: clamped.lines, scope_filtered: selected.scope_filtered, - capped, - over_budget: clamped.over_budget, flapping: flapping_by_rule(records, &assessment), - flap_suppressed: selected.flap_suppressed, duplicates: selected.duplicates, counts: clamped.counts, result_id, @@ -758,33 +728,18 @@ fn state_lines(shown: &BTreeMap>) -> Vec { } /// The digest a repeat is recognised by: the payload, plus every state-bearing -/// fact behind it, plus how much was withheld and why. +/// fact behind it, plus how much the scope filter withheld. /// -/// The withheld *counts* are in the input because a finding moving between the -/// two withholding reasons is a state change the payload cannot show — the lines -/// are identical whether a rule was capped or its entries clamped, and the two -/// mean different things to the rate that reads them. +/// The withheld count is in the input because a scope withholding is a state +/// change the payload cannot show. fn result_fingerprint( clamped: &Clamped, state: &[String], - capped: &[FindingRecord], scope_filtered: &[FindingRecord], - flap_suppressed: &[FindingRecord], ) -> String { let mut input = clamped.lines.clone(); input.extend(state.iter().cloned()); - // The flap count joins the tuple for the same reason the other three are in - // it, and the omission would have been the worse bug: a flap suppression is - // invisible in the lines, so a cycle that withheld a newly-flapping identity - // would digest identically to the one before it and the `resultId` - // short-circuit would report `unchanged` about a payload that had changed. - input.push(format!( - "withheld {} {} {} {}", - scope_filtered.len(), - capped.len(), - clamped.over_budget.len(), - flap_suppressed.len() - )); + input.push(format!("withheld {}", scope_filtered.len())); drain_result_fingerprint(&input).to_hex() } @@ -793,27 +748,21 @@ fn result_fingerprint( struct Selected<'a> { shown: BTreeMap>, scope_filtered: Vec, - flap_suppressed: Vec, duplicates: usize, } /// Stage one: the one instance per identity worth pointing at. /// -/// The emission policy is applied **here**, after the scope filter and before the -/// instance pick, and the position is chosen rather than convenient. This is the -/// last point at which a withheld identity can still be carried out as a record -/// for journalling — after `cap` it has been folded into a summary line and after -/// `state_lines` it is already inside the digest, so a filter downstream of either -/// would be a suppression the store never learns about. +/// **A flapping identity is shown** (CLOUD-2075): its pointer is one the ruling +/// needs, so the emission policy no longer withholds it — it is still counted +/// in [`Drained::flapping`], which is the report. `emit_cap` stays accepted and +/// unread. fn select<'a>( records: &'a [FindingRecord], changed: &BTreeSet, context: Option<&Context>, - assessment: &emission::Assessment, - emit_cap: usize, ) -> Selected<'a> { let mut scope_filtered = Vec::new(); - let mut flap_suppressed = Vec::new(); // Keyed by identity, which is what makes "suppressed and counted" the // structure rather than a rule applied afterwards: a second record for one // identity cannot occupy a second entry. @@ -836,16 +785,6 @@ fn select<'a>( scope_filtered.push(record.clone()); continue; } - // The signal filter (CLOUD-165). It reads the identity's own history off - // the journal and decides nothing about the finding's state: the record - // below is unchanged, its instances still say what the last scan saw, and - // its disposition is whatever the agent gave it. - if let emission::Emission::Withhold(_) = - assessment.decide(&record.identity.fingerprint.to_hex(), emit_cap) - { - flap_suppressed.push(record.clone()); - continue; - } let Some(instance) = context .and_then(|context| record.instance(context)) .or_else(|| record.instances.first()) @@ -863,20 +802,19 @@ fn select<'a>( Selected { shown, scope_filtered, - flap_suppressed, duplicates, } } -/// Stage two: collapse every rule that surfaced more distinct identities than it -/// may spend entries on, and carry what it withheld out for journalling. +/// Stage two: report every rule that surfaced more distinct identities than its +/// cardinality cap, BESIDE its entries (CLOUD-2075). /// /// Grouped by rule over `shown`, whose iteration is by fingerprint hex, so both /// the grouping and every group's contents are a function of the SET. The result -/// is sorted salient-first, which is the order the clamp then spends the budget -/// in — dropping the least salient first is what makes a truncated payload the -/// most useful one that fits. -fn cap(shown: BTreeMap>, cap: usize) -> (Vec>, Vec) { +/// is sorted salient-first. +//MUTANT-SUITE crates/batten/src/drain.rs +//MUTANT capped-pointers-withheld|s@^ items.extend(surfaced.into_iter().map(Item::Entry));$@ items.extend(surfaced.into_iter().take(cap).map(Item::Entry));@|a_rule_over_the_cardinality_cap_still_shows_every_pointer +fn cap(shown: BTreeMap>, cap: usize) -> Vec> { let mut per_rule: BTreeMap<&str, Vec>> = BTreeMap::new(); for surfaced in shown.into_values() { per_rule @@ -885,116 +823,68 @@ fn cap(shown: BTreeMap>, cap: usize) -> (Vec>, Vec .push(surfaced); } - let mut capped: Vec = Vec::new(); let mut items: Vec> = Vec::new(); for (rule, surfaced) in per_rule { if surfaced.len() > cap { - // The summary carries the strongest tier the rule surfaced, so - // collapsing a rule cannot bury it below a weaker rule's entries. + // The summary carries the strongest tier the rule surfaced, so it + // sorts ahead of the rule's own entries rather than below them. let tier = surfaced .iter() .map(|entry| entry.record.tier) .max() .unwrap_or(AdvisoryTier::Advisory); - capped.extend(surfaced.iter().map(|entry| entry.record.clone())); items.push(Item::Summary { rule, tier, - withheld: surfaced.len(), + count: surfaced.len(), }); - } else { - items.extend(surfaced.into_iter().map(Item::Entry)); } + items.extend(surfaced.into_iter().map(Item::Entry)); } items.sort_by(|left, right| left.key().cmp(&right.key())); - (items, capped) + items } -/// What the clamp emitted, what it had no room for, and the counts it told the -/// agent — which become the next drain's re-raise anchor. +/// What the clamp emitted and the counts it told the agent — which become the +/// next drain's re-raise anchor. struct Clamped { lines: Vec, - over_budget: Vec, counts: BTreeMap, } -/// Stage three: spend the token budget salient-first, and say how much went -/// unsaid. -/// -/// Greedy, reserving room for the closing summary line a later drop would owe. -/// Reserving against the *remaining* weight is what makes the bound hold rather -/// than nearly hold: the line that finally gets written can only be shorter than -/// the one that was budgeted for. +/// Stage three: emit every line salient-first, and REPORT a payload over the +/// token budget rather than withholding from it (CLOUD-2075). +//MUTANT budget-pointers-withheld|s@^ lines.push(candidate);$@ if within(\&lines, \&candidate, None, config.token_budget) { lines.push(candidate); }@|an_over_budget_drain_still_shows_every_pointer fn clamp(items: &[Item<'_>], config: &DrainConfig, previous: &BTreeMap) -> Clamped { - let suffix = suffix_weights(items); let mut lines: Vec = Vec::new(); let mut counts: BTreeMap = BTreeMap::new(); - let mut over_budget: Vec = Vec::new(); - let mut withheld = 0; - let mut clamped = false; - - for (index, item) in items.iter().enumerate() { - if !clamped { - let candidate = match item { - Item::Entry(surfaced) => { - let key = surfaced.record.identity.fingerprint.to_hex(); - render_line( - surfaced.record, - surfaced.instance, - previous.get(&key).copied(), - ) + for item in items { + let candidate = match item { + Item::Entry(surfaced) => { + let key = surfaced.record.identity.fingerprint.to_hex(); + // Only an observed count anchors the next drain's re-raise: a + // rule that did not run said nothing about how many. + if let Observation::Observed(count) = surfaced.instance.occurrences { + counts.insert(key.clone(), count); } - Item::Summary { rule, .. } => cap_summary(rule, config.cardinality_cap), - }; - let reserve = suffix - .get(index + 1) - .filter(|remaining| **remaining > 0) - .map(|remaining| budget_summary(*remaining)); - if within(&lines, &candidate, reserve.as_deref(), config.token_budget) { - if let Item::Entry(surfaced) = item { - // Only an observed count anchors the next drain's re-raise: - // a rule that did not run said nothing about how many. - if let Observation::Observed(count) = surfaced.instance.occurrences { - counts.insert(surfaced.record.identity.fingerprint.to_hex(), count); - } - } - lines.push(candidate); - continue; + render_line( + surfaced.record, + surfaced.instance, + previous.get(&key).copied(), + ) } - clamped = true; - } - withheld += item.weight(); - if let Item::Entry(surfaced) = item { - over_budget.push(surfaced.record.clone()); - } - } - - if clamped { - // The reserve above budgeted for this line; it is written only if it - // still fits, because a first item too large to keep leaves no room - // that was ever checked. - let summary = budget_summary(withheld); - if within(&lines, &summary, None, config.token_budget) { - lines.push(summary); - } - } - - Clamped { - lines, - over_budget, - counts, + Item::Summary { rule, count, .. } => cap_summary(rule, *count, config.cardinality_cap), + }; + lines.push(candidate); } -} - -/// How many findings each suffix of `items` stands for, so the clamp can reserve -/// room for the closing line it might owe. One entry longer than `items`, whose -/// last element is zero: past the end nothing remains to withhold. -fn suffix_weights(items: &[Item<'_>]) -> Vec { - let mut weights: Vec = vec![0; items.len() + 1]; - for (index, item) in items.iter().enumerate().rev() { - weights[index] = weights[index + 1].saturating_add(item.weight()); + if let Some((last, head)) = lines.split_last() + && !within(head, last, None, config.token_budget) + { + let cost = crate::budget::estimate_tokens(&lines.join("\n")); + let over = cost.saturating_sub(config.token_budget); + lines.push(budget_summary(over, config.token_budget)); } - weights + Clamped { lines, counts } } /// The directory holding one wake-state file per session, under a bound store. @@ -1111,13 +1001,9 @@ pub fn record_suppressions( /// Journal every identity this cycle withheld, under the reason it was withheld /// for. /// -/// One call rather than three at the boundary, because the *pairing* of a -/// withheld set with its reason is a fact about the emission contract and not -/// about the caller: the cap is a property of the rule and feeds rule-health -/// telemetry, where the scope filter and the token clamp are properties of this -/// boundary — the finding is unchanged and the next drain reconsiders it. A -/// caller free to pair them differently could put a transient suppression into -/// the number CLOUD-78's sampled review reads as rule health. +/// One call at the boundary, because the *pairing* of a withheld set with its +/// reason is a fact about the emission contract and not about the caller. Only +/// the scope filter withholds now (CLOUD-2075). /// /// Returns how many entries were actually written, which is what tells the /// caller whether a fold is worth running. @@ -1126,31 +1012,15 @@ pub fn record_suppressions( /// /// Returns an error when a shard cannot be appended to. pub fn journal_suppressions(store_dir: &Path, shard: &str, cycle: &Drained) -> Result { - let mut appended = record_suppressions( + // The scope filter is the one withholding left (CLOUD-2075): the cap, the + // budget and the flap filter report and withhold nothing, so they have no + // suppression to journal. + record_suppressions( store_dir, shard, &cycle.scope_filtered, NotShown::DrainSuppressed, - )?; - appended += record_suppressions( - store_dir, - shard, - &cycle.capped, - NotShown::OverCardinalityCap, - )?; - appended += record_suppressions( - store_dir, - shard, - &cycle.over_budget, - NotShown::DrainSuppressed, - )?; - appended += record_suppressions( - store_dir, - shard, - &cycle.flap_suppressed, - NotShown::FlapSuppressed, - )?; - Ok(appended) + ) } /// Journal every identity this payload actually emitted. @@ -1281,9 +1151,6 @@ mod tests { Drained { lines: Vec::new(), scope_filtered: Vec::new(), - capped: Vec::new(), - over_budget: Vec::new(), - flap_suppressed: Vec::new(), flapping: BTreeMap::new(), duplicates: 0, counts: BTreeMap::new(), @@ -1779,7 +1646,7 @@ mod tests { assert_eq!( drained.lines, vec![format!( - "{} r src/a.rs:1 1", + "rule 'r' at {} src/a.rs:1 1", one.identity.fingerprint.to_hex() )] ); @@ -1815,53 +1682,40 @@ mod tests { } #[test] - fn a_rule_over_the_cardinality_cap_renders_one_summary_line_and_never_k_entries() { - // §7 (b). K+1 distinct identities for one rule collapse to exactly one - // pointer-only summary line — never K entries, which is the failure this - // cap exists to prevent: a rule firing everywhere spending the agent's - // whole payload on itself. + fn a_rule_over_the_cardinality_cap_still_shows_every_pointer() { + // CLOUD-2075 §7 case 18. Eleven identities of one rule under the default + // cap of 10: eleven labelled entries plus the summary that REPORTS the + // rule over its cap. The cap withholds nothing. let config = DrainConfig { - cardinality_cap: 3, - ..generous() + token_budget: usize::MAX, + ..DrainConfig::default() }; - let scope = changed(&["src/a.rs"]); - - let under = cycle( - &spread("r", 3), - &scope, - None, - &config, - &BTreeMap::new(), - &[], - ); - assert_eq!(under.lines.len(), 3, "at the cap, every identity speaks"); - assert!(under.capped.is_empty()); - - let over = cycle( - &spread("r", 4), - &scope, + let drained = cycle( + &spread("r", 11), + &changed(&["src/a.rs"]), None, &config, &BTreeMap::new(), &[], ); - assert_eq!( - over.lines, - vec!["rule r: 3+ findings".to_owned()], - "one line for the rule, and no entries at all" - ); - assert_eq!(over.capped.len(), 4, "all four are withheld BY THE CAP"); + let entries = drained + .lines + .iter() + .filter(|line| line.starts_with("rule 'r' at ")) + .count(); + assert_eq!(entries, 11, "{:?}", drained.lines); assert!( - over.counts.is_empty(), - "nothing was shown, so nothing is remembered as having been shown" + drained.lines.contains(&format!( + "rule 'r': 11 findings, over the cardinality cap of {}", + config.cardinality_cap + )), + "{:?}", + drained.lines ); } #[test] fn the_cap_is_per_rule_so_one_noisy_rule_never_silences_a_quiet_one() { - // The cap is a statement about a rule's health, so it must not be - // reachable by a rule's neighbours: a second rule with one finding still - // gets its pointer. let config = DrainConfig { cardinality_cap: 2, ..generous() @@ -1876,83 +1730,69 @@ mod tests { &BTreeMap::new(), &[], ); - assert_eq!(drained.lines.len(), 2); + assert_eq!(drained.lines.len(), 7, "{:?}", drained.lines); assert!( drained .lines - .contains(&"rule noisy: 2+ findings".to_owned()) + .contains(&"rule 'noisy': 5 findings, over the cardinality cap of 2".to_owned()) ); assert!( - drained.lines.iter().any(|line| line.contains(" quiet ")), + drained + .lines + .iter() + .any(|line| line.starts_with("rule 'quiet' ")), "the quiet rule keeps its pointer: {:?}", drained.lines ); } #[test] - fn the_rendered_payload_stays_at_or_under_the_configured_token_budget() { - // §7 (a), both halves. The clamped payload is at or under the budget, - // and the SAME assertion over the unclamped set fails — without which - // this test could pass on a fixture that never approached the bar. - const BUDGET: usize = 60; - let records = spread("r", 40); - let scope = changed(&["src/a.rs"]); - - let unclamped = cycle(&records, &scope, None, &generous(), &BTreeMap::new(), &[]); - assert!( - crate::budget::estimate_tokens(&render(&unclamped)) > BUDGET, - "the fixture must actually overflow, or the clamp is untested" - ); - - let clamped = cycle( + fn an_over_budget_drain_still_shows_every_pointer() { + // CLOUD-2075 §7 case 19. The budget reports; it never withholds. + let records = spread("r", 3); + let drained = cycle( &records, - &scope, + &changed(&["src/a.rs"]), None, &DrainConfig { - token_budget: BUDGET, + token_budget: 8, ..generous() }, &BTreeMap::new(), &[], ); + let entries = drained + .lines + .iter() + .filter(|line| line.starts_with("rule 'r' at ")) + .count(); + assert_eq!(entries, 3, "{:?}", drained.lines); + let last = drained.lines.last().expect("a closing line"); assert!( - crate::budget::estimate_tokens(&render(&clamped)) <= BUDGET, - "over budget: {:?}", - render(&clamped) - ); - assert!( - !clamped.over_budget.is_empty(), - "and something was actually withheld" - ); - assert_eq!( - clamped.lines.last().map(String::as_str), - Some(format!("budget: {} findings withheld", clamped.over_budget.len()).as_str()), - "the payload says how much it did not say: {:?}", - clamped.lines + last.starts_with("budget: ") && last.ends_with(" tokens over the declared 8"), + "{last}" ); + assert_eq!(drained.counts.len(), 3); } #[test] - fn a_zero_budget_says_nothing_rather_than_saying_one_thing() { - // The honest bottom of the range. A budget that cannot afford even the - // closing summary emits nothing at all — and still carries every - // withheld identity out for journalling, so silence is recorded rather - // than merely observed. - let records = spread("r", 3); + fn a_payload_within_its_budget_carries_no_budget_line() { let drained = cycle( - &records, + &spread("r", 2), &changed(&["src/a.rs"]), None, - &DrainConfig { - token_budget: 0, - ..generous() - }, + &generous(), &BTreeMap::new(), &[], ); - assert!(drained.lines.is_empty()); - assert_eq!(drained.over_budget.len(), 3); - assert!(drained.counts.is_empty()); + assert!( + drained + .lines + .iter() + .all(|line| !line.starts_with("budget: ")), + "{:?}", + drained.lines + ); } #[test] @@ -1974,7 +1814,10 @@ mod tests { &previous, &[], ); - assert_eq!(drained.lines, vec![format!("{key} r src/a.rs:1 500->501")]); + assert_eq!( + drained.lines, + vec![format!("rule 'r' at {key} src/a.rs:1 500->501")] + ); assert_eq!( drained.counts.get(&key).copied(), Some(501), @@ -2000,7 +1843,10 @@ mod tests { &previous, &[], ); - assert_eq!(drained.lines, vec![format!("{key} r src/a.rs:1 10")]); + assert_eq!( + drained.lines, + vec![format!("rule 'r' at {key} src/a.rs:1 10")] + ); } #[test] @@ -2029,7 +1875,7 @@ mod tests { &[], ); assert!( - quiet.lines[0].contains(" warning-rule "), + quiet.lines[0].starts_with("rule 'warning-rule' "), "the stronger tier leads: {:?}", quiet.lines ); @@ -2039,7 +1885,7 @@ mod tests { let before = vec![escalating.clone(), urgent.clone()]; let shouted = cycle(&before, &scope, None, &generous(), &BTreeMap::new(), &[]); assert!( - shouted.lines[0].contains(" warning-rule "), + shouted.lines[0].starts_with("rule 'warning-rule' "), "nine thousand occurrences buy no position: {:?}", shouted.lines ); @@ -2050,29 +1896,6 @@ mod tests { ); } - #[test] - fn a_capped_or_clamped_identity_is_never_remembered_as_something_the_agent_saw() { - // The remembered counts are the anchor for "what was it last told", so - // an identity withheld this boundary must not enter them: it would make - // the NEXT drain's re-raise silent, because the delta would be measured - // against a number nobody ever read. - let config = DrainConfig { - cardinality_cap: 1, - token_budget: usize::MAX, - ..DrainConfig::default() - }; - let drained = cycle( - &spread("r", 4), - &changed(&["src/a.rs"]), - None, - &config, - &BTreeMap::new(), - &[], - ); - assert_eq!(drained.lines, vec!["rule r: 1+ findings".to_owned()]); - assert!(drained.counts.is_empty()); - } - #[test] fn a_silent_drain_leaves_the_remembered_counts_where_they_were() { // A payload the `resultId` short-circuit swallowed told the agent @@ -2097,19 +1920,13 @@ mod tests { } #[test] - fn the_capped_and_the_clamped_are_withheld_for_different_recorded_reasons() { - // The two bounds measure different things, and the store has to be able - // to tell them apart: the cap is a property of the RULE and feeds - // rule-health telemetry, where the clamp is a property of THIS payload - // and the finding is reconsidered next boundary. One reason for both - // would put a transient suppression into the rule-health number. + fn only_the_scope_filter_withholds_and_only_it_is_journalled() { + // CLOUD-2075: the cap and the budget report and withhold nothing, so the + // one suppression left to journal is the out-of-scope finding. let dir = std::env::temp_dir().join(format!("batten-reasons-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - // One rule over the cap, one rule under it whose entries the clamp then - // has no room for, and one code-anchored finding outside the changed - // scope. Three withheld sets, three reasons, one cycle. let mut records = spread("noisy", 4); records.extend(spread("quiet", 2)); records.push(record(FindingKind::Code, "elsewhere", "src/z.rs", "TODO")); @@ -2125,29 +1942,9 @@ mod tests { &BTreeMap::new(), &[], ); - assert_eq!(drained.capped.len(), 4, "the noisy rule, by the cap"); assert_eq!(drained.scope_filtered.len(), 1, "the one outside the diff"); - assert!( - !drained.over_budget.is_empty(), - "and the clamp took at least one of the quiet rule's entries" - ); - let capped: BTreeSet = drained - .capped - .iter() - .map(|record| record.identity.fingerprint.to_hex()) - .collect(); - assert!( - drained - .over_budget - .iter() - .all(|record| !capped.contains(&record.identity.fingerprint.to_hex())), - "the two sets are disjoint, so no identity is journalled under two reasons" - ); - assert_eq!( - journal_suppressions(&dir, "shard", &drained).unwrap(), - drained.capped.len() + drained.scope_filtered.len() + drained.over_budget.len(), - "every withheld identity is recorded, once" - ); + assert_eq!(drained.counts.len(), 6, "every in-scope identity was shown"); + assert_eq!(journal_suppressions(&dir, "shard", &drained).unwrap(), 1); let _ = std::fs::remove_dir_all(&dir); } @@ -2180,10 +1977,9 @@ mod tests { } #[test] - fn a_finding_moving_between_withholding_reasons_changes_the_result_id() { - // Same shape, one level out: the payload cannot show whether a rule was - // capped or its entries clamped, and the two mean different things to the - // rate that reads them. An identical `unchanged` for both would lose it. + fn a_cap_report_and_a_budget_report_change_the_result_id() { + // The cap and the budget report on their own lines (CLOUD-2075), so a + // payload over one is never the same report as a payload over the other. let records = spread("r", 4); let scope = changed(&["src/a.rs"]); let capped = cycle( diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 1d1733214..15255b5cc 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -585,6 +585,11 @@ pub struct Capabilities { /// ordinary permission flow, which is what happens today and is the one /// degradation that cannot surprise anyone. pub preapprove: PreapproveReach, + /// Whether a `PostToolUse` document can replace the tool output the model + /// reads (CLOUD-2075): the boundary that cuts an already-seen finding in a + /// CLI's output to its pointer. Declared only as measured (CLOUD-1961's + /// class); every host is `Unknown` until a live probe answers for it. + pub rewrites_tool_output: Declaration, /// Whether a stop-family event can veto completion. /// /// **`false` on every surveyed host, Claude included** — all of them can only @@ -874,6 +879,9 @@ pub enum Capability { /// END and expresses its grouping through [`Capability::ALL`] and /// [`Capability::DISPATCH`] instead. Preapprove, + /// [`Capabilities::rewrites_tool_output`] (CLOUD-2075). Appended at the END + /// for the reason the two rows above give. + RewritesToolOutput, } impl Capability { @@ -882,6 +890,7 @@ impl Capability { Capability::Ask, Capability::Advisory, Capability::Preapprove, + Capability::RewritesToolOutput, Capability::StopVetoesCompletion, Capability::TimeoutFailsOpen, Capability::NeedsFailClosedConfig, @@ -900,6 +909,7 @@ impl Capability { Capability::Ask, Capability::Advisory, Capability::Preapprove, + Capability::RewritesToolOutput, Capability::StopVetoesCompletion, Capability::TimeoutFailsOpen, Capability::NeedsFailClosedConfig, @@ -933,6 +943,7 @@ impl Capability { Capability::Ask => "ask", Capability::Advisory => "advisory", Capability::Preapprove => "preapprove", + Capability::RewritesToolOutput => "rewrites-tool-output", Capability::StopVetoesCompletion => "stop-vetoes-completion", Capability::TimeoutFailsOpen => "timeout-fails-open", Capability::NeedsFailClosedConfig => "needs-fail-closed-config", @@ -1163,6 +1174,7 @@ impl Capabilities { Capability::Ask => self.ask.declared, Capability::Advisory => self.advisory.declared, Capability::Preapprove => self.preapprove.declared, + Capability::RewritesToolOutput => self.rewrites_tool_output, Capability::StopVetoesCompletion => measured(self.stop_vetoes_completion), Capability::TimeoutFailsOpen => measured(self.timeout_fails_open), Capability::NeedsFailClosedConfig => measured(self.needs_fail_closed_config), @@ -1573,6 +1585,7 @@ impl Harness { honoured_on: &["PreToolUse"], declared: Declaration::Yes, }, + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1651,6 +1664,7 @@ impl Harness { // two events, so the host clearly HAS a permission dialogue — what // the evidence does not answer is whether anything skips it. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: true, @@ -1685,6 +1699,7 @@ impl Harness { // they sit in, so no document can be emitted without guessing an // envelope, and a guessed envelope reads as no decision at all. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: true, needs_fail_closed_config: false, @@ -1750,6 +1765,7 @@ impl Harness { // collapsing them would be the guess `Declaration` exists to // refuse. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1774,6 +1790,7 @@ impl Harness { // "parsed but not supported yet", which says nothing either way // about a grant. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1801,6 +1818,7 @@ impl Harness { // so the shape IS the answer rather than a gap in somebody's // documentation. preapprove: PreapproveReach::unreachable(Declaration::No), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -2098,6 +2116,27 @@ impl HookSource { HookSource::Ci => owned(CI_READS), } } + + /// How a finding this source emits is rendered over a context's life + /// (CLOUD-2075). Only the harness knows which context it prints into; the + /// others print the full arm on every firing. + #[must_use] + pub const fn finding_lifecycle(self) -> Lifecycle { + match self { + HookSource::Harness => Lifecycle::Sighted, + HookSource::Cli | HookSource::Git | HookSource::Ci => Lifecycle::PrintedFull, + } + } +} + +/// Whether a source's findings get the once-per-cycle full arm (CLOUD-2075). +//MUTANT harness-lifecycle-unsighted|s@^ HookSource::Harness => Lifecycle::Sighted,$@ HookSource::Harness => Lifecycle::PrintedFull,@|every_hook_source_declares_its_finding_lifecycle +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifecycle { + /// Full once per context per compaction cycle, the pointer otherwise. + Sighted, + /// The full arm on every firing: the source cannot name its reader. + PrintedFull, } /// `:` for every harness and every event that is a moment. @@ -2506,7 +2545,9 @@ pub struct Envelope { /// formal: a command's stdout can carry anything, so this is the likeliest /// field in the envelope to hold a secret. It is decided OVER and never /// reproduced — not in a deny message, not in a `-J` document, and not under - /// the state root. + /// the state root. The one exception is [`tool_output_document`], which + /// hands it back to the host it came from with only finding lines cut to + /// their pointer (CLOUD-2075). /// /// Carried as the raw [`Value`] rather than a projection because the shape is /// per-tool and only partly surveyed: an MCP tool returns a content-block @@ -2575,8 +2616,17 @@ pub struct Envelope { /// "writes are available": [`Envelope::writes_available`] is where that /// direction is decided, once. pub mode: Option, + /// The subagent the payload came from, when one is the reader (Claude Code's + /// `agent_id`). Part of the sightings CONTEXT (CLOUD-2075). + pub agent: Option, + /// Why a `SessionStart` fired (`startup`, `resume`, `clear`, `compact`), + /// when the host says. [`COMPACT_SOURCE`] re-delivers rather than forgets. + pub start_source: Option, } +/// The `SessionStart` source a compaction carries (CLOUD-2075). +pub const COMPACT_SOURCE: &str = "compact"; + /// The mode a host names when the turn may propose but not perform. /// /// Claude Code's spelling, and the only one measured. A host that spells it @@ -2607,6 +2657,24 @@ impl Envelope { } } +//MUTANT session-less-shared|s@^ let session = self.session.as_deref()?;$@ let session = self.session.as_deref().unwrap_or("unnamed");@|a_session_less_payload_is_full_on_every_firing +impl Envelope { + /// The reader a sighting belongs to: the session, plus the agent id when a + /// subagent is the reader (CLOUD-2075). + /// + /// `None` when the payload names no session: a payload that cannot name its + /// reader must not share a key with other readers, so a session-less firing + /// is full on every firing and marks nothing. + #[must_use] + pub fn context(&self) -> Option { + let session = self.session.as_deref()?; + Some(match self.agent.as_deref() { + Some(agent) => format!("{session}\u{1f}{agent}"), + None => session.to_owned(), + }) + } +} + /// The payload fields a shell hook may ask for by name. /// /// A FIXED ALLOWLIST, never a caller-supplied JSON path (CLOUD-479). The @@ -3089,18 +3157,35 @@ pub struct Repair { pub subject: Option, } +//MUTANT label-spelled-outside-projection|s@^ let rule = label(Label::Rule, \&self.rule);$@ let rule = format!("rule '{}'", self.rule);@|no_finding_label_is_spelled_outside_the_projection impl Repair { - /// The record line, in [`crate::waiver::Suppressed`]'s shape. - /// - /// Same shape deliberately: a reader who has learned to read one audit line - /// on this channel should not need a second grammar for the other. Class - /// first, then the row, then the subject when there is one. + /// The record line, labelled so a reader can tell the class from the row + /// (CLOUD-2075): `verdict '' rule ''[ at ] repaired + /// verdict ''`. #[must_use] pub fn line_text(&self) -> String { - let class = crate::verdict::Native::CallFixSilent.id(); + use crate::refusal::{Label, label}; + let class = label(Label::Verdict, crate::verdict::Native::CallFixSilent.id()); + let rule = label(Label::Rule, &self.rule); + let repaired = label(Label::Verdict, &self.repaired); match self.subject.as_deref() { - Some(subject) => format!("{class} {subject} {} {}", self.rule, self.repaired), - None => format!("{class} {} {}", self.rule, self.repaired), + Some(subject) => format!("{class} {rule} at {subject} repaired {repaired}"), + None => format!("{class} {rule} repaired {repaired}"), + } + } +} + +impl Decision { + /// Apply `map` to the refusal a `Deny` or `Ask` carries (CLOUD-2075). + #[must_use] + pub fn map_refusal(self, map: impl FnOnce(Refusal) -> Refusal) -> Decision { + match self { + Decision::Deny(refusal) => Decision::Deny(map(refusal)), + Decision::Ask(refusal) => Decision::Ask(map(refusal)), + other @ (Decision::Allow + | Decision::Waived(_) + | Decision::Preapproved(_) + | Decision::Repaired(_)) => other, } } } @@ -3251,6 +3336,16 @@ pub fn decode(harness: Harness, raw: &str) -> Option { .find_map(|key| value.get(*key).and_then(Value::as_str)) .filter(|mode| !mode.is_empty()) .map(ToOwned::to_owned), + // CLOUD-2075: the sightings context and the SessionStart source. + agent: value + .get("agent_id") + .and_then(Value::as_str) + .filter(|agent| !agent.is_empty()) + .map(ToOwned::to_owned), + start_source: value + .get("source") + .and_then(Value::as_str) + .map(ToOwned::to_owned), }) } @@ -3393,7 +3488,7 @@ pub struct Policy { /// Message composition only — it never decides whether the gate fires, which /// is why it sits beside `protected` rather than inside it and why no /// raise-only clamp applies to it. - redirects: Vec, + pub(crate) redirects: Vec, /// The agent-sourced facts this repository declares (CLOUD-776). /// /// Carried on the policy for the reason `verbs` and `protected` are: the @@ -4733,282 +4828,6 @@ fn command_line_gates(policy: &Policy, envelope: &Envelope, receipts: &ReceiptFa } } -/// The text a host reads for one refusal — the deny's whole projection. -/// -/// **No hatch sentence, on any arm.** The general hatch is removed from the -/// engine, and a row's own `bypass_env` is not advertised either: a refusal's -/// ways through are its declared routes, which `batten policy explain ` -/// prints, and an advertised variable is the cheapest concrete thing in reach -/// whether or not it is the right one (CLOUD-680). -/// # CLOUD-1286: a declared refusal emits its line and stops -/// -/// Everything below this paragraph applies to a refusal with NO declared class. -/// A declared one emits ` ` and nothing else — no `Refused by` -/// prefix, no gloss, no `Fix:` clause, and **no hatch sentence**, which is -/// CLOUD-437's defect finally removed rather than narrowed: it was identical on -/// every deny, so it was pure per-firing cost carrying no per-firing -/// information. The way through a class is its declared routes, and -/// `batten policy explain ` prints all of them; the hatch is a fact about -/// mediation that `crate::hook`'s own module header states once, where it costs -/// nothing to have already read. -/// -/// The `path write refused` arm below therefore also goes: its whole purpose was -/// to surface an override route the `Fix:` clause could not reach, and `explain` -/// now reaches every route including that one. Composing an -/// `override request` command line per firing was ~40 tokens spent to save one -/// lookup. -/// # CLOUD-1386: the ROUTE comes back, because it was never the constant part -/// -/// The paragraph above is right that a sentence identical on every deny is pure -/// per-firing cost, and the hatch stays gone for exactly that reason. It was -/// wrong to take the class's first `command` route with it. That route is not -/// constant — it differs per class, it is the shortest thing that turns a refusal -/// into the next action, and `Refusal::from_class` has already resolved it by the -/// time this runs. Dropping it made every declared refusal a bare noun phrase. -/// -/// MEASURED, and the cost was not a lookup. A session pushed with a bare -/// `--force-with-lease`, read back `branch write unsafe leased-push`, and had no -/// way to tell that the class refuses one SPELLING and that -/// `--force-with-lease=:` is allowed — which the class says outright, -/// one `explain` away. It concluded instead that the landing lease gates pushing, -/// reported that to its reviewer as a design defect, and argued for changing a -/// rule that was working correctly. The remedy existed, was declared, and was one -/// clause short of arriving. -/// -/// # ONCE PER SESSION, WHICH IS THE SHAPE BOTH EARLIER VERSIONS MISSED -/// -/// The cost CLOUD-1286 measured is a REPEAT cost: prose that arrives on every -/// firing of a class a reader has already read. The value is a FIRST-SIGHTING -/// value: a reader who has never seen this class cannot act on a bare token. Both -/// are true, and neither "always" nor "never" can hold both. -/// -/// So the class travels the first time it fires in a session and never again. -/// `first_sighting` is the caller's answer — the boundary consults a -/// session-scoped record and marks it, exactly as `expire_wiring_record` treats -/// `SessionStart` as the session identity. This function stays pure and stays the -/// one place the two renderings are chosen between. -/// -/// **A fresh session and a compacted one are the same reader**, and the host says -/// so: compaction fires `SessionStart` again (Claude Code's `source` is -/// `"compact"`, beside `"startup"`, `"resume"` and `"clear"`). The record is -/// cleared on every `SessionStart` whatever its source, so a compacted context — -/// which may no longer hold the paragraph — is told again. The guarantee is per -/// context rather than per reader, and it errs toward saying it again rather than -/// assuming it was retained; that is also why the source needs no parsing here. -/// -/// # EVERY route, because "the first one" is a choice nobody made -/// -/// The first-sighting arm renders all of the class's routes rather than the one -/// `Fix:` carries. `Fix:` takes the first because it renders on every firing and -/// a list there is the repeat cost above. Once per session that budget is not in -/// force, and picking by declaration order is not a summary — it is one -/// alternative selected arbitrarily. `leased-push` is the measurement: it declares -/// the rebase first and `--force-with-lease=:` second, and the second is -/// the one that answers the reader who just hit it. Rendering the first alone is -/// what produced the defect report this row exists for. -/// -/// # AND EVERY KIND, WHICH IS WHERE THE ARM WAS STILL SILENT (CLOUD-1637) -/// -/// "Every route" meant every `command` route, because `Refusal::from_class` -/// filled `routes` from `verdict::command_routes`. A class whose routes are all -/// `document` or `issue` therefore resolved to an empty list, this function took -/// the empty-routes early return, and the FIRST sighting emitted the same bare -/// line as a repeat. The gloss rendered on no arm at all. -/// -/// It is not an edge: 112 of 162 consumer classes and 32 of 39 vendored ones -/// declare no `command` route, so 144 of 201 classes were bare on every firing. -/// Measured live on 2026-09-08 — `tool run loose`, `verdict read dropped`, -/// `verdict carry other` and `call name refused` each fired with nothing but a -/// token, and two of them fired twice because the first firing taught nothing. -/// A three-word token is a POINTER TO A DEFINITION, and the definition was never -/// delivered; the research this row cites is unanimous that a fail signal with no -/// explanation adds nothing over baseline, and that an opaque refusal is the -/// condition under which an agent fabricates a rationale instead. -/// -/// So the arm renders ` — ; `, and the -/// routes are every non-override route rendered by kind — `run` what the agent -/// executes, `read` what it opens, `see` what it takes to the tracker. -/// [`crate::verdict::sighting_routes`] owns that mapping as an exhaustive match -/// with no wildcard arm, so a kind added later cannot be dropped silently. -/// -/// # THE RULE ID IS ON BOTH ARMS, AND `Fix::Run` IS ON NEITHER -/// -/// Two ids exist and they dereference through different verbs: the class token -/// through `batten policy explain `, and the rule id through -/// `batten policy rule `. The id is not redundant with the class — 66 -/// `[[rule]]` rows declare no class of their own and raise their kind's native -/// one, so every plain `shape` row (eleven in this config when CLOUD-1806 -/// counted) raises `call name refused` and the id is the only thing that says -/// which fired. It stays on the repeat arm too, which is -/// also what keeps the repeat a byte prefix of the first sighting. -/// -/// **What goes is the consumer `reason` reaching the line as [`Fix::Run`].** It -/// used to lead the routes clause, on the argument that a consumer's `redirect` -/// knows something the class does not. That argument does not survive what a -/// `reason` actually is: prose, written as "what to do instead" for a reader with -/// no budget pressure. `an-update-owes-a-recent-read`'s is ~700 characters and -/// ENDS by naming a different rule, so the emitted line grew a second row's id — -/// exactly what CLOUD-1286 removed the prefix to prevent, and -/// `board_receipts::an_update_is_not_row_ones_business` is what said so. -/// -/// The `reason` has a destination and this is not it: `batten policy rule ` -/// prints the refusing row's own remedy, which is where the specific answer lives -/// when the class gloss is generic. `call name refused`'s gloss says only "the -/// mediated call matches a command shape the config refuses"; that a board row is -/// read through `batten mcp call Linear get_issue` is `no-raw-issue-read`'s -/// `reason`, reachable only through the id. So [`Fix::Run`] stays a dedup key and -/// a member of `render`'s long form, and is never a rendered member of this -/// clause. -/// -/// # THE CEILING GOVERNS BOTH ARMS, EACH BY ITS OWN KEY -/// -/// The once-per-session change re-pointed `refusal_ceiling` at the SECOND firing, -/// on the sound argument that `[refusal] max_tokens` was always about repeat cost. -/// The consequence was not sound: it left the FIRST sighting bounded by nothing at -/// all, in the same commit that made the first sighting the long one. Bounding it -/// by `max_tokens` is not the repair either — the first sighting is the repeat -/// line plus a gloss and a route, so the short arm's number is a ceiling the long -/// arm can never meet. `[refusal] first_sighting_max_tokens` is the second key, -/// and [`crate::refusal::validate`] refuses one at or below its sibling. -/// -/// Over budget, ROUTES ARE DROPPED FROM THE END and the gloss never is. A whole -/// route goes rather than a truncated one: half a command is not a way out, and -/// no truncator exists in this tree — `budget.rs` offers `estimate_tokens` and -/// `estimate_tokens_over` and nothing that shortens. -/// -/// **THE IRREDUCIBLE LINE IS EMITTED, NOT TRUNCATED, AND THAT IS A DECISION.** -/// When ` — ; ` is itself over -/// budget, it goes out over budget. The gloss is undroppable by contract and the -/// first route is the floor by contract, so there is nothing left to shed; the -/// threshold bounds the route LIST, not the line, exactly as `budget::Report` -/// reports over-budget rather than rewriting a file. Emitting a bare token -/// instead would spend the one firing that could have taught the class on saying -/// nothing. -/// -/// # THE UNDECLARED ARM IS BOUNDED TOO, BECAUSE ITS SIBLING IS -/// -/// A refusal composed from consumer prose carries no class, so it keeps the long -/// form — there is no token to buy concision with, and a bare line there would be -/// the bare "no" CLOUD-122 forbids. What it was NOT was bounded: it returned -/// before any ceiling was consulted and repeated identically on every firing, -/// forever. Same function, same authority boundary, same defect class as the arm -/// above, and bounding one while leaving its sibling unbounded is half a change -/// (non-negotiable rule 2). -/// -/// So it takes the same shape. The hatch sentence is the constant part — CLOUD-437 -/// established that it is identical on every deny and therefore pure per-firing -/// cost carrying no per-firing information — so it renders on the first sighting -/// and is what the ceiling sheds. `render()` itself is never shed: reason and fix -/// are what CLOUD-122 requires, and the fix clause is the one thing nothing may -/// drop. -/// -/// **A consumer that declares no ceiling gets no bound**, which is the same answer -/// every other budget gives an undeclared row — the ceiling is the consumer's -/// statement about their own line, and inventing one here would be this crate -/// deciding a consumer fact. -#[must_use] -pub fn deny_text( - refusal: &Refusal, - first_sighting: bool, - ceiling: Option<&crate::refusal::Ceiling>, -) -> String { - if refusal.verdict().is_some() && first_sighting { - return first_sighting_line(refusal, ceiling); - } - if refusal.verdict().is_some() { - return refusal.line(); - } - refusal.render() -} - -/// What an ESCALATION carries, which is not what a refusal carries. -/// -/// **A different reader, so a different projection** (CLOUD-1637). Everything -/// [`deny_text`] does is priced against an agent's context: the class is a token -/// it can dereference with `batten policy explain`, the row's remedy one it can -/// reach with `batten policy rule `, and the whole point of the compact arm -/// is that ~300 firings a session must not each carry a paragraph. -/// -/// A person answering an escalation has none of that. They have not read an -/// earlier firing, they are not going to run a lookup to decide the question in -/// front of them, and they see this string once. So the budget that justifies the -/// dereference is not in force and the dereference costs rather than saves. -/// -/// This is [`crate::refusal::Refusal::render`]'s stated purpose rather than a new -/// projection: its own doc calls it "the projection for a surface with no budget -/// pressure — `check`'s findings, a report, anything a human reads once", against -/// [`crate::refusal::Refusal::line`] for "a surface that pays for every byte on -/// every subsequent turn". The two were already written; the ask arm was reading -/// the wrong one. -/// -/// **This is what the row's `reason` is FOR on this arm.** `deny_text` stopped -/// rendering it because a `[[rule]]` `reason` is prose and the agent has a verb -/// that fetches it. Here it is the whole of what the person reads — -/// `land-through-the-loop`'s class gloss says only that the call matches a shape -/// the config refuses, and "land through `mise run land` so `main` stays -/// fast-forward" is the sentence that lets someone answer. `ask_disposition` is -/// the suite that says so. -/// -/// The hatch is not appended: an escalation is a question put to someone who can -/// answer it, and handing them an environment variable that skips the question is -/// offering a way past the gate instead of through it. -#[must_use] -pub fn ask_text(refusal: &Refusal) -> String { - refusal.render() -} - -/// The first-sighting projection: the class definition, bounded by its own key. -/// -/// Split out of [`deny_text`] so the shedding loop is one named thing rather than -/// a block inside a four-exit function — and so the irreducible case below is a -/// `break` a reader can see rather than a condition they have to reconstruct. -/// -/// Shedding is from the END: the routes are declared "in the order a reader -/// should consider them", so the last is the one whose loss costs least. The loop -/// stops at one route rather than at zero, which is what makes the floor a floor. -fn first_sighting_line(refusal: &Refusal, ceiling: Option<&crate::refusal::Ceiling>) -> String { - let gloss = refusal.gloss(); - if gloss.is_empty() && refusal.routes().is_empty() { - // A declared class with neither is not reachable through `verdict::validate`, - // which refuses a class with no route and refuses an empty gloss. Answering - // with the compact line rather than composing an empty clause keeps that - // unreachability from rendering as a dangling em-dash if it ever is. - return refusal.line(); - } - let compose = |routes: &[String]| { - let head = if gloss.is_empty() { - refusal.line() - } else { - format!("{} — {gloss}", refusal.line()) - }; - if routes.is_empty() { - head - } else { - format!("{head}; {}", routes.join("; ")) - } - }; - let mut routes: Vec = Vec::new(); - for route in refusal.routes() { - // A class can declare the same target under two ids, and a reader met - // with the same clause twice learns that the renderer cannot count. - if !routes.contains(route) { - routes.push(route.clone()); - } - } - loop { - let candidate = compose(&routes); - // At one route there is nothing left to shed: the gloss is undroppable by - // contract and the first route is the floor by contract, so an over-budget - // line here is emitted over budget rather than made useless. - if routes.len() <= 1 - || !ceiling.is_some_and(|declared| declared.over_first_sighting(&candidate)) - { - return candidate; - } - routes.pop(); - } -} - /// The first shape row that matches the mediated command, in declaration order. /// /// Declaration order is the tie-break rather than "most specific wins": a @@ -5773,11 +5592,14 @@ fn receipt_alternation_refusal( .map(|check| format!("`{check}` {}", verdict_of(check).as_str())) .collect::>() .join(", "); + // Undeclared on purpose, so the bindings CLOUD-1996 computes for receipt + // rows do not change; the per-firing state rides as the subjects. Refusal::new( &rule.id, - format!("this call needs any one of these receipts valid, and none is: {named}"), + "this call needs any one of these receipts valid, and none is", Fix::declared(rule.reason.as_deref()), ) + .at(named) } /// Compose a receipt row's refusal, naming the check and what is wrong with it. @@ -6912,7 +6734,7 @@ fn policy_protected_paths(rule: &Rule) -> Vec { /// `None` for every other event, for could-not-look, and for a policy that /// registers no module. #[must_use] -pub fn stop_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Option { +pub fn stop_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Option { if envelope.event != Event::Stop { return None; } @@ -6935,7 +6757,7 @@ pub fn stop_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> O // refuses. The id, the cause and the remedy all travel, so the fix clause is // present exactly as that contract insists. let (_, refusal) = policy_refusal(policy, envelope, facts)?; - Some(render_advice(&refusal)) + Some(refusal) } /// The policy gate: hand every registered module the call's facts and read back @@ -6998,29 +6820,6 @@ pub fn policy_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> policy_advisories(policy, envelope, facts) } -/// One refusal's text on the advisory channel, with no word that claims a verdict. -/// -/// A declared class renders EVERY route by kind (`read …` / `run …` / `see …`), -/// because a class whose only way out is a document carried no pointer at all -/// when this rendered the first command route alone (CLOUD-1470). An undeclared -/// refusal keeps its fix. -//MUTANT advice-routes-dropped|s@^ let tail = if routes.is_empty() {$@ let tail = if true {@|a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call -#[must_use] -pub(crate) fn render_advice(refusal: &Refusal) -> String { - let routes = refusal.routes(); - let tail = if routes.is_empty() { - match refusal.fix() { - crate::refusal::Fix::Run(text) => text.clone(), - crate::refusal::Fix::None => String::new(), - } - } else { - format!("— {}", routes.join("; ")) - }; - format!("{}: {} {tail}", refusal.rule(), refusal.reason()) - .trim_end() - .to_owned() -} - /// The STRONGEST violation any enabled bundle raises, with the severity its /// enabling row declared. /// @@ -10486,6 +10285,46 @@ pub fn encode_ask( } } +/// Encode a `PostToolUse` tool-output rewrite for `harness`, or `None` where +/// the host is not measured to honour one (CLOUD-2075). +/// +/// The gate only; [`tool_output_document`] owns the bytes. +/// +/// # Errors +/// +/// Serialization of this fixed shape cannot practically fail. +//MUTANT rewrite-ungated|s@^ if !harness.capabilities().rewrites_tool_output.is_capturable() {$@ if false {@|the_tool_output_rewrite_is_emitted_only_where_measured +pub fn encode_tool_output( + harness: Harness, + event: &str, + result: &Value, +) -> serde_json::Result> { + if !harness.capabilities().rewrites_tool_output.is_capturable() { + return Ok(None); + } + tool_output_document(event, result).map(Some) +} + +/// The `PostToolUse` document replacing what the model reads of a tool's output +/// (CLOUD-2075), whatever any harness declares — so its shape is testable apart +/// from the gate in [`encode_tool_output`]. +/// +/// **It relays the host's own tool output back to the same host**: the bytes go +/// to the channel they came from, unchanged except for finding lines, which is +/// the one exception to [`Envelope::result`]'s "never reproduced". +/// +/// # Errors +/// +/// Serialization of this fixed shape cannot practically fail. +pub fn tool_output_document(event: &str, result: &Value) -> serde_json::Result { + serde_json::to_string(&serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": event, + "updatedToolOutput": result, + } + })) +} + /// Encode an **advisory** body for `harness`, or `None` where no non-blocking /// channel to the model is reachable on this surface (CLOUD-461). /// @@ -10757,16 +10596,14 @@ mod tests { }] } - /// CLOUD-1386: the first sighting carries EVERY declared `command` route. + /// CLOUD-2075: both arms carry EVERY route, the override included as the + /// ready request that admits; only the full arm adds the definition. /// /// Measured on `leased-push`, which declares the rebase first and the - /// explicit `--force-with-lease=:` second. A session that read only - /// the first could not tell the class refuses a SPELLING rather than the - /// action, and reported a working gate as a design defect. So the assertion - /// that matters is on the SECOND route: a renderer taking `Fix:`'s single - /// route passes every other clause of this case. + /// explicit `--force-with-lease=:` second — the second is the one + /// that answers the reader, so it is the assertion that matters. #[test] - fn a_first_sighting_carries_every_command_route() { + fn both_arms_carry_every_route() { let registry = two_route_class(); let refusal = Refusal::from_class( "leased-push", @@ -10775,65 +10612,34 @@ mod tests { &[], crate::refusal::Fix::None, ); - let text = deny_text(&refusal, true, None); - assert!( - text.contains("git push --force-with-lease=:"), - "the second route is the one that answers the reader: {text}" - ); - assert!(text.contains("git pull --rebase"), "{text}"); + for arm in [crate::refusal::Arm::Full, crate::refusal::Arm::Pointer] { + let text = refusal.render_finding(arm); + assert!( + text.contains("run git push --force-with-lease=:"), + "{text}" + ); + assert!(text.contains("run git pull --rebase"), "{text}"); + assert!( + text.contains( + "admit with batten override request --rule 'leased-push' --verdict \ + 'branch write unsafe' --subject '" + ), + "{text}" + ); + } assert!( - !text.contains("override"), - "a way out that begins by asking to be excused is not an alternative: {text}" + !refusal + .render_finding(crate::refusal::Arm::Pointer) + .contains(" —") ); } - /// The repeat is the bare line — the cost CLOUD-1286 measured, still unpaid. - #[test] - fn a_repeat_sighting_carries_no_route_at_all() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::None, - ); - let text = deny_text(&refusal, false, None); - assert!(!text.contains("git pull --rebase"), "{text}"); - assert!(!text.contains(" — "), "{text}"); - } - - /// A first sighting is bounded by the SAME declared ceiling as a repeat. - /// - /// The once-per-session change re-pointed `refusal_ceiling` at the second - /// firing, which left this arm bounded by nothing in the same commit that made - /// it the long one. Measured on a consumer `[[rule]]` row's `reason` reaching - /// `Fix::Run` as prose — ~700 characters ending in a DIFFERENT rule's id, which - /// is what `board_receipts::an_update_is_not_row_ones_business` caught. - /// - /// Dropped WHOLE rather than truncated: half a command is not a way out, and - /// the class token is still on the line for `batten policy explain`. - /// THE FIXTURE IS PROSE BECAUSE THE DEFECT WAS PROSE, and a shorter one does - /// not reach the bound. Measured while writing this: the class's own two - /// routes compose an 82-character line — about 20 estimated tokens, UNDER the - /// declared 24 — so a case built on them asserts the ceiling drops something - /// it never had cause to drop, and fails for being wrong about its own - /// premise rather than about the engine. - /// - /// A consumer `[[rule]]` row's `reason` is what actually arrives here, and - /// `an-update-owes-a-recent-read`'s is ~700 characters ending in another - /// rule's id. This mirrors that shape rather than lowering the ceiling until - /// a short line trips it, which would have measured the fixture. - const PROSE_FIX: &str = "Re-read the row. That is the whole remedy: read it \ - again with its relations, and the receipt mints itself from that result \ - — there is no second call and no payload to pipe anywhere. Then make the \ - write from what you just read, not from the plan you built earlier: if \ - the row changed, that is the point, so decide again. This bounds how old \ - the read was; it cannot prove the row is unchanged, because the tracker \ - offers no precondition on write."; + /// The prose a consumer row declares as its remedy reaches the FULL arm + /// (CLOUD-2075 reverses CLOUD-1637's omission), and never the pointer arm. + const PROSE_FIX: &str = "Re-read the row, then write from what you just read."; #[test] - fn a_first_sighting_over_the_declared_ceiling_drops_its_routes() { + fn a_rows_remedy_rides_the_full_arm_only() { let registry = two_route_class(); let refusal = Refusal::from_class( "row-one", @@ -10842,148 +10648,19 @@ mod tests { &[], crate::refusal::Fix::Run(PROSE_FIX.to_owned()), ); - let unbounded = deny_text(&refusal, true, None); - // THE PREMISE HAS MOVED, and the move is CLOUD-1637's (see `deny_text`). - // The prose no longer reaches the line on ANY arm — `Fix::Run` is a dedup - // key and never a rendered member — so what the ceiling sheds is the - // class's own routes, from the end, and never the gloss. - assert!( - !unbounded.contains("Re-read the row"), - "a consumer `reason` never reaches the emitted line: {unbounded}" - ); - assert!( - unbounded.contains("git pull --rebase") - && unbounded.contains("git push --force-with-lease=:"), - "the premise: unbounded, this arm carries both routes — {unbounded}" - ); - // Admits the head and the first route, refuses the second. - let one_route = crate::refusal::Ceiling { - max_tokens: 24, - first_sighting_max_tokens: Some( - crate::budget::estimate_tokens(&unbounded).saturating_sub(2), - ), - }; - let bounded = deny_text(&refusal, true, Some(&one_route)); - assert!( - bounded.contains("git pull --rebase"), - "the first route is the floor: {bounded}" - ); - assert!( - !bounded.contains("git push --force-with-lease=:"), - "the last route is what sheds: {bounded}" - ); - assert!( - bounded.contains(" — branch write unsafe"), - "and the gloss is never what sheds: {bounded}" - ); - } - - /// The irreducible line goes out OVER budget rather than losing its meaning. - /// - /// The threshold bounds the route LIST, not the line. With the gloss - /// undroppable by contract and the first route the floor by contract, there is - /// nothing left to shed — and no truncator exists in this tree. Pinned here so - /// the decision is visible rather than emergent. - #[test] - fn an_irreducible_first_sighting_is_emitted_over_budget() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::None, - ); - let ceiling = crate::refusal::Ceiling { - max_tokens: 24, - first_sighting_max_tokens: Some(1), - }; - let text = deny_text(&refusal, true, Some(&ceiling)); - assert!( - ceiling.over_first_sighting(&text), - "the premise: nothing this arm can compose fits a ceiling of 1 — {text}" - ); - assert!( - text.contains(" — branch write unsafe"), - "the gloss is undroppable: {text}" - ); - assert!( - text.contains("git pull --rebase"), - "and the first route is the floor: {text}" - ); - } - - /// And a SHORT route still travels, or the bound above is just the old - /// never-render behaviour wearing a ceiling. - /// - /// The class's own two routes at the REAL declared ceiling of 24, which is the - /// case the row exists for: `leased-push`'s explicit lease form has to reach a - /// reader who has just been refused, and it does. - #[test] - fn a_first_sighting_inside_the_ceiling_still_carries_its_routes() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::None, - ); - let ceiling = crate::refusal::Ceiling { - max_tokens: 24, - first_sighting_max_tokens: None, - }; - let text = deny_text(&refusal, true, Some(&ceiling)); - assert!( - text.contains("git push --force-with-lease=:"), - "{text}" - ); - } - - /// The caller's narrower alternative is a dedup key and is never rendered. - /// - /// **This case reverses, and CLOUD-1637 is where** (see `deny_text`). It used - /// to assert that a consumer's `redirect` LED the routes clause, on the - /// argument that it knows something the class does not. What actually arrives - /// through `Fix::Run` is a `[[rule]]` row's `reason` — prose written as "what - /// to do instead" for a reader with no budget pressure, one of which is ~700 - /// characters and ends by naming a DIFFERENT rule. So the emitted line grew a - /// second row's id, which is what CLOUD-1286 removed the prefix to prevent. - /// - /// The `reason` has its own destination: `batten policy rule `, reached - /// through the rule id this line still carries. What is asserted now is that - /// it reaches the line through neither. - #[test] - fn a_narrower_fix_is_never_a_rendered_member() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::Run(PROSE_FIX.to_owned()), - ); - let text = deny_text(&refusal, true, None); - assert!( - !text.contains("Re-read the row"), - "a consumer `reason` is not a rendered member: {text}" - ); - let routes = text.split("; ").skip(1).collect::>().join("; "); + let full = refusal.render_finding(crate::refusal::Arm::Full); + assert!(full.contains(PROSE_FIX), "{full}"); assert!( - routes.starts_with("run git pull --rebase"), - "the class's own first route leads instead: {text}" - ); - assert!( - routes.contains("run git push --force-with-lease=:"), - "{text}" + !refusal + .render_finding(crate::refusal::Arm::Pointer) + .contains("Re-read the row") ); } /// A route the class declares twice is said once. /// - /// The dedup the case above used to cover incidentally. Two ids may point at - /// one target, and a reader met with the same clause twice learns that the - /// renderer cannot count. + /// Two ids may point at one target, and a reader met with the same clause + /// twice learns that the renderer cannot count. #[test] fn a_repeated_route_target_is_rendered_once() { let mut registry = two_route_class(); @@ -10999,7 +10676,7 @@ mod tests { &[], crate::refusal::Fix::None, ); - let text = deny_text(&refusal, true, None); + let text = refusal.render_finding(crate::refusal::Arm::Full); assert_eq!(text.matches("run git pull --rebase").count(), 1, "{text}"); } @@ -11380,6 +11057,8 @@ mod tests { reads: None, cwd: None, session: None, + agent: None, + start_source: None, // The Stop-path fields (CLOUD-479) are absent on a PreTool envelope, // which is the honest shape rather than a filler value. stop_active: None, @@ -11423,6 +11102,8 @@ mod tests { reads: None, cwd: None, session: None, + agent: None, + start_source: None, stop_active: None, last_message: None, transcript: None, @@ -11743,7 +11424,7 @@ mod tests { let line = suppressed.line_text(); assert!(!line.contains("gh pr merge"), "{line}"); assert!(!line.contains("42"), "{line}"); - assert_eq!(line, "waived gh-pr-merge (expires 2099-01-01)"); + assert_eq!(line, "waived rule 'gh-pr-merge' (expires 2099-01-01)"); } #[test] @@ -11758,8 +11439,8 @@ mod tests { .find("pub fn adjudicate(") .expect("adjudicate is defined here"); let end = source[start..] - .find("/// The text a host reads for one refusal") - .expect("the chain ends before deny_text"); + .find("fn event_decides(") + .expect("the chain ends before the per-event table"); let body = &source[start..start + end]; for clock in ["SystemTime", "waiver::today", "today()", "Date"] { assert!( @@ -11796,7 +11477,7 @@ mod tests { // the class is new to them — the one these assertions are about. The // repeat rendering has its own cases, where the difference IS the // subject rather than incidental to it. - Decision::Deny(refusal) => deny_text(&refusal, true, None), + Decision::Deny(refusal) => refusal.render_finding(crate::refusal::Arm::Full), // An `Ask` is not a deny, and collapsing the two here would let a // row that silently started escalating keep passing every assertion // below about what a refusal says. A `Waived` is not one either, and @@ -13224,16 +12905,16 @@ deny contains "refused by themodule" if { // // OVER THE EMITTED LINE, NOT THE STRUCT (CLOUD-1637). The // `Refusal` now CARRIES the gloss — resolved where the registry is - // in hand, so `deny_text` stays pure — and reading a `{:?}` of the + // in hand, so the projection stays pure — and reading a `{:?}` of the // struct would fail on a field whose presence is the design. What // the row is about is which ARM renders it: the repeat never does, // and the first sighting is the one firing that must. - let repeat = deny_text(&refusal, false, None); + let repeat = refusal.render_finding(crate::refusal::Arm::Pointer); assert!( !repeat.contains("the fixture class"), "a repeat dereferences the gloss rather than carrying it: {repeat}" ); - let first = deny_text(&refusal, true, None); + let first = refusal.render_finding(crate::refusal::Arm::Full); assert!( first.contains("the fixture class"), "and the first sighting is where it does travel: {first}" @@ -13702,7 +13383,7 @@ deny contains "refused by themodule" if { ]))) else { panic!("a stale receipt must deny"); }; - let rendered = refusal.render(); + let rendered = refusal.render_finding(crate::refusal::Arm::Full); assert!(rendered.contains("linear-check"), "got: {rendered}"); // WHAT INVALIDATED IT IS THE CLASS, not a phrase inside a sentence // (CLOUD-1285, then CLOUD-1286). `receipt read other` is the amend-or- @@ -13768,7 +13449,7 @@ deny contains "refused by themodule" if { let Decision::Deny(refusal) = write_guarded("Write", "batten.toml") else { panic!("a declared write verb against a protected path must deny"); }; - let rendered = refusal.render(); + let rendered = refusal.render_finding(crate::refusal::Arm::Full); assert!(rendered.contains("Write"), "got: {rendered}"); assert!(rendered.contains("batten.toml"), "got: {rendered}"); assert!( @@ -14224,7 +13905,7 @@ deny contains "refused by themodule" if { refusal.bindings().first(), Some(&want), "{class} must bind the pointers its line prints: {}", - refusal.render() + refusal.render_finding(crate::refusal::Arm::Full) ); } } @@ -14250,7 +13931,7 @@ deny contains "refused by themodule" if { refusal.bindings(), [crate::admission::subject_as_bound("call name refused")], "{}", - refusal.render() + refusal.render_finding(crate::refusal::Arm::Full) ); assert_eq!(refusal.bindings(), ["call,name,refused"]); } @@ -14528,7 +14209,7 @@ deny contains "refused by themodule" if { ); assert!( !text.contains(" Fix: "), - "nor the remedy, which `batten policy explain` prints: {text}" + "nor a `Fix:` clause: the remedy rides the full arm as a sentence: {text}" ); assert!( text.contains(refusal.rule()), @@ -14551,9 +14232,11 @@ deny contains "refused by themodule" if { !text.contains("BYPASS"), "no deny advertises the hatch on the hot path: {text}" ); + // CLOUD-2075 REVERSES the half that followed: an override route is + // a way out, so it renders as the ready request on every firing. assert!( - !text.contains("batten override request"), - "and none composes an override command line per firing: {text}" + text.contains("admit with batten override request --rule '"), + "the override route is on the line, as the request that admits: {text}" ); } } @@ -14593,8 +14276,8 @@ deny contains "refused by themodule" if { // is the token and the pointer and stops (CLOUD-1286), so the gloss's // opening parenthesis is the thing that must NOT be there. assert!( - reason.starts_with("path write refused"), - "the hot path leads with the token: {reason}" + reason.starts_with("verdict 'path write refused'"), + "the hot path leads with the labelled token: {reason}" ); assert!( !reason.contains("path write refused ("), @@ -14623,8 +14306,10 @@ deny contains "refused by themodule" if { .contains("\"fix\":null"), "the key is present and null" ); - assert!(refusal.render().contains("Fix: none declared")); - assert!(refusal.render().contains("surface that owns it")); + // The way out is the row's own hop (CLOUD-2075), never a bare "no". + let line = refusal.render_finding(crate::refusal::Arm::Full); + assert!(line.contains("run batten policy rule 'some-row'"), "{line}"); + assert!(line.contains("it fired"), "{line}"); } /// Adjudicate against the protected fixture with a declared redirect table. @@ -15824,6 +15509,44 @@ deny contains "refused by themodule" if { ); } + /// The `PostToolUse` rewrite is emitted only where it is MEASURED + /// (CLOUD-2075 §7 case 0, CLOUD-1961's class). The document's shape is the + /// host's own `tool_response` with only `stdout`/`stderr` replaced, whatever + /// any harness declares; the gate is every harness's declaration. + #[test] + fn the_tool_output_rewrite_is_emitted_only_where_measured() { + let rewritten = serde_json::json!({ + "stdout": "rule 'r'; run batten policy rule 'r'", + "stderr": "", + "interrupted": false, + }); + let document: Value = serde_json::from_str( + &tool_output_document("PostToolUse", &rewritten).expect("serializes"), + ) + .expect("the document is JSON"); + assert_eq!( + document["hookSpecificOutput"]["hookEventName"], + "PostToolUse" + ); + assert_eq!( + document["hookSpecificOutput"]["updatedToolOutput"], + rewritten + ); + for harness in Harness::ALL { + let declared = harness.capabilities().rewrites_tool_output; + let encoded = + encode_tool_output(*harness, "PostToolUse", &rewritten).expect("serializes"); + if declared.is_capturable() { + assert!(encoded.is_some(), "{harness:?} declares the rewrite"); + } else { + assert_eq!(encoded, None, "{harness:?} is not measured to rewrite"); + } + // No live probe has answered for any host yet, so every row is + // `Unknown` until one does — never a guessed `Yes` or `No`. + assert_eq!(declared, Declaration::Unknown, "{harness:?}"); + } + } + /// A `warn` at `PreToolUse` reaches the agent, and this is the measurement. /// /// CLOUD-1131's acceptance clause is that the signal is OBSERVED reaching the @@ -16315,7 +16038,7 @@ deny contains "refused by themodule" if { ) else { panic!("a host-spelled write against a protected path must deny"); }; - let rendered = refusal.render(); + let rendered = refusal.render_finding(crate::refusal::Arm::Full); assert!(rendered.contains("WriteFile"), "got: {rendered}"); assert!( rendered.contains("change it in a pull request"), @@ -16506,6 +16229,8 @@ deny contains "refused by themodule" if { reads: None, cwd: None, session: None, + agent: None, + start_source: None, stop_active: None, last_message: None, transcript: None, diff --git a/crates/batten/src/hookcost.rs b/crates/batten/src/hookcost.rs index 9b13dace3..ae41263b3 100644 --- a/crates/batten/src/hookcost.rs +++ b/crates/batten/src/hookcost.rs @@ -211,19 +211,35 @@ pub const REPEAT_RULE: &str = "hook-repeat-pointer"; /// /// **No I/O and no clock**, which is what lets the second test tier run this over /// a fixture transcript and get the same answer the live path would. +/// +/// **Repeats are counted per compaction cycle** (CLOUD-2075): every +/// [`Event::SessionBoundary`] opens a new segment, because the contract is a +/// finding's full arm ONCE per cycle — so a full arm after a `SessionStart` is the +/// one copy that cycle holds, not a repeat. An emission carrying labelled +/// findings is judged per finding: a full arm counts per `(segment, key)`, and a +/// pointer arm never counts, since pointing is what a repeat is meant to do. +/// Unlabelled output keeps the `(segment, hook, digest)` key. +//MUTANT-SUITE crates/batten/src/hookcost.rs +//MUTANT segment-not-reset|s@^ segment += 1;$@ segment += 0;@|a_full_arm_after_a_session_start_is_not_a_repeat_and_a_pointer_arm_never_is #[must_use] pub fn measure(stream: &Stream) -> Reading { let mut per_hook: BTreeMap = BTreeMap::new(); - // Keyed on (producer, digest) so one hook saying two different things is two - // entries and two hooks saying one thing is two entries. Collapsing either - // way would report a repeat that nobody made. - let mut seen: BTreeMap<(String, String), (usize, usize)> = BTreeMap::new(); + // Keyed on (segment, producer, digest) so one hook saying two different + // things is two entries and two hooks saying one thing is two entries. + // Collapsing either way would report a repeat that nobody made. + let mut seen: BTreeMap<(usize, String, String), (usize, usize)> = BTreeMap::new(); let mut tokens = 0; + let mut segment: usize = 0; for record in &stream.records { + if record.event == Event::SessionBoundary { + segment += 1; + continue; + } let Event::HookOutput { hook, tokens: cost, digest, + findings, } = &record.event else { continue; @@ -235,15 +251,30 @@ pub fn measure(stream: &Stream) -> Reading { }); entry.tokens += cost; entry.emissions += 1; - let slot = seen - .entry((hook.clone(), digest.clone())) - .or_insert((0, record.line)); - slot.0 += 1; + if findings.is_empty() { + let slot = seen + .entry((segment, hook.clone(), digest.clone())) + .or_insert((0, record.line)); + slot.0 += 1; + continue; + } + for (key, arm) in findings { + if *arm == crate::refusal::Arm::Pointer { + continue; + } + // The key's last field is the definition's digest; the names before + // it are pointers, and the report carries only the digest prefix. + let digest = key.rsplit('\u{1f}').next().unwrap_or(key).to_owned(); + let slot = seen + .entry((segment, hook.clone(), digest)) + .or_insert((0, record.line)); + slot.0 += 1; + } } let repeats = seen .into_iter() .filter(|(_, (count, _))| *count > 1) - .map(|((hook, digest), (count, first_line))| Repeat { + .map(|((_, hook, digest), (count, first_line))| Repeat { hook, // A PREFIX. Eight characters name the thing in a report; the whole // digest would let a reader who already holds a candidate text @@ -334,10 +365,47 @@ mod tests { hook: hook.to_owned(), tokens, digest: digest.to_owned(), + findings: Vec::new(), + }, + } + } + + /// One emission carrying one labelled finding of `key` on `arm`. + fn finding_at(line: usize, key: &str, arm: crate::refusal::Arm) -> Record { + Record { + line, + event: Event::HookOutput { + hook: "PreToolUse:Bash".to_owned(), + tokens: 10, + digest: format!("{line:012}"), + findings: vec![(key.to_owned(), arm)], }, } } + #[test] + fn a_full_arm_after_a_session_start_is_not_a_repeat_and_a_pointer_arm_never_is() { + use crate::refusal::Arm; + let key = "r\u{1f}c\u{1f}abcdef0123456789"; + let boundary = |line| Record { + line, + event: Event::SessionBoundary, + }; + let mut records = vec![finding_at(1, key, Arm::Full), boundary(2)]; + records.push(finding_at(3, key, Arm::Full)); + records.extend((4..9).map(|line| finding_at(line, key, Arm::Pointer))); + let clean = measure(&session(records, 4_000)); + assert!(clean.repeats.is_empty(), "{:?}", clean.repeats); + assert!(judge(&clean, Some(&Ceiling::once())).is_empty()); + + let twice = measure(&session( + vec![finding_at(1, key, Arm::Full), finding_at(2, key, Arm::Full)], + 4_000, + )); + assert_eq!(twice.repeats.len(), 1, "two full arms in one cycle repeat"); + assert_eq!(judge(&twice, Some(&Ceiling::once())).len(), 1); + } + fn session(records: Vec, bytes: usize) -> Stream { Stream { session: Some("s-1".to_owned()), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 5555ab24b..043ff12fb 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -1624,7 +1624,7 @@ fn run_init( file = config::CONFIG_FILE )), ); - output::verdict(err, &refusal.render())?; + output::verdict(err, &refusal.render_finding(refusal::Arm::Full))?; Ok(ExitCode::Violation) } } @@ -1759,7 +1759,7 @@ fn run_hk( }], Fix::None, ); - output::verdict(err, &refusal.render())?; + output::verdict(err, &refusal.render_finding(refusal::Arm::Full))?; Ok(ExitCode::Violation) } } @@ -15917,6 +15917,7 @@ fn run_hook( // same `Fix::Run`, which is the safe direction and a visible one. if envelope.event == hook::Event::PostTool { record_post_tool(overrides, &envelope, harness, &mut advice); + collapse_seen_output(harness, &envelope, out)?; } // THE SAME CORRECTION AGAIN, ONE SELECTOR LATER (CLOUD-924). The paragraph // above records CLOUD-312 finding that "command-less" had stopped meaning @@ -16240,16 +16241,20 @@ fn run_hook( // a `retry` becomes a different refusal and a `silent` becomes an allow, and // advice about the original call would then be advice about a call that no // longer happened. - let decision = settle_repair(&policy, &envelope, decision); + let decision = settle_repair(&policy, &envelope, decision) + .map_refusal(|refusal| refusal.read_through(&policy.redirects)); let ceiling = policy.advisory.as_ref(); // A PRE-APPROVAL TAKES THE ADVICE INTO ITS OWN DOCUMENT (CLOUD-1949): two // documents on one stream is the collision above, with the grant as the // discarded one. let context = matches!(decision, hook::Decision::Preapproved(_)) && !advice.is_empty(); - let context = context.then(|| advisory::admit(std::mem::take(&mut advice), ceiling).text); + let context = context.then(|| { + let mut taken = std::mem::take(&mut advice); + sight_advice(&envelope, &mut taken); + advisory::admit(taken, ceiling).text + }); emit_channel(harness, &envelope, out, err, advice, ceiling, &decision)?; let rendering = Rendering { - ceiling: policy.refusal.as_ref(), context: context.as_deref(), }; render(harness, &envelope, decision, &rendering, mode, out, err) @@ -16583,11 +16588,8 @@ fn unadjudicable_remedy() -> Fix { /// [`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 no 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. +/// **The refusal is `unloaded`**: no `policy rule` hop resolves when the config +/// did not load, so its line names none (CLOUD-2075). //MUTANT refusal-drops-the-cause|s@^ .filter(\x7cline\x7c !is_source_excerpt(line))$@ .take(1)@|a_fact_row_that_states_no_returns_is_refused_at_load_over_the_binary fn deny_unadjudicable( harness: hook::Harness, @@ -16628,7 +16630,7 @@ fn deny_unadjudicable( .filter(|line| !line.is_empty()) .collect::>() .join(" "); - let refusal = Refusal::new( + let refusal = Refusal::unloaded( "engine-cannot-adjudicate", format!( "this build could not load the rules it is registered to enforce, so nothing judged \ @@ -16636,10 +16638,7 @@ fn deny_unadjudicable( ), unadjudicable_remedy(), ); - let rendering = Rendering { - ceiling: None, - context: None, - }; + let rendering = Rendering { context: None }; // 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). @@ -16988,14 +16987,18 @@ fn fill_turn_advice( raw: &str, advice: &mut Vec, ) { - if advice.is_empty() - && let Some(nudge) = hook::stop_advice(policy, envelope, facts) - .or_else(|| stop_nudges(overrides, envelope, raw)) - { - advice.push(advisory::Advice::new( - severity::AdvisoryTier::Caution, - nudge, - )); + if advice.is_empty() { + if let Some(refusal) = hook::stop_advice(policy, envelope, facts) { + advice.push(advisory::Advice::finding( + severity::AdvisoryTier::Caution, + refusal.read_through(&policy.redirects), + )); + } else if let Some(nudge) = stop_nudges(overrides, envelope, raw) { + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Caution, + nudge, + )); + } } // THE WRITE-TIME SIGNAL (CLOUD-1131), and it is the delivery half of the // demotion `hook::policy_rules` performs. A `mediated_call` module enabled at @@ -17011,19 +17014,37 @@ fn fill_turn_advice( // already spoke would make the signal arrive at some calls and not others for // reasons the reader cannot see. `policy_advice` is empty at `Stop` (the // block above owns that moment), and returns every non-blocking module's - // line on a call (CLOUD-1470); the equality test drops a line two bundles - // rendered identically. + // line on a call (CLOUD-1470); the key test drops a finding two bundles + // raised identically (CLOUD-2075). for refusal in hook::policy_advice(policy, envelope, facts) { - let signal = hook::render_advice(&refusal); - if !advice.iter().any(|entry| entry.text == signal) { - advice.push(advisory::Advice::new( + let refusal = refusal.read_through(&policy.redirects); + let key = refusal.sighting_key(); + let seen = advice.iter().any(|entry| { + entry + .finding + .as_ref() + .is_some_and(|other| other.sighting_key() == key) + }); + if !seen { + advice.push(advisory::Advice::finding( severity::AdvisoryTier::Warning, - signal, + refusal, )); } } } +/// Render each classed entry's finding at the moment it is emitted, through the +/// one chooser, so the context's store is marked only for what reaches it. +fn sight_advice(envelope: &hook::Envelope, advice: &mut [advisory::Advice]) { + for entry in advice { + if let Some(refusal) = entry.finding.take() { + let arm = arm_for(hook::HookSource::Harness, envelope, &refusal); + entry.text = refusal.render_finding(arm); + } + } +} + /// Reconcile what a handler said with what the engine decides. /// /// **A handler's refusal REPLACES the engine's decision; a handler's grant may @@ -17112,6 +17133,7 @@ fn collect_batch_advice( // process, in this order, the record describing what this session LOADED is // dropped before a repair can write one describing what it FIXED. expire_wiring_record(envelope); + expire_sightings(envelope, advice); repair_startup_rows(envelope, overrides); report_container_health(envelope, overrides, advice); Ok(()) @@ -17352,12 +17374,95 @@ fn expire_wiring_record(envelope: &hook::Envelope) { return; } let _ = wiring::clear_at_load(hook_authority_root()); - // AND THE CLASSES THIS SESSION HAS ALREADY BEEN TOLD (CLOUD-1386), on the - // same event and for the same reason: the event is the session identity, and - // a sighting that outlived its session would withhold a remedy from a reader - // who has never seen it. Clearing costs a directory removal; not clearing - // costs the exact defect the store exists to prevent. - refusal::forget_sightings(hook_authority_root()); +} + +/// Decide this context's sightings at `SessionStart`, per source (CLOUD-2075). +/// +/// **`compact` re-delivers eagerly**: every full arm the previous cycle saw goes +/// out in full on this event's `additionalContext`, and stays marked, so the +/// context holds exactly one copy at every moment. **Every other source** forgets +/// this context alone; its next firing is full. Other contexts in the clone are +/// never touched. +//MUTANT compact-not-redelivered|s@^ if envelope.start_source.as_deref() == Some(hook::COMPACT_SOURCE) {$@ if false {@|a_compaction_redelivers_every_seen_item_once_at_session_start +//MUTANT drain-result-kept|s@^ forget_drain_result(envelope);$@ let _ = forget_drain_result;@|a_session_start_relists_the_drain_payload +fn expire_sightings(envelope: &hook::Envelope, advice: &mut Vec) { + if envelope.event != hook::Event::SessionStart { + return; + } + forget_drain_result(envelope); + let Some(context) = envelope.context() else { + return; + }; + if envelope.start_source.as_deref() == Some(hook::COMPACT_SOURCE) { + for text in refusal::sighted(hook_authority_root(), &context) { + advice.push(advisory::Advice::delivered( + severity::AdvisoryTier::Warning, + text, + )); + } + return; + } + refusal::forget_sightings(hook_authority_root(), &context); +} + +/// Mark every finding a tool's output carries as seen in this context, and cut +/// the ones it had already seen to their pointer (CLOUD-2075 §D). +/// +/// A Bash child cannot see which context it prints into; the hook payload names +/// it. The marking happens on every harness, so a CLI first sighting joins the +/// same lifecycle as a hook one. The rewrite document is emitted only where the +/// host is measured to honour it ([`hook::encode_tool_output`]) and only when a +/// line changed. A session-less payload marks nothing. +fn collapse_seen_output( + harness: hook::Harness, + envelope: &hook::Envelope, + out: &mut dyn Write, +) -> Result<()> { + let Some(context) = envelope.context() else { + return Ok(()); + }; + let root = hook_authority_root(); + let mut rewritten = envelope.result.clone(); + let mut changed = false; + for stream in ["stdout", "stderr"] { + let Some(text) = envelope.result.get(stream).and_then(|value| value.as_str()) else { + continue; + }; + let cut = refusal::collapse(text, |key, full| { + refusal::first_sighting(root, &context, key, full) + }); + if cut != text { + changed = true; + rewritten[stream] = serde_json::Value::String(cut); + } + } + if changed + && let Some(body) = hook::encode_tool_output(harness, &envelope.raw_event, &rewritten)? + { + writeln!(out, "{body}")?; + } + Ok(()) +} + +/// Clear the drain's `unchanged` watermark for this session's lineage, so the +/// first drain of a new cycle lists in full (CLOUD-2075). Silent on every +/// failure: a missed clear costs one `unchanged` marker, never a verdict. +fn forget_drain_result(envelope: &hook::Envelope) { + let Some(session) = envelope.session.as_deref() else { + return; + }; + let Ok(repo) = git::repo_root(hook_authority_root()) else { + return; + }; + let Ok(resolved) = store::resolve(&repo) else { + return; + }; + let Some(dir) = store::bound_dir(&resolved) else { + return; + }; + if let Ok(root) = session::root(&dir, session) { + let _ = session::forget_result(&dir, &root); + } } /// Run the declared handlers for this envelope's event (CLOUD-898). @@ -17458,14 +17563,7 @@ fn dispatch_handlers( } return None; }; - if !envelope.event.carries_a_verdict() { - advice.push(advisory::Advice::new( - severity::AdvisoryTier::Caution, - format!("hook.handler.{id}: {reason}"), - )); - return None; - } - Some(hook::Decision::Deny(crate::refusal::Refusal::declared( + let refused = crate::refusal::Refusal::declared( format!("hook.handler.{id}"), verdict::Native::HandlerDenied, // THE HANDLER'S OWN WORDS TRAVEL AS A SUBJECT, not as the reason @@ -17482,7 +17580,16 @@ fn dispatch_handlers( // "every refusal names something to run" is now the REGISTRY's obligation // and `verdict::validate` refuses a class that fails it. crate::refusal::Fix::None, - ))) + ); + // The demotion is the SAME refusal, carried as a classed finding (CLOUD-2075). + if !envelope.event.carries_a_verdict() { + advice.push(advisory::Advice::finding( + severity::AdvisoryTier::Caution, + refused, + )); + return None; + } + Some(hook::Decision::Deny(refused)) } /// Assemble a `requires_key` row's checkout evidence (CLOUD-446). @@ -17702,6 +17809,10 @@ fn emit_channel( if advice.is_empty() || speaks_a_verdict { return Ok(()); } + // Marked only here, after the verdict's early return, so advice dropped + // beside a verdict is never recorded as seen (CLOUD-2075). + let mut advice = advice; + sight_advice(envelope, &mut advice); let emission = advisory::admit(advice, ceiling); emit_advisory(harness, envelope, out, err, &emission.text) } @@ -18152,13 +18263,10 @@ fn drain_advisories( Verbosity::Verbose, err, &format!( - "hook: drained {} line(s); withheld {} out of scope, {} over the cardinality cap, {} \ - over the token budget, {} flapping; {} rule(s) with a flapping identity", + "hook: drained {} line(s); withheld {} out of scope; {} rule(s) with a flapping \ + identity", drained.lines.len(), drained.scope_filtered.len(), - drained.capped.len(), - drained.over_budget.len(), - drained.flap_suppressed.len(), drained.flapping.len(), ), )?; @@ -20080,24 +20188,31 @@ fn load_exec_settings( /// What a refusal line needs to render, resolved by the caller. /// /// TWO VALUES THAT TRAVEL TOGETHER, and bundling them is what keeps [`render`]'s -/// signature honest rather than merely short: both are resolved from the policy at -/// the boundary, and NEITHER is an input to the decision. The hatch is a name the -/// renderer prints; the ceiling is a bound on how long the printed line may be. -/// Reaching for `policy` inside `render` to fetch either would give it back the -/// inputs CLOUD-898 says it must not have, over values that decide nothing about -/// the call. -/// -/// Growing this struct is therefore the test for a third one: it belongs here if -/// the renderer only prints or measures it, and belongs nowhere near here if the +/// signature honest rather than merely short: it is resolved at the boundary and +/// is NOT an input to the decision. No renderer takes a ceiling (CLOUD-2075): a +/// ceiling reports in the corpus case and never sheds a pointer. +/// +/// Growing this struct is the test for another value: it belongs here if the +/// renderer only prints or measures it, and belongs nowhere near here if the /// renderer would have to branch on it to decide. struct Rendering<'a> { - /// What one emitted mediated line may cost, or no declared bound. - ceiling: Option<&'a refusal::Ceiling>, /// The admitted advice a pre-approval carries in its own document /// (CLOUD-1949). Printed, never branched on — the test above. context: Option<&'a str>, } +/// The one chooser between a finding's two arms (CLOUD-2075): the source's +/// declared lifecycle, consulted against the context's store. +fn arm_for(source: hook::HookSource, envelope: &hook::Envelope, refusal: &Refusal) -> refusal::Arm { + match (source.finding_lifecycle(), envelope.context()) { + (hook::Lifecycle::Sighted, Some(context)) => { + refusal::sight(hook_authority_root(), &context, refusal) + } + (hook::Lifecycle::Sighted, None) | (hook::Lifecycle::PrintedFull, _) => refusal::Arm::Full, + } +} + +//MUTANT ask-arm-sighted|s@^ let asked = refusal.render_finding(refusal::Arm::Full);$@ let asked = refusal.render_finding(arm_for(hook::HookSource::Harness, envelope, \&refusal));@|an_ask_carries_the_full_arm_on_every_firing fn render( harness: hook::Harness, envelope: &hook::Envelope, @@ -20107,7 +20222,7 @@ fn render( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - let Rendering { ceiling, context } = *rendering; + let Rendering { context } = *rendering; // THE DECISION ARRIVES AS A VALUE, which is what makes this a renderer // rather than a second adjudicator (CLOUD-898). A handler's refusal and the // engine's own reach the host through the identical match below: a @@ -20173,20 +20288,11 @@ fn render( // whether it named a fix. hook::Decision::Deny(refusal) => { // THE EFFECT IS HERE AND THE RENDERING IS NOT (CLOUD-1386). - // `deny_text` decides between two projections and stays pure; - // consulting-and-marking a store is a write, and a write belongs at - // the boundary with every other one. A renderer that touched the disk - // would also be one no test could drive twice. - // KEYED ON THE RULE AND THE CLASS (CLOUD-1637). It was keyed on the - // class token alone, which meant an UNDECLARED refusal — no token — - // skipped the store and always read as a first sighting, so its long - // form repeated forever. Every refusal has a key now, so both arms - // consult the store and both are bounded; - // `refusal::first_sighting` carries why neither name alone is the - // right key and what was measured in each direction. - let first_sighting = - refusal::first_sighting(hook_authority_root(), &refusal.sighting_key()); - let reason = hook::deny_text(&refusal, first_sighting, ceiling); + // `render_finding` projects and stays pure; consulting-and-marking + // the context's store is a write, and a write belongs at the boundary + // with every other one. `arm_for` is the one chooser (CLOUD-2075). + let arm = arm_for(hook::HookSource::Harness, envelope, &refusal); + let reason = refusal.render_finding(arm); match hook::encode_deny(harness, &envelope.raw_event, &reason)? { Some(body) => { writeln!(out, "{body}")?; @@ -20211,19 +20317,16 @@ fn render( // withholding the remedy to save a clause would be spending their // attention rather than the model's. // - // IT ALSO DOES NOT TAKE THE REFUSAL'S PROJECTION (CLOUD-1637). It used - // to call `deny_text` with `first_sighting = true`, which was the right - // answer to the wrong question: the arms of `deny_text` differ in how - // much an AGENT has already read, and a person has read none of it and - // will not run a lookup to answer the question in front of them. - // `ask_text` carries why the long form is theirs. - let reason = hook::ask_text(&refusal); - match hook::encode_ask(harness, &envelope.raw_event, &reason)? { + // The full arm on every firing (CLOUD-2075): a person has read none + // of the earlier firings and will not run a lookup to answer the + // question in front of them. + let asked = refusal.render_finding(refusal::Arm::Full); + match hook::encode_ask(harness, &envelope.raw_event, &asked)? { Some(body) => { writeln!(out, "{body}")?; Ok(ExitCode::Success) } - None => Err(Denial::raise(reason)), + None => Err(Denial::raise(asked)), } } // The pre-approval, and it is the mirror image of the arm above it. An diff --git a/crates/batten/src/perf.rs b/crates/batten/src/perf.rs index eb92c146f..3f75a5178 100644 --- a/crates/batten/src/perf.rs +++ b/crates/batten/src/perf.rs @@ -2825,39 +2825,24 @@ pub struct RenderRecord { /// `policy-budget` already gates instruction files with, rather than a new /// bytes-over-four approximation wearing a precise-looking unit. pub tokens: usize, - /// The rendered line itself, so the tier can compare it to - /// [`crate::refusal::Refusal::line`] rather than to a length. + /// The rendered line itself, so the tier can compare it to the pointer arm + /// rather than to a length. No renderer takes a ceiling (CLOUD-2075), so this + /// is the whole arm. pub line: String, - /// The same arm rendered with NO ceiling, and it is the column that makes the - /// shipped one legible. - /// - /// The committed `[refusal] max_tokens` bounds the carried line, and when a - /// class's routes take it over that bound `deny_text` falls back to the - /// compact form — so a table of the shipped rendering alone can report every - /// arm as equal and look like a broken measurement rather than like a budget - /// doing its job. This is what the first sighting WOULD cost if the ceiling - /// permitted it, which is both the explanation and the number a successor - /// weighing a residency protocol actually needs. - pub unbounded_characters: usize, - /// [`crate::budget::estimate_tokens`] over that same unbounded rendering. - pub unbounded_tokens: usize, } /// Render every strategy × residency × class arm through the shipped path. /// -/// The registry and ceiling are the caller's, so the committed authority is what -/// gets measured: a bench that vendored its own registry would price a class -/// nobody is ever refused under. +/// The registry is the caller's, so the committed authority is what gets +/// measured: a bench that vendored its own registry would price a class nobody +/// is ever refused under. /// /// # Errors /// /// A class this repository does not declare. That is a property of the /// configuration rather than a verdict about the cost, and reporting a /// zero-length rendering for an absent class would be a measurement of nothing. -pub fn refusal_render( - registry: &[crate::verdict::DeclaredVerdict], - ceiling: Option<&crate::refusal::Ceiling>, -) -> Result> { +pub fn refusal_render(registry: &[crate::verdict::DeclaredVerdict]) -> Result> { let mut records = Vec::new(); for (class, rule) in MEASURED_CLASSES { let refusal = crate::refusal::Refusal::from_class( @@ -2876,8 +2861,12 @@ pub fn refusal_render( for strategy in Strategy::ALL { for residency in Residency::ALL { let first = first_sighting(*strategy, *residency); - let line = crate::hook::deny_text(&refusal, first, ceiling); - let unbounded = crate::hook::deny_text(&refusal, first, None); + let arm = if first { + crate::refusal::Arm::Full + } else { + crate::refusal::Arm::Pointer + }; + let line = refusal.render_finding(arm); records.push(RenderRecord { strategy: *strategy, residency: *residency, @@ -2886,8 +2875,6 @@ pub fn refusal_render( characters: line.chars().count(), tokens: crate::budget::estimate_tokens(&line), line, - unbounded_characters: unbounded.chars().count(), - unbounded_tokens: crate::budget::estimate_tokens(&unbounded), }); } } @@ -2982,58 +2969,6 @@ fn refusal_render_preamble() -> String { out } -/// What the declared ceiling actually did to these records, as opposed to what -/// it is declared to do. -/// -/// **DERIVED, NEVER ASSERTED, and that distinction is why this function exists.** -/// This paragraph used to state flatly that the ceiling withholds the carried -/// routes, which was true when it was written and false one renderer change -/// later — CLOUD-1637 landed, every emitted figure became its unbounded one, and -/// the report went on explaining a suppression that was no longer happening. A -/// sentence about a measurement has to be computed from it. -fn refusal_render_ceiling_note( - records: &[RenderRecord], - ceiling: Option<&crate::refusal::Ceiling>, -) -> String { - use std::fmt::Write as _; - - let mut out = String::new(); - let Some(declared) = ceiling else { - out.push_str( - "**No `[refusal]` ceiling is declared**, so the emitted and unbounded columns below \ - are the same rendering.\n\n", - ); - return out; - }; - let withheld = records - .iter() - .filter(|record| record.characters < record.unbounded_characters) - .count(); - if withheld == 0 { - let _ = writeln!( - out, - "**The declared `[refusal] max_tokens` is {}, and on this tree it withholds \ - NOTHING**: every emitted figure below equals its unbounded one, so the ceiling is \ - declared and inert over these classes rather than shaping the numbers. The \ - `unbounded` columns are kept because that is a fact about today's registry, not a \ - property of the bound.\n", - declared.max_tokens - ); - } else { - let _ = writeln!( - out, - "**The declared `[refusal] max_tokens` is {}, and it is an input to {} of the {} \ - arms below**, where the rendered line would exceed it and `deny_text` falls back to \ - the compact form. The `unbounded` columns are those arms rendered with no ceiling — \ - what the sighting would cost if the budget permitted it.\n", - declared.max_tokens, - withheld, - records.len() - ); - } - out -} - /// Whether any measured class has nothing for a residency protocol to withhold. /// /// **THE SAME LESSON AS THE CEILING NOTE, one paragraph over.** This text used to @@ -3078,10 +3013,7 @@ fn refusal_render_margin_note(records: &[RenderRecord]) -> String { out, "**A zero in the emitted column is a measurement, not a gap in the table**, and \ {} of the measured classes reach it: {}. A class whose first sighting renders \ - exactly what its repeat renders has nothing for a residency protocol to withhold, \ - whether because the renderer appends nothing for it or because the declared ceiling \ - withholds what it would have appended. The unbounded columns are what tell those \ - two apart.\n", + exactly what its repeat renders has nothing for a residency protocol to withhold.\n", flat.len(), flat.join(", ") ); @@ -3168,17 +3100,18 @@ fn refusal_render_verdict(records: &[RenderRecord]) -> String { /// it is byte-stable under no commit — and the crate version is absent for the /// same reason one release later: release-plz bumps it in a commit that renders /// nothing differently, so a version in the body would redden the drift check on -/// every release. The baseline is the declared class ids and the declared -/// ceiling, which are what the rendering reads. +/// every release. The baseline is the declared class ids, which are what the +/// rendering reads; no renderer takes a ceiling (CLOUD-2075). #[must_use] -pub fn refusal_render_report( - records: &[RenderRecord], - ceiling: Option<&crate::refusal::Ceiling>, -) -> String { +pub fn refusal_render_report(records: &[RenderRecord]) -> String { use std::fmt::Write as _; let mut out = refusal_render_preamble(); - out.push_str(&refusal_render_ceiling_note(records, ceiling)); + out.push_str( + "**Nothing is shed.** No renderer takes a ceiling: `[refusal]`'s keys are measured \ + by the corpus case and report an over-ceiling line rather than truncating one, so \ + every figure below is the whole arm.\n\n", + ); for (class, rule) in MEASURED_CLASSES { let _ = writeln!(out, "## `{class}` (rule `{rule}`)\n"); @@ -3188,8 +3121,6 @@ pub fn refusal_render_report( "first sighting".to_owned(), "emitted characters".to_owned(), "emitted tokens".to_owned(), - "unbounded characters".to_owned(), - "unbounded tokens".to_owned(), ]]; for record in records.iter().filter(|record| record.class == *class) { rows.push(vec![ @@ -3198,8 +3129,6 @@ pub fn refusal_render_report( record.first_sighting.to_string(), record.characters.to_string(), record.tokens.to_string(), - record.unbounded_characters.to_string(), - record.unbounded_tokens.to_string(), ]); } out.push_str(&markdown_table(&rows)); @@ -3221,17 +3150,12 @@ pub fn refusal_render_report( out, "- **`{class}`** — a warm repeat emits {} characters ({} tokens) today against \ {} ({} tokens) delivered in full every time: **{} characters saved per repeat \ - firing**. Unbounded, the same comparison is {} against {}: **{} characters** — \ - what a residency protocol would have to deliver, and withhold, per firing.", + firing**.", compact.characters, compact.tokens, full.characters, full.tokens, full.characters.saturating_sub(compact.characters), - compact.unbounded_characters, - full.unbounded_characters, - full.unbounded_characters - .saturating_sub(compact.unbounded_characters) ); } } diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index febc9a9be..c9eb5d14f 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -233,16 +233,29 @@ pub struct Refusal { /// The id that refused: a `[[rule]]` row's id, or a derived gate's declared /// constant. What a reviewer greps for in `batten.toml`. rule: String, - /// Every non-override route the class declares, rendered by kind, for the - /// once-per-session sighting (CLOUD-1386, CLOUD-1637). + /// Every route the class declares, override routes included, unrendered + /// (CLOUD-2075). [`finding_line`] renders them by kind on BOTH arms. /// /// **Skipped in serialization**, because it is a RENDERING input rather than /// part of the refusal payload: a consumer of `{rule, verdict, reason, fix}` /// asked for the remedy, and `fix` is still that. Resolved here because this - /// is where the registry is already in hand, which keeps `deny_text` pure and - /// the boundary free of a second registry lookup. + /// is where the registry is already in hand, which keeps the projection pure + /// and the boundary free of a second registry lookup. #[serde(skip)] - routes: Vec, + routes: Vec, + /// The rendered pointers, as [`crate::verdict::render_subjects`] spells them + /// (CLOUD-2075). The ` at ` clause of both arms. + #[serde(skip)] + subjects: String, + /// Which reader a document route's target is read through, where the + /// consumer declares one (`[[redirect]] read`), keyed by target + /// ([`Refusal::read_through`]). + #[serde(skip)] + readers: std::collections::BTreeMap, + /// Whether `batten policy rule ''` resolves this refusal's row: false + /// only where the config did not load ([`Refusal::unloaded`]). + #[serde(skip)] + dereferenceable: bool, /// The one-line gloss of the declared class, for the first-sighting arm /// (CLOUD-1637). /// @@ -342,16 +355,6 @@ fn admission_bindings(token: &str, subjects: &[crate::verdict::Subject]) -> Vec< spellings } -/// What [`Fix::None`] renders as: the gap, stated, plus the general recourse. -/// -/// A refusal with no declared alternative still owes the caller *something* — the -/// contract is that a block gets an agent to right in one hop — so the crate's own -/// general answer stands in. It is deliberately generic: which surface owns a -/// given path is the consumer's knowledge, and CLOUD-280 is where a path class -/// gets to declare it. -const NO_DECLARED_FIX: &str = - "none declared — change it through the surface that owns it, or restore it with git"; - /// Whether this RULE has already explained itself this session, marking it if /// not (CLOUD-1386, re-keyed by CLOUD-1637). /// @@ -399,22 +402,25 @@ const NO_DECLARED_FIX: &str = /// actionable, and reported a working gate as a design defect. Neither "always" /// nor "never" is right. "Once" is. /// -/// SCOPED TO THE SESSION. The store lives under `$GIT_DIR`, -/// so it dies with the container and is cleared at `SessionStart` beside the -/// wiring record — which is the same identity `expire_wiring_record` uses, and -/// for the reason stated there: the event IS the session. +/// SCOPED TO THE CONTEXT, NOT THE CLONE (CLOUD-2075). The store lives under +/// `$GIT_DIR/batten-sightings//`, where the context +/// is the session plus the agent id where a subagent is the reader — a +/// subagent's first sighting used to come back compact because another context +/// in the clone had already marked it. Each file holds the FULL arm's text, so a +/// compaction can re-deliver it. +/// +/// **Compaction is a `SessionStart` with `source: compact`**, and it is visible: +/// the boundary re-delivers every full arm this context holds on that event and +/// keeps the marks, so the context holds exactly one copy at every moment. Any +/// other source forgets this context alone. /// /// **A failure to read or write answers TRUE**, which is the direction that /// matters: an unreadable store means the class explains itself again, costing a /// clause. The opposite default would silently withhold the remedy from a reader /// who has never seen it, which is the whole defect. -/// -/// Compaction is invisible from here, so "per session" is the implementable -/// approximation of "per reader" — and it errs toward repeating rather than -/// assuming what a reader retained. #[must_use] -pub fn first_sighting(root: &Path, key: &str) -> bool { - let Some(dir) = crate::git::git_dir(root).ok().map(|dir| dir.join(STORE)) else { +pub fn first_sighting(root: &Path, context: &str, key: &str, full: &str) -> bool { + let Some(dir) = context_dir(root, context) else { return true; }; // One file per key rather than a list: two refusals firing concurrently @@ -428,20 +434,155 @@ pub fn first_sighting(root: &Path, key: &str) -> bool { let _ = std::fs::create_dir_all(&dir); // Discarded deliberately: an unwritable store means the next firing explains // itself again, which is the safe direction. - let _ = crate::durable::replace(&path, key); + let _ = crate::durable::replace(&path, full); true } -/// Forget every class explained under the previous session (CLOUD-1386). +/// The directory one context's sightings live in. +//MUTANT sighting-context-ignored|s@^ Some(store.join(crate::provision::digest(context.as_bytes())))$@ Some(store.join(crate::provision::digest(b"")))@|two_contexts_in_one_clone_each_get_the_full_text +fn context_dir(root: &Path, context: &str) -> Option { + let store = crate::git::git_dir(root).ok()?.join(STORE); + Some(store.join(crate::provision::digest(context.as_bytes()))) +} + +/// Which arm this firing of `refusal` gets in `context`, marking it seen. +//MUTANT sight-always-first|s@^ if !first_sighting(root, context, \&key, \&full) {$@ if false {@|a_warn_advisory_is_full_once_then_a_pointer +#[must_use] +pub fn sight(root: &Path, context: &str, refusal: &Refusal) -> Arm { + let full = refusal.render_finding(Arm::Full); + let key = refusal.sighting_key(); + if !first_sighting(root, context, &key, &full) { + return Arm::Pointer; + } + Arm::Full +} + +/// Forget every item this ONE context has seen (CLOUD-2075). Other contexts in +/// the clone are never touched. +//MUTANT forget-sightings-noop|s@^ let _ = std::fs::remove_dir_all(dir);$@ let _ = dir;@|a_session_start_forgets_only_that_contexts_sightings +pub fn forget_sightings(root: &Path, context: &str) { + if let Some(dir) = context_dir(root, context) { + let _ = std::fs::remove_dir_all(dir); + } +} + +/// Every full arm this context has seen, sorted so re-delivery is byte-stable. +#[must_use] +pub fn sighted(root: &Path, context: &str) -> Vec { + let Some(dir) = context_dir(root, context) else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut texts: Vec = entries + .filter_map(Result::ok) + .filter_map(|entry| std::fs::read_to_string(entry.path()).ok()) + .collect(); + texts.sort_unstable(); + texts +} + +/// An item's identity: the rule, the class, and a digest of its DEFINITION, so +/// an edit to a gloss, route, precondition or reason mid-cycle is a new item +/// that renders in full again (CLOUD-1582, absorbed). +//MUTANT sighting-key-ignores-definition|s@^ let digest = crate::provision::digest(definition.as_bytes());$@ let digest = crate::provision::digest(b"");@|an_edited_definition_is_a_new_item_mid_cycle +fn key_of(rule: &str, class: Option<&str>, definition: &str) -> String { + let digest = crate::provision::digest(definition.as_bytes()); + format!("{rule}\u{1f}{}\u{1f}{digest}", class.unwrap_or_default()) +} + +/// One finding line read back: its names, its arm and its key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Parsed { + /// The class, where the line is labelled with one. + pub verdict: Option, + /// The rule id. + pub rule: String, + /// `Full` iff the line carries the ` — ` definition clause. + pub arm: Arm, + /// [`key_of`] over the line's definition. + pub key: String, +} + +/// Read one quoted name from the front of `text`, returning it and the rest. +fn unquote(text: &str) -> Option<(String, &str)> { + let mut rest = text.strip_prefix('\'')?; + let mut name = String::new(); + loop { + let index = rest.find(['\'', '\\'])?; + name.push_str(&rest[..index]); + if rest[index..].starts_with("\\'") { + name.push('\''); + rest = &rest[index + 2..]; + } else if rest[index..].starts_with('\'') { + return Some((name, &rest[index + 1..])); + } else { + name.push('\\'); + rest = &rest[index + 1..]; + } + } +} + +/// The ONE reader of the finding grammar +/// `[verdict '' ]rule ''[ at ](; )*[ — ]`. /// -/// Called from the `SessionStart` arm beside the wiring record's own clear. A -/// store that outlived its session would withhold a remedy from a reader who has -/// not seen it, which is the failure this exists to prevent — so the clear is the -/// load-bearing half, not the bookkeeping half. -pub fn forget_sightings(root: &Path) { - if let Ok(dir) = crate::git::git_dir(root) { - let _ = std::fs::remove_dir_all(dir.join(STORE)); +/// `None` for a line that does not open with a label, which is every line that +/// is not a finding. +#[must_use] +pub fn parse_finding(line: &str) -> Option { + let mut rest = line; + let mut verdict = None; + if let Some(after) = rest.strip_prefix("verdict ") { + let (name, tail) = unquote(after)?; + verdict = Some(name); + rest = tail.strip_prefix(' ')?; + } + let (rule, _) = unquote(rest.strip_prefix("rule ")?)?; + let (head, tail) = match line.split_once(" —") { + Some((head, tail)) => (head, Some(tail)), + None => (line, None), + }; + let arm = if tail.is_some() { + Arm::Full + } else { + Arm::Pointer + }; + // THE DEFINITION IS THE LINE MINUS ITS PER-FIRING PARTS: the labels and + // subjects (the first `; `-segment) and every override request, whose + // `--subject` varies per firing. + let routes: Vec<&str> = head + .split("; ") + .skip(1) + .filter(|segment| !segment.starts_with(OVERRIDE_OPENER)) + .collect(); + let definition = format!("{} —{}", routes.join("; "), tail.unwrap_or_default()); + let key = key_of(&rule, verdict.as_deref(), &definition); + Some(Parsed { + verdict, + rule, + arm, + key, + }) +} + +/// Cut every full arm `mark` reports already seen to its pointer prefix, +/// leaving every other line byte-identical (CLOUD-2075 §D). `mark(key, full)` +/// consults and marks the reader's store and answers whether this is the first +/// sighting. +//MUTANT boundary-collapse-skipped|s@^ let rewritten = if arm == Arm::Pointer { pointer } else { body };$@ let rewritten = body;@|collapse_cuts_a_marked_full_arm_to_its_pointer_prefix +pub fn collapse(text: &str, mut mark: impl FnMut(&str, &str) -> bool) -> String { + let mut lines: Vec<&str> = Vec::new(); + for body in text.split('\n') { + let arm = match parse_finding(body) { + Some(parsed) if parsed.arm == Arm::Full && !mark(&parsed.key, body) => Arm::Pointer, + Some(_) | None => Arm::Full, + }; + let pointer = body.split(" —").next().unwrap_or(body); + let rewritten = if arm == Arm::Pointer { pointer } else { body }; + lines.push(rewritten); } + lines.join("\n") } /// Where the per-session sightings live, under `$GIT_DIR`. @@ -451,6 +592,175 @@ pub fn forget_sightings(root: &Path) { /// receipts would put a note where every reader expects a claim. const STORE: &str = "batten-sightings"; +/// Which of a finding's two renderings a firing gets (CLOUD-2075). +/// +/// The pointer arm is a byte PREFIX of the full arm and carries the same +/// subjects and routes; the full arm adds the ` — ` tail. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Arm { + /// Labels, subjects, every route and the definition — once per context per + /// compaction cycle. + Full, + /// Labels, subjects and every route, on every other firing. + Pointer, +} + +/// The two names a finding line labels, so a reader can always tell a rule +/// from a verdict (CLOUD-2075). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Label { + /// A declared class, dereferenced by `batten policy explain`. + Verdict, + /// A `[[rule]]` id, dereferenced by `batten policy rule`. + Rule, +} + +impl Label { + /// The label's word. + #[must_use] + pub const fn word(self) -> &'static str { + match self { + Label::Verdict => "verdict", + Label::Rule => "rule", + } + } +} + +/// The ONE spelling of `verdict ''` and `rule ''`. Every emitter +/// that labels a name calls this; `emission_census` refuses a hand-spelled one. +#[must_use] +pub fn label(kind: Label, name: &str) -> String { + format!("{} {}", kind.word(), quoted(name)) +} + +/// A name in single quotes. A `'` inside it is written `\'`; [`parse_finding`] +/// undoes exactly that. +fn quoted(name: &str) -> String { + format!("'{}'", name.replace('\'', "\\'")) +} + +/// Pointer text made safe for the grammar: it can never contain the tail +/// opener or the clause separator. +fn plain(text: &str) -> String { + text.replace(" — ", " - ").replace("; ", ", ") +} + +/// How every rendered override route opens; [`parse_finding`] drops these +/// segments from an item's definition because their subject varies per firing. +const OVERRIDE_OPENER: &str = "admit with batten override request "; + +/// CLOUD-1806's class route. The hop [`finding_line`] appends names the same +/// verb with the real id, so a route whose target is this placeholder is +/// dropped at render rather than printed twice. +pub(crate) const RULE_HOP_PLACEHOLDER: &str = "batten policy rule ''"; + +/// The one projection every channel carries (CLOUD-2075): +/// `[verdict '' ]rule ''[ at ](; )*[; run batten policy rule ''][ — ]`. +/// +/// Both labels are printed even where the id equals the token, so a reader can +/// always tell a rule from a verdict. Every route — override routes included, +/// each a ready `override request` with the subject the refusal binds — is on +/// BOTH arms, so the pointer arm sheds no way out. The full arm adds the +/// definition: the class gloss (or the undeclared refusal's reason), the row's +/// own remedy, each override's precondition, and the `explain` hop. +/// +/// Nothing is shed and no ceiling is consulted: `[refusal]`'s keys are +/// measured by `refusal_ceiling`'s corpus case, which reports an over-ceiling +/// line rather than truncating one. +//MUTANT full-arm-reason-dropped|s@^ if let Some(remedy) = refusal.remedy() {$@ if let Some(remedy) = None::<\&str> {@|a_first_sighting_carries_the_rows_reason_and_both_labels +//MUTANT pointer-arm-routes-dropped|s@^ for route in routes(refusal) {$@ for route in routes(refusal).into_iter().take(if arm == Arm::Full { usize::MAX } else { 0 }) {@|the_pointer_arm_carries_every_route_and_subject_the_full_arm_does +//MUTANT collapsed-row-unlabelled|s@^ if let Some(token) = refusal.verdict() {$@ if let Some(token) = refusal.verdict() \&\& token != refusal.rule() {@|a_collapsed_row_still_labels_rule_and_verdict +//MUTANT advice-routes-dropped|s@^ for route in routes(refusal) {$@ for route in routes(refusal).into_iter().take(0) {@|a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call +//MUTANT rule-label-dropped|s@^ line.push_str(\&label(Label::Rule, refusal.rule()));$@ line.push_str(refusal.rule());@|every_hook_policy_table_deny_names_its_fix +fn finding_line(refusal: &Refusal, arm: Arm) -> String { + let mut line = String::new(); + if let Some(token) = refusal.verdict() { + line.push_str(&label(Label::Verdict, token)); + line.push(' '); + } + line.push_str(&label(Label::Rule, refusal.rule())); + if !refusal.subjects.is_empty() { + line.push_str(" at "); + line.push_str(&plain(&refusal.subjects)); + } + for route in routes(refusal) { + line.push_str("; "); + line.push_str(&route); + } + if refusal.dereferenceable { + line.push_str("; run batten policy rule "); + line.push_str("ed(refusal.rule())); + } + if arm == Arm::Pointer { + return line; + } + line.push_str(" —"); + let definition = if refusal.verdict.is_some() { + &refusal.gloss + } else { + &refusal.reason + }; + if !definition.is_empty() { + line.push(' '); + line.push_str(&sentence(definition)); + } + if let Some(remedy) = refusal.remedy() { + line.push(' '); + line.push_str(&sentence(remedy)); + } + for (id, precondition) in refusal.preconditions() { + line.push_str(&format!( + " Admissible as {} when {}", + quoted(id), + sentence(precondition) + )); + } + if let Some(class) = refusal.verdict() { + line.push_str(" Run batten policy explain "); + line.push_str("ed(class)); + line.push('.'); + } + line +} + +/// Every route rendered, de-duplicated, in declaration order. +fn routes(refusal: &Refusal) -> Vec { + let mut rendered: Vec = Vec::new(); + for route in &refusal.routes { + if let Some(text) = route_text(refusal, route) + && !rendered.contains(&text) + { + rendered.push(text); + } + } + rendered +} + +/// One route's text, or `None` where it does not render. +//MUTANT override-route-unrendered|s@^ if route.kind == RouteKind::Override {$@ if route.kind == RouteKind::Override \&\& false {@|the_override_route_on_the_line_is_the_request_that_admits +//MUTANT route-reader-unnamed|s@^ Some(reader) => format!("{text} via {reader}"),$@ Some(_) => text,@|a_document_route_into_a_read_redirected_path_names_its_reader +fn route_text(refusal: &Refusal, route: &crate::verdict::Route) -> Option { + use crate::verdict::RouteKind; + if route.kind == RouteKind::Override { + let class = refusal.verdict()?; + let subject = refusal.bindings().first()?; + return Some(format!( + "{OVERRIDE_OPENER}--rule {} --verdict {} --subject {}", + quoted(refusal.rule()), + quoted(class), + quoted(subject), + )); + } + if route.target == RULE_HOP_PLACEHOLDER { + return None; + } + let text = plain(&crate::verdict::render_route(route)?); + Some(match refusal.readers.get(&route.target) { + Some(reader) => format!("{text} via {reader}"), + None => text, + }) +} + impl Refusal { /// Build a refusal. The [`Fix`] is required, which is the contract. pub fn new(rule: impl Into, reason: impl Into, fix: Fix) -> Refusal { @@ -461,8 +771,11 @@ impl Refusal { // rendering arm asks whether there is anything to say, not whether a // class exists. routes: Vec::new(), + subjects: String::new(), + readers: std::collections::BTreeMap::new(), + dereferenceable: true, // Likewise: no class, so no gloss. The undeclared arm's payload is - // the consumer's own `reason`, which `render` already carries. + // the consumer's own `reason`, which the full arm carries. gloss: String::new(), verdict: None, reason: reason.into(), @@ -474,6 +787,65 @@ impl Refusal { } } + /// The pointers this firing names, for a refusal composed from prose + /// (CLOUD-2075). A declared refusal takes them from its subjects instead. + #[must_use] + pub fn at(mut self, subjects: impl Into) -> Refusal { + self.subjects = subjects.into(); + self + } + + /// The refusal for a config that did not load: no `policy rule` hop + /// resolves, so the line names none (CLOUD-2075). + #[must_use] + pub fn unloaded(rule: impl Into, reason: impl Into, fix: Fix) -> Refusal { + Refusal { + dereferenceable: false, + ..Refusal::new(rule, reason, fix) + } + } + + /// Name the reader each document route's target is read through, where the + /// consumer's `[[redirect]]` declares one (CLOUD-2075). + #[must_use] + pub fn read_through(mut self, redirects: &[crate::redirect::Redirect]) -> Refusal { + for route in &self.routes { + if route.kind == crate::verdict::RouteKind::Document + && let Some(reader) = crate::redirect::resolve_read(redirects, &route.target) + { + self.readers.insert(route.target.clone(), reader.to_owned()); + } + } + self + } + + /// The one projection (CLOUD-2075); see [`finding_line`]. + #[must_use] + pub fn render_finding(&self, arm: Arm) -> String { + finding_line(self, arm) + } + + /// The row's own remedy: the `Fix::Run` text, unless it is one of the + /// class's route targets already on the line. + #[must_use] + pub fn remedy(&self) -> Option<&str> { + let text = self.fix.declared_alternative()?; + if self.routes.iter().any(|route| route.target == text) { + return None; + } + Some(text) + } + + /// `(id, precondition)` for each override route the class declares. + #[must_use] + pub fn preconditions(&self) -> Vec<(&str, &str)> { + self.routes + .iter() + .filter(|route| route.kind == crate::verdict::RouteKind::Override) + .filter_map(|route| Some((route.id.as_str(), route.precondition.as_deref()?))) + .collect() + } + /// Build one of Batten's OWN refusals, from a declared class (CLOUD-1050). /// /// The caller names a [`crate::verdict::Native`] variant and the pointers it @@ -526,7 +898,12 @@ impl Refusal { // Resolved here rather than at the boundary because the registry is // already in hand — a second lookup downstream would be a second // authority over which routes the class declares. - routes: crate::verdict::sighting_routes(registry, token), + routes: crate::verdict::resolve(registry, token) + .map(|(entry, _)| entry.routes.clone()) + .unwrap_or_default(), + subjects: crate::verdict::render_subjects(subjects), + readers: std::collections::BTreeMap::new(), + dereferenceable: true, gloss: crate::verdict::gloss_of(registry, token) .unwrap_or_default() .to_owned(), @@ -583,26 +960,24 @@ impl Refusal { /// rules/scanning.md` is what the reader of a refused `grep` needed. /// [`crate::verdict::sighting_routes`] carries the mapping and the exhaustive /// match that keeps a future kind from being dropped silently. + /// + /// Rendered exactly as the line carries them, override routes included + /// (CLOUD-2075). #[must_use] - pub fn routes(&self) -> &[String] { - &self.routes + pub fn routes(&self) -> Vec { + routes(self) } - /// What the sightings store keys this refusal by: the rule and the class. - /// - /// Composed here rather than at the boundary because it is a fact about the - /// refusal, and because the two halves are private. The separator is a unit - /// separator, which neither a rule id nor a three-word class can contain, so - /// no two distinct pairs can collide on one key. + /// What the sightings store keys this refusal by: the rule, the class and + /// the definition (CLOUD-2075). /// - /// Degenerates to the rule id for an undeclared refusal, which has no class — - /// the honest key there, since the rule's own prose is the whole payload. + /// Read off the full arm through [`parse_finding`], so ONE authority keys + /// both a refusal the engine built and a line the boundary read back from + /// tool output. #[must_use] pub fn sighting_key(&self) -> String { - match self.verdict() { - Some(token) => format!("{}\u{1f}{token}", self.rule), - None => self.rule.clone(), - } + let full = self.render_finding(Arm::Full); + parse_finding(&full).map_or(full, |parsed| parsed.key) } /// The class's one-line gloss, or empty for a refusal with no class. @@ -616,100 +991,6 @@ impl Refusal { &self.gloss } - /// The text projection every channel carries. - /// - /// `Refused by : Fix: .` — one sentence of cause and one - /// of remedy, in that order, with the remedy clause **always present**. A - /// channel may append its own trailing note (the mediation hatch is - /// [`crate::hook`]'s, not a refusal's), but nothing may drop the fix clause, - /// because dropping it is exactly the bare "no" this contract exists to - /// prevent. - #[must_use] - pub fn render(&self) -> String { - let fix = match &self.fix { - Fix::Run(text) => text.as_str(), - Fix::None => NO_DECLARED_FIX, - }; - format!( - "Refused by {}: {} Fix: {}", - self.rule, - sentence(&self.reason), - sentence(fix) - ) - } - - /// What the HOT PATH emits: the declared class and its pointers, and nothing - /// else (CLOUD-1286). - /// - /// [`Refusal::render`] is the projection for a surface with no budget - /// pressure — `check`'s findings, a report, anything a human reads once. This - /// is the projection for a surface that pays for every byte on every - /// subsequent turn, and the two are deliberately different rather than one - /// wrapper being shortened for everybody. - /// - /// **Three clauses go, and each is a copy of something already declared.** - /// `Refused by :` restates a token that names its own class; the - /// parenthetical gloss IS the class's definition inlined; `Fix:` is the - /// class's first `command` route, which `batten policy explain ` - /// prints along with every other route the class declares — the override - /// route included, which the `Fix:` clause could never reach by construction. - /// So the decision this row owed in writing is: **the token is the pointer to - /// the fix**, one hop, and the hop is the same command for all four clauses - /// rather than a different lookup for each. - /// - /// **The RULE ID stays, as a trailing pointer rather than as a prefix.** What - /// goes is `Refused by :` — five tokens of framing around one useful - /// word. The word itself is not framing: two rows can raise the same class, - /// and `explain` answers about the class and cannot say which row fired, so - /// dropping the id would leave a reader unable to find the config line that - /// refused them. It varies per firing, which is exactly the test this row - /// applies — the prose that repeats is what moves behind the dereference, - /// and the pointers that change stay inline. - /// - /// **CLOUD-1637 counted that argument and it holds for more rows than it - /// claimed.** "Two rows can raise the same class" reads as an edge case; it - /// is 66 of the 128 `[[rule]]` rows in this repository's own config. Rows of - /// kind `shape`, `receipt`, `forbid`, `pipeline`, `command`, `ratchet` and - /// `secrets` declare no class of their own and raise their kind's native one - /// — every plain `shape` row (eleven in this config when CLOUD-1806 counted) - /// raises `call name refused`, ten `receipt` rows - /// share four classes. So the id is not a tie-breaker for a rare collision, - /// it is the only discriminator for half the population, and it renders on - /// BOTH arms rather than on the first sighting alone. - /// - /// The second half of the argument is now true in a way it was not when it - /// was written: `explain` still cannot say which row fired, and - /// `batten policy rule ` is the verb that can. The id is a live pointer - /// rather than a string to grep for. - /// - /// **An UNDECLARED refusal keeps the long form**, and that is not a hole. A - /// refusal composed from consumer prose carries no token, so a bare line - /// would be a bare "no" — precisely the thing CLOUD-122 exists to forbid. - /// Concision is bought with a class a reader can look up; where there is no - /// class there is nothing to buy it with, and the long form is the honest - /// answer rather than a fallback. - /// **A COLLAPSED ROW RENDERS ONE TOKEN** (CLOUD-1638). Where the row IS its - /// class's sole raiser the two names are one, and `validate` refuses any - /// other spelling at load — so appending the id would print the same three - /// words twice. The 116 rows that are not collapsed still carry it, for the - /// reason above: it is their only discriminator. - /// - /// Decided from the strings rather than from a flag, because the load-time - /// predicate has already made them equal exactly when they name one thing, - /// and re-deriving the condition here would be a second authority over it. - #[must_use] - pub fn line(&self) -> String { - match self.verdict() { - // `reason` already IS `render_line`'s output for a declared refusal — - // token plus pointers — so this is a projection rather than a second - // renderer. Composing the line here from the token and the subject - // would be a second authority over a string the composer built. - Some(token) if token == self.rule => self.reason.clone(), - Some(_) => format!("{} {}", self.reason, self.rule), - None => self.render(), - } - } - /// The machine-readable payload: `{rule, reason, fix}`, byte-stable. /// /// `hook` has no `-J` channel by design — its stdout is already a @@ -845,18 +1126,19 @@ mod tests { } #[test] - fn the_rendering_always_carries_a_fix_clause() { + fn every_line_names_its_rule_hop_whatever_the_fix() { // Both dispositions, because the one that matters is the one with nothing - // declared: that is where a bare "no" would come from. - assert!( - Refusal::new("g", "why", Fix::None) - .render() - .contains("Fix: none declared") - ); + // declared: the `policy rule` hop is the way out there (CLOUD-2075). + for fix in [Fix::None, Fix::Run("do this".to_owned())] { + let full = Refusal::new("g", "why", fix).render_finding(Arm::Full); + assert!(full.contains("; run batten policy rule 'g'"), "{full}"); + } assert!( - Refusal::new("g", "why", Fix::Run("do this".to_owned())) - .render() - .contains("Fix: do this.") + Refusal::unloaded("g", "why", Fix::None) + .render_finding(Arm::Full) + .find("policy rule") + .is_none(), + "a config that did not load resolves no rule hop" ); } @@ -867,13 +1149,62 @@ mod tests { // rather than doubled. let authored = Refusal::new("g", "Because it does.", Fix::Run("mise run x".to_owned())); assert_eq!( - authored.render(), - "Refused by g: Because it does. Fix: mise run x." + authored.render_finding(Arm::Full), + "rule 'g'; run batten policy rule 'g' — Because it does. mise run x." ); let bare = Refusal::new("g", "because it does", Fix::Run("mise run x.".to_owned())); assert_eq!( - bare.render(), - "Refused by g: because it does. Fix: mise run x." + bare.render_finding(Arm::Full), + "rule 'g'; run batten policy rule 'g' — because it does. mise run x." + ); + } + + #[test] + fn the_pointer_arm_is_a_byte_prefix_of_the_full_arm() { + let refusal = Refusal::declared( + "protected-mutation", + crate::verdict::Native::ProtectedMutation, + &[crate::verdict::Subject::Path { + path: "docs/a.md".to_owned(), + }], + Fix::None, + ); + let full = refusal.render_finding(Arm::Full); + let pointer = refusal.render_finding(Arm::Pointer); + assert!(full.starts_with(&pointer), "{pointer}\n{full}"); + assert!(!pointer.contains(" —"), "{pointer}"); + assert!( + pointer.starts_with("verdict 'path write refused' rule 'protected-mutation' at "), + "{pointer}" + ); + let parsed = parse_finding(&full).expect("the full arm parses"); + assert_eq!(parsed.arm, Arm::Full); + assert_eq!(parsed.rule, "protected-mutation"); + assert_eq!(parsed.verdict.as_deref(), Some("path write refused")); + assert_eq!( + parse_finding(&pointer).expect("the pointer parses").arm, + Arm::Pointer + ); + assert_eq!(parsed.key, refusal.sighting_key()); + } + + #[test] + fn a_quote_in_a_name_round_trips_through_the_grammar() { + let line = format!("{} — x.", label(Label::Rule, "it's")); + assert_eq!(parse_finding(&line).expect("parses").rule, "it's"); + assert!(parse_finding("not a finding").is_none()); + } + + #[test] + fn collapse_cuts_a_marked_full_arm_to_its_pointer_prefix() { + let full = Refusal::new("g", "why", Fix::None).render_finding(Arm::Full); + let pointer = Refusal::new("g", "why", Fix::None).render_finding(Arm::Pointer); + let text = format!("{full}\ncanary\n{full}\n{pointer}\nrule 'unterminated"); + let mut seen = std::collections::HashSet::new(); + let cut = collapse(&text, |key, _| seen.insert(key.to_owned())); + assert_eq!( + cut, + format!("{full}\ncanary\n{pointer}\n{pointer}\nrule 'unterminated") ); } @@ -918,11 +1249,11 @@ mod sightings { fn a_class_explains_itself_once_and_then_stops() { let dir = repo("once"); assert!( - first_sighting(&dir, "branch write unsafe"), - "a class this session has not raised explains itself" + first_sighting(&dir, "s", "branch write unsafe", "full"), + "a class this context has not raised explains itself" ); assert!( - !first_sighting(&dir, "branch write unsafe"), + !first_sighting(&dir, "s", "branch write unsafe", "full"), "and does not explain itself a second time" ); } @@ -931,27 +1262,33 @@ mod sightings { #[test] fn each_class_is_counted_on_its_own() { let dir = repo("keyed"); - assert!(first_sighting(&dir, "branch write unsafe")); + assert!(first_sighting(&dir, "s", "branch write unsafe", "a")); assert!( - first_sighting(&dir, "path write refused"), + first_sighting(&dir, "s", "path write refused", "b"), "a different class has still never been seen" ); } - /// THE CLEAR IS THE LOAD-BEARING HALF. A store that outlived its session would - /// withhold the remedy from a reader who has never read it — the exact defect - /// the store exists to prevent, reintroduced by forgetting to forget. + /// THE CLEAR IS THE LOAD-BEARING HALF, and it is per context: forgetting one + /// context leaves another's sightings alone (CLOUD-2075). #[test] fn a_new_session_hears_it_again() { let dir = repo("cleared"); - assert!(first_sighting(&dir, "branch write unsafe")); - assert!(!first_sighting(&dir, "branch write unsafe")); + assert!(first_sighting(&dir, "a", "branch write unsafe", "full")); + assert!(first_sighting(&dir, "b", "branch write unsafe", "full")); + assert!(!first_sighting(&dir, "a", "branch write unsafe", "full")); - forget_sightings(&dir); + assert_eq!(sighted(&dir, "a"), vec!["full".to_owned()]); + forget_sightings(&dir, "a"); + assert!(sighted(&dir, "a").is_empty()); assert!( - first_sighting(&dir, "branch write unsafe"), + first_sighting(&dir, "a", "branch write unsafe", "full"), "session start forgets, so the next reader is told" ); + assert!( + !first_sighting(&dir, "b", "branch write unsafe", "full"), + "and another context keeps what it saw" + ); } /// A TREE WITH NO GIT DIRECTORY ANSWERS TRUE, which is the safe direction: @@ -962,9 +1299,9 @@ mod sightings { let dir = std::env::temp_dir().join(format!("batten-no-git-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("the fixture directory"); - assert!(first_sighting(&dir, "branch write unsafe")); + assert!(first_sighting(&dir, "s", "branch write unsafe", "full")); assert!( - first_sighting(&dir, "branch write unsafe"), + first_sighting(&dir, "s", "branch write unsafe", "full"), "and keeps doing so, because nothing could record that it had" ); } diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 099b3e8b4..6ff255580 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -6292,11 +6292,16 @@ pub struct Finding { /// carries no line and prints its pointer without one rather than inventing a /// number it does not have — the `None` arm is a different pointer, not a /// degraded one. +/// +/// The rule is LABELLED (CLOUD-2075), so a reader can always tell a rule from a +/// verdict: `[:] rule ''[ ]`. +//MUTANT check-rule-unlabelled|s@^ let rule = crate::refusal::label(crate::refusal::Label::Rule, \&self.rule);$@ let rule = self.rule.clone();@|a_check_finding_line_labels_its_rule impl crate::output::Line for Finding { fn line(&self) -> String { + let rule = crate::refusal::label(crate::refusal::Label::Rule, &self.rule); let at = match self.line { - Some(line) => format!("{}:{} {}", self.path, line, self.rule), - None => format!("{} {}", self.path, self.rule), + Some(line) => format!("{}:{} {rule}", self.path, line), + None => format!("{} {rule}", self.path), }; // Appended rather than interpolated into the pointer, so a consumer // parsing `path:line rule` off the front still parses it. @@ -6825,7 +6830,7 @@ fn run_static_inner( }], Fix::Run(SPAWNING_VERB.to_owned()), ) - .render(), + .render_finding(crate::refusal::Arm::Full), )); } } @@ -13675,6 +13680,27 @@ pub fn glob_match(pattern: &str, path: &str) -> bool { mod tests { use super::PathSet; + /// CLOUD-2075 §7 case 21: a `check` finding labels its rule. + #[test] + fn a_check_finding_line_labels_its_rule() { + use crate::output::Line as _; + let finding = super::Finding { + owner: None, + rule: "r".to_owned(), + severity: super::RuleSeverity::Deny, + path: "src/a.rs".to_owned(), + line: Some(3), + identity: crate::identity::StoredIdentity::new( + crate::identity::FindingKind::Scope, + crate::identity::scope_fingerprint("r", "src/a.rs"), + ), + check: crate::findings::Check::Reevaluate, + remediation: Some(crate::findings::Remediation::NoFix("fixture".to_owned())), + reason: None, + }; + assert_eq!(finding.line(), "src/a.rs:3 rule 'r'"); + } + /// CLOUD-609's guard on the change itself: `contains` still means MEMBERSHIP. /// /// Asserted directly rather than through a caller, because widening it would diff --git a/crates/batten/src/secrets.rs b/crates/batten/src/secrets.rs index 3b30c021f..23dfc1862 100644 --- a/crates/batten/src/secrets.rs +++ b/crates/batten/src/secrets.rs @@ -1117,7 +1117,7 @@ fn resolve_scanner(provisions: &[Provision], root: &Path) -> Result { }], Fix::Run(PROVISION_VERB.to_owned()), ) - .render(), + .render_finding(crate::refusal::Arm::Full), )); }; // The anchor is a RELATIVE path (`.`) whenever the config sits in the cwd, @@ -1146,7 +1146,7 @@ fn resolve_scanner(provisions: &[Provision], root: &Path) -> Result { }], Fix::Run(PROVISION_VERB.to_owned()), ) - .render(), + .render_finding(crate::refusal::Arm::Full), )); } Ok(path) diff --git a/crates/batten/src/selfwrite.rs b/crates/batten/src/selfwrite.rs index 75d8c7a14..fdcd05884 100644 --- a/crates/batten/src/selfwrite.rs +++ b/crates/batten/src/selfwrite.rs @@ -201,6 +201,7 @@ pub fn scan(stream: &Stream, memory_root: &str) -> Vec { // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this // predicate has nothing to say about it. | Event::HookOutput { .. } + | Event::SessionBoundary | Event::AssistantText => {} } } diff --git a/crates/batten/src/session.rs b/crates/batten/src/session.rs index a5971a791..bf2b07246 100644 --- a/crates/batten/src/session.rs +++ b/crates/batten/src/session.rs @@ -494,6 +494,24 @@ pub fn save_watermark(store_dir: &Path, root: &Root, watermark: &Watermark) -> R write_record(store_dir, &record) } +/// Clear what the drain last told `root`'s lineage, keeping its cycle ordinal +/// (CLOUD-2075): after a `SessionStart` the context no longer holds the payload +/// the `unchanged` marker would point at, so the next drain lists in full. +/// +/// # Errors +/// +/// Returns an error when the record cannot be written or published. +pub fn forget_result(store_dir: &Path, root: &Root) -> Result<()> { + let Some(mut record) = read_record(&record_path(store_dir, &root.key)) else { + return Ok(()); + }; + let Some(mark) = record.watermark.as_mut() else { + return Ok(()); + }; + mark.result_id.clear(); + write_record(store_dir, &record) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/src/stop.rs b/crates/batten/src/stop.rs index fae4dd028..ff93a096f 100644 --- a/crates/batten/src/stop.rs +++ b/crates/batten/src/stop.rs @@ -326,7 +326,11 @@ mod tests { assert_eq!(refusal.rule(), RULE); // One command, not a menu: the contract is one hop to right. assert_eq!(refusal.fix(), &Fix::Run("mise run check".to_owned())); - assert!(refusal.render().contains("denial: first")); + assert!( + refusal + .render_finding(crate::refusal::Arm::Full) + .contains("denial: first") + ); } #[test] @@ -338,8 +342,14 @@ mod tests { pending: Vec::new(), }; let refusal = facts.refusal().expect("at-risk work blocks"); - assert!(matches!(refusal.fix(), Fix::Run(_))); - assert!(refusal.render().contains("Fix:")); + let Fix::Run(fix) = refusal.fix() else { + panic!("at-risk work names a fix"); + }; + assert!( + refusal + .render_finding(crate::refusal::Arm::Full) + .contains(fix.as_str()) + ); } #[test] diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index d099390ec..818c31ea9 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -402,6 +402,12 @@ pub enum Event { /// match. A substring or prefix comparison would have to hold the text to /// make it, which is the payload this variant refuses to carry. digest: String, + /// The labelled findings the emission carried, one per line that + /// [`crate::refusal::parse_finding`] reads, as its key and arm + /// (CLOUD-2075). A key is a digest plus the two names, so the text still + /// dies inside [`collect`]; this is what lets the session measure judge a + /// finding per compaction cycle rather than per byte. + findings: Vec<(String, crate::refusal::Arm)>, }, /// The assistant said something to the operator: a non-empty `text` block /// on an assistant turn. @@ -412,6 +418,11 @@ pub enum Event { /// It exists so "a human spoke and the session answered only with tool /// calls" is a typed question rather than a reading of prose. AssistantText, + /// A `SessionStart` hook ran: the boundary of a compaction cycle + /// (CLOUD-2075), inside which a finding's full arm is delivered once. + /// + /// **APPENDED, for [`Event::MemoryInjection`]'s reason.** + SessionBoundary, } /// One event and where it was found. @@ -690,7 +701,7 @@ impl Stream { // breakdown. `hook_decisions` beside it is a different question // — how many hooks DECIDED, not what they said — which is // exactly the pair this variant exists to keep apart. - Event::HookOutput { .. } | Event::AssistantText => {} + Event::HookOutput { .. } | Event::SessionBoundary | Event::AssistantText => {} } } counts @@ -707,9 +718,10 @@ impl Stream { Event::ToolCall { .. } => kinds.insert(Kind::ToolCalls), Event::ToolResult { .. } => kinds.insert(Kind::ToolResults), Event::HookDecision { .. } => kinds.insert(Kind::HookDecisions), - Event::MemoryInjection { .. } | Event::HookOutput { .. } | Event::AssistantText => { - false - } + Event::MemoryInjection { .. } + | Event::HookOutput { .. } + | Event::SessionBoundary + | Event::AssistantText => false, }; } kinds @@ -1020,11 +1032,20 @@ fn collect_attachment(attachment: &Attachment, line: usize, records: &mut Vec Vec<(String, crate::refusal::Arm)> { + let mut texts: Vec = Vec::new(); + texts.extend(self.additional_context.iter().cloned()); + texts.extend(self.stderr.iter().cloned()); + if let Some(document) = self + .stdout + .as_deref() + .and_then(|stdout| serde_json::from_str::(stdout).ok()) + { + let specific = &document["hookSpecificOutput"]; + for key in ["permissionDecisionReason", "additionalContext"] { + if let Some(text) = specific[key].as_str() { + texts.push(text.to_owned()); + } + } + } + texts + .iter() + .flat_map(|text| text.lines()) + .filter_map(crate::refusal::parse_finding) + .map(|parsed| (parsed.key, parsed.arm)) + .collect() + } + /// Which producer this cost belongs to. /// /// `hookName` first, its `hookEvent` next, the tag last. Never a constant of @@ -1545,6 +1593,10 @@ const QUEUED_COMMAND: &str = "queued_command"; /// The host's `origin.kind` for a record the operator authored. const HUMAN_ORIGIN: &str = "human"; +/// The host's `hookEvent` for a session start, the compaction-cycle boundary +/// (CLOUD-2075). +const SESSION_START: &str = "SessionStart"; + /// The host's authorship record on a line or an attachment. #[derive(Debug, Deserialize)] struct HostOrigin { diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 5900159a5..e3318c776 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -630,7 +630,7 @@ pub fn first_command_route<'a>(registry: &'a [DeclaredVerdict], token: &str) -> //MUTANT-SUITE crates/batten/tests/it/refusal_ceiling.rs //MUTANT document-route-dropped|s@ RouteKind::Document => "read",@ RouteKind::Document => return None,@|a_first_sighting_carries_the_gloss_and_its_route_by_kind #[must_use] -fn render_route(route: &Route) -> Option { +pub(crate) fn render_route(route: &Route) -> Option { let verb = match route.kind { RouteKind::Command => "run", RouteKind::Document => "read", @@ -1725,7 +1725,7 @@ pushed", /// CLOUD-1806's route: the recorded way through every plain `shape` row. //MUTANT shape-class-inadmissible|s@^const SHAPE_ADMIT_ROUTE: VendoredRoute = admit($@const SHAPE_ADMIT_ROUTE: VendoredRoute = run(@|a_shape_deny_is_admissible_through_its_class_override -//MUTANT shape-route-circular|s@^ run("rule read first", "batten policy rule ''"),$@ read("config read first", "batten.toml"),@|a_shape_first_sighting_names_the_rows_remedy_verb +//MUTANT shape-route-circular|s@^ run("rule read first", crate::refusal::RULE_HOP_PLACEHOLDER),$@ read("config read first", "batten.toml"),@|a_shape_first_sighting_names_the_rows_remedy_verb const SHAPE_ADMIT_ROUTE: VendoredRoute = admit( "articulate the call", "the remedy `batten policy rule` prints for this row cannot perform the change this call \ @@ -2155,7 +2155,7 @@ own text and could carry anything. What to run instead is the row's declared rem `batten policy rule` prints; where that remedy cannot perform the change, the class is \ admissible through a recorded admission bound to the row id at the current commit.", routes: &[ - run("rule read first", "batten policy rule ''"), + run("rule read first", crate::refusal::RULE_HOP_PLACEHOLDER), SHAPE_ADMIT_ROUTE, ], applicability: Applicability::Advice, diff --git a/crates/batten/src/waiver.rs b/crates/batten/src/waiver.rs index ff6d8e889..5bb3edb35 100644 --- a/crates/batten/src/waiver.rs +++ b/crates/batten/src/waiver.rs @@ -415,15 +415,13 @@ impl Applied { /// who greps `check` output can grep this. #[must_use] pub fn line_text(&self) -> String { + let rule = crate::refusal::label(crate::refusal::Label::Rule, &self.rule); match self.line { Some(line) => format!( - "waived {}:{} {} (expires {})", - self.path, line, self.rule, self.expires - ), - None => format!( - "waived {} {} (expires {})", - self.path, self.rule, self.expires + "waived {}:{} {rule} (expires {})", + self.path, line, self.expires ), + None => format!("waived {} {rule} (expires {})", self.path, self.expires), } } } @@ -511,7 +509,8 @@ impl Suppressed { /// too — which is the point of keeping one verdict word across both channels. #[must_use] pub fn line_text(&self) -> String { - format!("waived {} (expires {})", self.rule, self.expires) + let rule = crate::refusal::label(crate::refusal::Label::Rule, &self.rule); + format!("waived {rule} (expires {})", self.expires) } } @@ -716,7 +715,7 @@ mod tests { assert_eq!(applied.len(), 1); assert_eq!( applied[0].line_text(), - "waived src/a.rs:3 r (expires 2099-01-01)" + "waived src/a.rs:3 rule 'r' (expires 2099-01-01)" ); assert!( !applied[0].line_text().contains("deny"), @@ -811,7 +810,7 @@ mod tests { let (_, applied) = apply(vec![scoped], &[waiver("r", "2099-01-01")], TODAY); assert_eq!( applied[0].line_text(), - "waived **/*.rs r (expires 2099-01-01)" + "waived **/*.rs rule 'r' (expires 2099-01-01)" ); } @@ -926,7 +925,7 @@ mod tests { expires: "2099-01-01".to_owned(), } .line_text(); - assert_eq!(line, "waived no-merge (expires 2099-01-01)"); + assert_eq!(line, "waived rule 'no-merge' (expires 2099-01-01)"); // One verdict word across both channels, so a reader who greps `check` // output for a suppression finds a mediated one too. assert!(line.starts_with("waived ")); diff --git a/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in b/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in index 924a31d11..4be6d1002 100644 --- a/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in +++ b/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -notes.md:1 no-appeal-to-authority +notes.md:1 rule 'no-appeal-to-authority' diff --git a/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in b/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in index 307304813..b9128ccf5 100644 --- a/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in +++ b/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -notes.md:1 skill-tier no-document +notes.md:1 rule 'skill-tier' no-document diff --git a/crates/batten/tests/fixtures/repos/document-node-differs/expected.in b/crates/batten/tests/fixtures/repos/document-node-differs/expected.in index bf127357d..2bd001e96 100644 --- a/crates/batten/tests/fixtures/repos/document-node-differs/expected.in +++ b/crates/batten/tests/fixtures/repos/document-node-differs/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -pins.toml:1 pin-agreement node-differs +pins.toml:1 rule 'pin-agreement' node-differs diff --git a/crates/batten/tests/fixtures/repos/forbid-deny/expected.in b/crates/batten/tests/fixtures/repos/forbid-deny/expected.in index 74e8a70ca..45b1a7e5e 100644 --- a/crates/batten/tests/fixtures/repos/forbid-deny/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-deny/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -lib.rs:4 no-todo +lib.rs:4 rule 'no-todo' diff --git a/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in b/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in index 7eddc28c8..ee9e29a06 100644 --- a/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -pins.toml:6 no-built-backend +pins.toml:6 rule 'no-built-backend' diff --git a/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in b/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in index e463d3926..ae8fd1a76 100644 --- a/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in @@ -1,5 +1,5 @@ argv: check exit: 2 stdout: -run.sh:1 no-grep-q -run.sh:2 no-grep-q +run.sh:1 rule 'no-grep-q' +run.sh:2 rule 'no-grep-q' diff --git a/crates/batten/tests/fixtures/repos/forbid-warn/expected.in b/crates/batten/tests/fixtures/repos/forbid-warn/expected.in index 5ac05436e..162d5649d 100644 --- a/crates/batten/tests/fixtures/repos/forbid-warn/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-warn/expected.in @@ -1,4 +1,4 @@ argv: check exit: 0 stdout: -lib.rs:2 no-todo +lib.rs:2 rule 'no-todo' diff --git a/crates/batten/tests/it/advisory_drain.rs b/crates/batten/tests/it/advisory_drain.rs index d0ec5ff46..135ac2699 100644 --- a/crates/batten/tests/it/advisory_drain.rs +++ b/crates/batten/tests/it/advisory_drain.rs @@ -291,12 +291,15 @@ fn a_post_tool_event_drains_the_store_as_pointer_lines() { assert_eq!(first.status.code(), Some(0), "the drain never denies"); let lines = payload(&first); assert_eq!(lines.len(), 1, "one finding, one line: {lines:?}"); - let fields: Vec<&str> = lines[0].split(' ').collect(); - assert_eq!(fields.len(), 4, "fingerprint, rule, path:line, count"); + // CLOUD-2075's labelled grammar: `rule '' at `. + let rest = lines[0] + .strip_prefix("rule 'no-todo' at ") + .unwrap_or_else(|| panic!("the line labels its rule: {lines:?}")); + let fields: Vec<&str> = rest.split(' ').collect(); + assert_eq!(fields.len(), 3, "fingerprint, path:line, count"); assert_eq!(fields[0].len(), 64, "a fingerprint is 64 hex characters"); - assert_eq!(fields[1], "no-todo"); - assert_eq!(fields[2], "src/a.rs:2"); - assert_eq!(fields[3], "1"); + assert_eq!(fields[1], "src/a.rs:2"); + assert_eq!(fields[2], "1"); assert!( !lines[0].contains("TODO"), "a pointer, never the matched content" @@ -587,6 +590,31 @@ fn an_unchanged_finding_set_answers_with_the_marker_rather_than_the_listing() { ); } +/// A `SessionStart` opens a new cycle, and the context no longer holds the +/// payload `unchanged` would point at — so the next drain lists in full +/// (CLOUD-2075 §7 case 22). +#[test] +fn a_session_start_relists_the_drain_payload() { + let (repo, home) = drained_fixture("drain-session-start", "\n[drain]\ninterval_ms = 0\n"); + let first = payload(&hook(&repo, &home, &post_tool_batch("s1"))); + assert_eq!(first.len(), 1, "{first:?}"); + assert_eq!( + payload(&hook(&repo, &home, &post_tool_batch("s1"))), + vec!["unchanged".to_owned()] + ); + let started = hook( + &repo, + &home, + r#"{"hook_event_name":"SessionStart","session_id":"s1","source":"startup","cwd":"/w"}"#, + ); + assert_eq!(started.status.code(), Some(0)); + assert_eq!( + payload(&hook(&repo, &home, &post_tool_batch("s1"))), + first, + "the first drain of a new cycle lists in full" + ); +} + #[test] fn a_drain_with_nothing_to_say_stays_silent_rather_than_claiming_unchanged() { // The distinction the marker would lose if it were emitted unconditionally: @@ -788,21 +816,20 @@ fn spread_fixture(name: &str, drain_table: &str, spans: usize) -> (PathBuf, Path } #[test] -fn a_rule_over_the_cardinality_cap_emits_one_summary_line_and_the_cap_is_config() { - // CLOUD-82 (b) over the binary, and the half a renderer unit test cannot - // reach: the cap that decides is the one in `batten.toml`. Same fixture, - // same findings, two caps, two payloads — a hard-coded K could not produce - // both columns, and a key that parsed but did nothing would produce neither. +fn a_rule_over_the_cardinality_cap_reports_it_beside_every_entry_and_the_cap_is_config() { + // CLOUD-82 (b) over the binary, reversed by CLOUD-2075: the cap REPORTS and + // withholds nothing. The cap that decides the report is the one in + // `batten.toml` — two caps, two payloads. let (capped, home_c) = spread_fixture( "drain-cap-on", "\n[drain]\ninterval_ms = 0\ncardinality_cap = 2\n", 4, ); let lines = payload(&hook(&capped, &home_c, &post_tool_batch("s1"))); + assert_eq!(lines.len(), 5, "four entries and the cap report: {lines:?}"); assert_eq!( - lines, - vec!["rule no-todo: 2+ findings".to_owned()], - "one pointer-only summary line, never the four entries" + lines[0], "rule 'no-todo': 4 findings, over the cardinality cap of 2", + "the report leads its rule's entries" ); let (uncapped, home_u) = spread_fixture( @@ -814,26 +841,15 @@ fn a_rule_over_the_cardinality_cap_emits_one_summary_line_and_the_cap_is_config( assert_eq!( lines.len(), 4, - "under the cap every identity speaks: {lines:?}" - ); - - // The withheld identities are recorded under the reason that feeds - // rule-health telemetry — not as an ordinary drain suppression, which is - // what a transient bound would be. - let shown = state_cmd(&capped, &home_c, &["state", "list", "-J"]); - let document: serde_json::Value = - serde_json::from_slice(&shown.stdout).expect("state list -J is JSON"); - assert_eq!( - document[0]["presentation"]["not-shown"], "over-cardinality-cap", - "the cap is journalled as itself: {document}" + "under the cap every identity speaks and nothing is reported: {lines:?}" ); } #[test] -fn the_emitted_payload_stays_under_the_configured_token_budget() { - // CLOUD-82 (a) over the binary. The budget is asserted against the bytes the - // host actually receives, with the same estimator `[budget]` gates - // instruction files with — a second estimator here could agree with nothing. +fn an_emitted_payload_over_the_configured_token_budget_reports_it() { + // CLOUD-82 (a) over the binary, reversed by CLOUD-2075: the budget REPORTS, + // with the same estimator `[budget]` gates instruction files with, and + // withholds no pointer. const BUDGET: usize = 20; let (repo, home) = spread_fixture( "drain-budget", @@ -841,15 +857,11 @@ fn the_emitted_payload_stays_under_the_configured_token_budget() { 12, ); let lines = payload(&hook(&repo, &home, &post_tool_batch("s1"))); + assert_eq!(lines.len(), 13, "every pointer and the report: {lines:?}"); assert!( - batten::budget::estimate_tokens(&lines.join("\n")) <= BUDGET, - "over the configured budget: {lines:?}" - ); - assert!( - lines.last().is_some_and( - |line| line.starts_with("budget: ") && line.ends_with(" findings withheld") - ), - "and the payload says how much it did not say: {lines:?}" + lines.last().is_some_and(|line| line.starts_with("budget: ") + && line.ends_with(&format!(" tokens over the declared {BUDGET}"))), + "and the payload says how far over it is: {lines:?}" ); } @@ -883,8 +895,8 @@ fn a_re_raised_group_reports_the_delta_rather_than_the_instance_list() { "the count field carries the delta: {again:?}" ); let fields: Vec<&str> = again[0].split(' ').collect(); - assert_eq!(fields.len(), 4, "still a pointer, not an instance list"); - assert_eq!(fields[2], "src/a.rs:2", "and one in-scope pointer"); + assert_eq!(fields.len(), 6, "still a pointer, not an instance list"); + assert_eq!(fields[4], "src/a.rs:2", "and one in-scope pointer"); } #[test] @@ -1011,16 +1023,17 @@ fn occurrences(record: &serde_json::Value) -> Option { // Acceptance (a), all four clauses over one alternating fixture. #[test] -fn an_alternating_rule_tracks_state_truthfully_while_its_emissions_stop_at_the_cap() { +fn an_alternating_rule_tracks_state_truthfully_and_is_reported_flapping_not_withheld() { // A window that a handful of evaluations fills, a threshold the alternation - // clears, and a cap of one so the second emission is the suppressed one. + // clears, and a cap of one that is accepted and no longer read. let (repo, home) = flapping_fixture( "drain-flap", "\n[drain]\ninterval_ms = 0\nflap_window = 6\nflap_percent = 50\nemit_cap = 1\n", ); + // CLOUD-2075: a flapping identity is a pointer the ruling needs, so the + // signal policy REPORTS it (the per-rule count below) and withholds nothing. let mut emissions = 0; - let mut suppressed = false; for round in 0..6 { let raised = round % 2 == 0; evaluate(&repo, &home, raised); @@ -1041,20 +1054,13 @@ fn an_alternating_rule_tracks_state_truthfully_while_its_emissions_stop_at_the_c if lines.iter().any(|line| line.contains("no-todo")) { emissions += 1; } - if stored(&repo, &home)["presentation"]["not-shown"] == "flap-suppressed" { - suppressed = true; - } + assert_ne!( + stored(&repo, &home)["presentation"]["not-shown"], + "flap-suppressed", + "round {round}: a flapping identity is shown, never withheld" + ); } - - assert!( - suppressed, - "the identity is annotated as withheld by the signal policy, journalled \ - under its own reason so the false-positive rate excludes it" - ); - assert!( - emissions <= 2, - "emissions stop at the cap; got {emissions} over six evaluations" - ); + assert_eq!(emissions, 3, "every raised round shows its pointer"); // The rule-health counter, on the operator's channel: a rule id and a count, // never a finding's content. @@ -1076,149 +1082,6 @@ fn an_alternating_rule_tracks_state_truthfully_while_its_emissions_stop_at_the_c assert_eq!(occurrences(&stored(&repo, &home)), Some(0)); } -/// The drain shard's name in this store, discovered rather than hardcoded. -/// -/// A store that has taken one evaluation and one drain boundary holds exactly two -/// shards: the scan's, which is `journal::shard_id(worktree)`, and the drain's, -/// which is neither derived from nor predictable off the worktree path. Reading -/// it back is what keeps the cases below honest if that derivation ever changes; -/// a literal `c0cc…` would pass for the wrong reason the day it did. -fn drain_shard_of(repo: &Path, home: &Path) -> String { - let store = shards_dir(home); - let scan = batten::journal::shard_id(repo); - let mut names: Vec = std::fs::read_dir(&store) - .unwrap_or_else(|why| panic!("read {}: {why}", store.display())) - .flatten() - .filter_map(|entry| { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "jsonl") { - path.file_stem() - .map(|stem| stem.to_string_lossy().into_owned()) - } else { - None - } - }) - .filter(|name| *name != scan) - .collect(); - names.sort(); - assert_eq!( - names.len(), - 1, - "one drain shard beside the scan's: {names:?}" - ); - names.into_iter().next().expect("the drain shard") -} - -/// The one bound store's shard directory under `home`. -fn shards_dir(home: &Path) -> PathBuf { - let bound = home.join("data/batten"); - let mut stores: Vec = std::fs::read_dir(&bound) - .unwrap_or_else(|why| panic!("read {}: {why}", bound.display())) - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .collect(); - stores.sort(); - assert_eq!(stores.len(), 1, "one bound store: {stores:?}"); - stores - .into_iter() - .next() - .expect("the store") - .join("journal/shards") -} - -/// Drive the alternating fixture under `repo_dir` and report whether the drain -/// ever annotated the identity as withheld by the emission policy. -/// -/// The six-round shape is acceptance (a)'s, deliberately: this is the SAME run -/// that case makes, asked under a chosen shard order rather than the ambient one. -fn suppression_fires_under(root: &Path, repo_dir: &str) -> bool { - let (repo, home) = flapping_fixture_under( - root, - repo_dir, - "\n[drain]\ninterval_ms = 0\nflap_window = 6\nflap_percent = 50\nemit_cap = 1\n", - ); - let mut suppressed = false; - for round in 0..6 { - evaluate(&repo, &home, round % 2 == 0); - let woken = hook(&repo, &home, &post_tool_batch("flap-order")); - assert_eq!(woken.status.code(), Some(0), "the drain never denies"); - if stored(&repo, &home)["presentation"]["not-shown"] == "flap-suppressed" { - suppressed = true; - } - } - suppressed -} - -// CLOUD-1252. THE ORDER THE LOG IS READ IN IS NOT THE ORDER THE SHARDS ARE NAMED -// IN, and until `journal::Entry` carried an append stamp it was exactly that. -// -// `emission::assess` scopes an identity's emission budget by POSITION in the -// merged log — an emission counts when its index is at or above the window's -// first evaluation — which is sound only if the log is chronological. -// `read_shards` concatenates whole shards in path order, so the log was grouped -// by WRITER and which writer led was decided by whether the checkout's path -// fingerprint sorted above the drain shard's name. On the losing draw every -// emission sat below the window, the count was always zero, and flap suppression -// never fired AT ALL — for that checkout, permanently, with the engine reporting -// a healthy drain the whole time. Measured at 1 store in 13, and drawn every time -// by the musl target directory, which is how it surfaced. -// -// BOTH DRAWS, CONSTRUCTED RATHER THAN AWAITED, and that is the whole case. One -// run proves only which side this machine sits on; acceptance (a) has been -// passing for months on the winning one. The directory name is searched until its -// shard id lands either side of the drain's, so each assertion names the order it -// is making. -// -// SHOWN ABLE TO FAIL (CLOUD-418): revert `read_shards` to `paths.sort()` alone -// and the `above` half reds while the `below` half stays green. A case that fires -// in one order only is a case that cannot tell the fix from the draw. -#[test] -fn flap_suppression_fires_whichever_shard_sorts_first() { - let root = scratch("drain-flap-order"); - // One boundary, purely to learn this store's drain shard. The probe's own - // verdict is not asserted — it is a read, and the cases below are the claim. - let (probe, probe_home) = flapping_fixture_under( - &root, - "probe", - "\n[drain]\ninterval_ms = 0\nflap_window = 6\nflap_percent = 50\nemit_cap = 1\n", - ); - evaluate(&probe, &probe_home, true); - hook(&probe, &probe_home, &post_tool_batch("flap-probe")); - let drain = drain_shard_of(&probe, &probe_home); - - // A directory name per side. The search is bounded and deterministic: the - // drain shard is a 64-char hex string, so roughly one name in two lands on - // each side and forty candidates is far past certainty. - let mut below = None; - let mut above = None; - for n in 0..40 { - let candidate = format!("repo{n}"); - let shard = batten::journal::shard_id(&root.join(&candidate)); - if shard < drain && below.is_none() { - below = Some(candidate); - } else if shard > drain && above.is_none() { - above = Some(candidate); - } - if below.is_some() && above.is_some() { - break; - } - } - let below = below.expect("a checkout whose scan shard sorts below the drain's"); - let above = above.expect("a checkout whose scan shard sorts above the drain's"); - - assert!( - suppression_fires_under(&root, &below), - "scan shard below the drain's ({below}): this is the draw that always \ - worked, so a failure here is the policy, not the order" - ); - assert!( - suppression_fires_under(&root, &above), - "scan shard ABOVE the drain's ({above}): the losing draw. Flap \ - suppression must not depend on a filename comparison — see CLOUD-1252" - ); -} - // Acceptance (b). The load-bearing case for the (identity × context) key: two // worktrees at two refs, each monotone, interleaved in one shared journal. #[test] diff --git a/crates/batten/tests/it/ask_disposition.rs b/crates/batten/tests/it/ask_disposition.rs index ef06a9fb7..7fa113e8b 100644 --- a/crates/batten/tests/it/ask_disposition.rs +++ b/crates/batten/tests/it/ask_disposition.rs @@ -92,11 +92,32 @@ fn asking(name: &str) -> PathBuf { fn payload(command: &str) -> String { let encoded = serde_json::to_string(command).expect("a command is encodable"); format!( - "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + "{{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"s1\",\"tool_name\":\"Bash\",\ \"tool_input\":{{\"command\":{encoded}}}}}" ) } +/// An ask carries the full arm on EVERY firing (CLOUD-2075 §7 case 13): its +/// reader is a person, who has read no earlier firing and runs no lookup. +#[test] +fn an_ask_carries_the_full_arm_on_every_firing() { + let dir = asking("ask-full-every-firing"); + for _ in 0..2 { + let (code, body, cause) = adjudicate(&dir, "claude-code", ASKED); + assert_eq!(code, Some(0), "{cause}"); + let json: serde_json::Value = + serde_json::from_str(body.trim()).expect("the host's verdict envelope is JSON"); + let rendered = json["hookSpecificOutput"]["permissionDecisionReason"] + .as_str() + .expect("an escalation carries what is being asked"); + assert!(rendered.contains(" —"), "the full arm: {rendered}"); + assert!( + rendered.contains(REASON), + "with the row's reason: {rendered}" + ); + } +} + /// Adjudicate `command` in `dir` as `harness` sees it. fn adjudicate(dir: &Path, harness: &str, command: &str) -> (Option, String, String) { let out = run_with_stdin( diff --git a/crates/batten/tests/it/board_receipts.rs b/crates/batten/tests/it/board_receipts.rs index 3f76d54f6..81138ff14 100644 --- a/crates/batten/tests/it/board_receipts.rs +++ b/crates/batten/tests/it/board_receipts.rs @@ -382,8 +382,11 @@ fn an_update_is_not_row_ones_business() { // lives behind `batten policy explain` with the rest of it, and the id on // the emitted line is the engine's own attribution and nothing else. The // negative assertion is what keeps that claim honest. - assert!( - !text.contains("issue list unread"), + // CLOUD-2075 puts the refusing row's reason on the full arm, and that prose + // names row 1 — so attribution is read off the RULE LABEL, never a substring. + assert_ne!( + crate::common::refusing_rule(&text).as_deref(), + Some("issue list unread"), "an update names an id, so the row that gates FILING must stay silent: {text}" ); assert!( @@ -714,9 +717,12 @@ fn the_same_instant_yields_the_same_verdict() { mint_read_receipt(&repo, "CLOUD-1", 5); let at = later(1000); let args = ["adjudicate", "--harness", "exit-code", "--instant", &at]; - let sighting = run_with_stdin(&repo, &args, &payload("mcp__Linear__save_issue", update)); - let first = run_with_stdin(&repo, &args, &payload("mcp__Linear__save_issue", update)); - let second = run_with_stdin(&repo, &args, &payload("mcp__Linear__save_issue", update)); + // In one session (CLOUD-2075): the lifecycle is per context. + let call = + payload("mcp__Linear__save_issue", update).replacen('{', "{\"session_id\":\"s1\",", 1); + let sighting = run_with_stdin(&repo, &args, &call); + let first = run_with_stdin(&repo, &args, &call); + let second = run_with_stdin(&repo, &args, &call); assert_eq!( first.status.code(), second.status.code(), diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 7f3f06531..1f898f90b 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -1176,7 +1176,7 @@ fn check_violation_exits_two_with_pointer_only_output() { assert_eq!(output.status.code(), Some(2), "a finding is a violation"); let stdout = String::from_utf8_lossy(&output.stdout); // Pointer only: the location and rule id, never the offending line text. - assert_eq!(stdout, "lib.rs:2 no-todo\n"); + assert_eq!(stdout, "lib.rs:2 rule 'no-todo'\n"); assert!( !stdout.contains("fix this"), "output must not leak the bytes" @@ -1278,7 +1278,8 @@ fn check_refuses_a_command_rule_rather_than_skipping_it() { // refusal carries the `batten:` prefix that belongs to 1 and 3, and no // bypass hatch, because a read-only run has nothing to bypass. assert!( - stderr.contains("Refused by dyn:") && stderr.contains("Fix: batten enforce."), + stderr.contains("verdict 'spawn run refused' rule 'dyn'") + && stderr.contains("run batten enforce"), "the refusal must adopt the one shape, got: {stderr}" ); assert!( @@ -1302,7 +1303,10 @@ fn enforce_runs_a_command_rule_and_maps_its_exit_code() { "a non-zero command exit is a violation" ); // Rule-scoped pointer: no invented line number, and never the command output. - assert_eq!(String::from_utf8_lossy(&output.stdout), "**/*.rs dyn\n"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "**/*.rs rule 'dyn'\n" + ); } #[test] @@ -1715,7 +1719,7 @@ fn a_local_override_may_add_a_rule_but_not_redefine_one() { assert_eq!(output.status.code(), Some(2), "the added rule must fire"); assert_eq!( String::from_utf8_lossy(&output.stdout), - "lib.rs:1 no-fixme\n" + "lib.rs:1 rule 'no-fixme'\n" ); // Redefining a committed rule could weaken it, so it is refused outright. @@ -1975,6 +1979,8 @@ fn the_matrix_covers_every_supported_harness() { struct FixCase { /// A mediated command the row refuses. command: &'static str, + /// The row that refuses it, which the line must label (CLOUD-2075). + rule: &'static str, /// The remedy that row declares, which the refusal must carry. fix: &'static str, } @@ -1982,18 +1988,22 @@ struct FixCase { const FIX_CASES: &[FixCase] = &[ FixCase { command: "gh pr merge 42", + rule: "commit ship other", fix: "use `mise run land`", }, FixCase { command: "gh pr comment 7 --body /fast-forward", + rule: "review ship early", fix: "use `mise run land`", }, FixCase { command: "gh pr checks --watch", + rule: "check watch loose", fix: "use `mise run ci-wait`", }, FixCase { command: "gh run watch 123", + rule: "job watch loose", fix: "use `mise run ci-wait`", }, ]; @@ -2018,42 +2028,21 @@ fn every_hook_policy_table_deny_names_its_fix() { let output = run_hook_in(&dir, "exit-code", &claude_payload(case.command)); assert_eq!(output.status.code(), Some(2), "{}: deny", case.command); let stderr = String::from_utf8_lossy(&output.stderr); - // CLOUD-1286: the sanctioned command is ONE HOP away rather than - // inline, and this case is what proves the hop actually lands. The - // emitted line carries the rule id; `batten policy rule ` resolves - // that id to the row's own remedy. Asserting only the absence would pass - // over a refusal that points nowhere, which is worse than the repetition - // it replaced. - // - // THE ID IS THE LAST TOKEN OF THE HEAD, NOT OF THE LINE (CLOUD-1637). A - // first sighting is ` — ; `, - // so the last token of the whole line is now the final route's target - // and taking it grabbed `batten.toml`. Splitting on the em-dash reads the - // id on both arms: the repeat has no such clause and the head IS the - // line, which is the byte-prefix property doing useful work. - // - // `policy rule` rather than `policy explain`, which is the verb this hop - // was always named after: `explain` answers about the CLASS and resolves - // a rule id only as a fallback, and the two are different questions - // wherever a class has more than one raiser. - // AND THE ID IS THREE WORDS, NOT ONE (CLOUD-1638). Taking the last - // whitespace token grabbed `other` out of `commit ship other`. The head - // is ` ` where the id is present only when it - // DIFFERS from the class, so: the last three words are the id on a - // discriminating row, and on a collapsed row the class token — the first - // three words — is the id, because that is what collapsing means. - let head = stderr.split(" — ").next().unwrap_or(&stderr); - let words: Vec<&str> = head.split_whitespace().collect(); + // CLOUD-1286: the sanctioned command is ONE HOP away, and this case is + // what proves the hop lands. CLOUD-2075 LABELS the rule, so the id is + // read off `rule ''` rather than guessed from word positions. + let label = format!("rule '{}'", case.rule); assert!( - words.len() >= 3, - "a deny names the class that fired: {stderr}" + stderr.contains(&label), + "{}: the line labels its rule, got: {stderr}", + case.command ); - let tail = words[words.len() - 3..].join(" "); - let class = words[..3].join(" "); - let mut explained = batten_with(&dir, &["policy", "rule", &tail], &[]); - if explained.status.code() != Some(0) { - explained = batten_with(&dir, &["policy", "rule", &class], &[]); - } + let id = stderr + .split("rule '") + .nth(1) + .and_then(|rest| rest.split('\'').next()) + .expect("a labelled rule id"); + let explained = batten_with(&dir, &["policy", "rule", id], &[]); assert_eq!( explained.status.code(), Some(0), @@ -2276,13 +2265,15 @@ fn a_deny_names_the_path_classs_own_mutation_over_the_verbs() { stderr.contains("guarded/thing.md"), "the path class that matched is the pointer, got: {stderr}" ); + // CLOUD-2075: the full arm carries the WINNING remedy — the path class's — + // and never the verb's fallback beside it. assert!( - !stderr.contains("change it in a pull request"), - "the remedy is dereferenced rather than inlined, got: {stderr}" + stderr.contains("change it in a pull request"), + "the full arm carries the path class's remedy, got: {stderr}" ); assert!( !stderr.contains("restore it with git"), - "the verb's general remedy must not appear either, got: {stderr}" + "the verb's general remedy must not appear, got: {stderr}" ); let unclaimed = run_hook_in(&dir, "exit-code", &claude_payload("rm vendor/thing.md")); @@ -2524,21 +2515,21 @@ fn an_absent_session_degrades_to_per_invocation_without_panicking() { // adjudicated exactly as one that does — the deny is a function of the // command, and nothing here is keyed on a session yet. // - // TWO IDENTICAL FIXTURES, advancing in lockstep: a refusal renders long on - // its first sighting and short after, and the sighting store lives under the - // fixture's `$GIT_DIR` (`refusal::first_sighting`). One fixture would compare - // a first sighting with a repeat and report the store as a session effect. + // TWO FIXTURES, and a FRESH SESSION per harness (CLOUD-2075): a session's + // first firing is the full arm, and a session-less payload is the full arm + // on every firing, so the two documents match exactly when each named + // session is new to the store. let dir = repo_with_gh_policy("session-absent"); let twin = repo_with_gh_policy("session-absent-twin"); - let with = serde_json::json!({ - "hook_event_name": "PreToolUse", - "session_id": "abc123", - "tool_name": "Bash", - "tool_input": { "command": "gh pr merge 42" } - }) - .to_string(); let without = payload_at("PreToolUse", "gh pr merge 42"); for harness in harnesses() { + let with = serde_json::json!({ + "hook_event_name": "PreToolUse", + "session_id": format!("abc-{harness}"), + "tool_name": "Bash", + "tool_input": { "command": "gh pr merge 42" } + }) + .to_string(); let a = run_hook_in(&dir, harness, &with); let b = run_hook_in(&twin, harness, &without); assert_eq!( @@ -3584,9 +3575,8 @@ fn the_committed_shape_rules_fire_on_every_banned_shape() { // the head ends with the id. The strictness this case wants is preserved // exactly by reading the head: on a repeat the head IS the line, which is // the byte-prefix property the two arms are built to have. - let head = stderr.split(" — ").next().unwrap_or(&stderr); assert!( - head.trim().ends_with(&case.rule), + common::refusing_rule(&stderr).as_deref() == Some(case.rule), "{:?} must be refused by {}, got: {stderr}", case.call.describe(), case.rule @@ -6705,7 +6695,7 @@ fn the_committed_repo_config_gates_a_repository() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "crates/** source carry broken\n", + "crates/** rule 'source carry broken'\n", "a command condemns a batch, so the pointer is the glob and carries no line" ); } @@ -6922,17 +6912,17 @@ fn the_committed_repo_agnosticism_rules_fire_on_every_banned_shape() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "crates/demo/notes.txt:1 fact name other\n\ - crates/demo/notes.txt:2 path name other\n\ - crates/demo/notes.txt:3 source name other\n\ - crates/demo/notes.txt:4 issue name other\n\ - crates/demo/notes.txt:5 issue name other\n\ - crates/demo/src/lib.rs:1 fact name other\n\ - crates/demo/src/lib.rs:2 path name other\n\ - crates/demo/src/lib.rs:3 source name other\n\ - crates/demo/src/lib.rs:4 issue name other\n\ - crates/demo/src/lib.rs:5 issue name other\n\ - policy/demo.rego:1 pattern name other\n", + "crates/demo/notes.txt:1 rule 'fact name other'\n\ + crates/demo/notes.txt:2 rule 'path name other'\n\ + crates/demo/notes.txt:3 rule 'source name other'\n\ + crates/demo/notes.txt:4 rule 'issue name other'\n\ + crates/demo/notes.txt:5 rule 'issue name other'\n\ + crates/demo/src/lib.rs:1 rule 'fact name other'\n\ + crates/demo/src/lib.rs:2 rule 'path name other'\n\ + crates/demo/src/lib.rs:3 rule 'source name other'\n\ + crates/demo/src/lib.rs:4 rule 'issue name other'\n\ + crates/demo/src/lib.rs:5 rule 'issue name other'\n\ + policy/demo.rego:1 rule 'pattern name other'\n", "one sorted pointer per banned shape per file, and nothing else" ); @@ -7068,12 +7058,12 @@ fn the_committed_portability_rules_fire_on_every_banned_shape() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "mise-tasks/seed.sh:1 shell parse unsafe\n\ - mise-tasks/seed.sh:2 shell edit unsafe\n\ - mise-tasks/seed.sh:3 shell read unsafe\n\ - mise-tasks/seed.sh:4 shell list unsafe\n\ - mise-tasks/seed.sh:5 shell guard unsafe\n\ - tests/seed.bats:2 branch edit unsafe\n", + "mise-tasks/seed.sh:1 rule 'shell parse unsafe'\n\ + mise-tasks/seed.sh:2 rule 'shell edit unsafe'\n\ + mise-tasks/seed.sh:3 rule 'shell read unsafe'\n\ + mise-tasks/seed.sh:4 rule 'shell list unsafe'\n\ + mise-tasks/seed.sh:5 rule 'shell guard unsafe'\n\ + tests/seed.bats:2 rule 'branch edit unsafe'\n", "one sorted pointer per banned construct, and nothing else" ); @@ -7172,7 +7162,7 @@ fn the_committed_example_config_loads_over_the_binary() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "**/*.rs source carry broken\n", + "**/*.rs rule 'source carry broken'\n", "a command condemns a batch, so the pointer is the glob and carries no line" ); } @@ -7226,7 +7216,7 @@ fn the_shipped_starter_config_loads_over_the_binary() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "src/main.rs:1 source carry broken\n", + "src/main.rs:1 rule 'source carry broken'\n", "a forbid rule points at the line, not at the batch a command condemns" ); } @@ -7315,7 +7305,7 @@ fn warn_findings_report_without_failing_the_run() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "lib.rs:1 no-todo\n", + "lib.rs:1 rule 'no-todo'\n", "the warn finding must still be reported" ); } @@ -12418,7 +12408,7 @@ fn a_tracked_instruction_may_not_prescribe_the_denied_commit_identity() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "HOWTO.md:2 remedy carry refused\n", + "HOWTO.md:2 rule 'remedy carry refused'\n", "one pointer, and the matched line is never echoed" ); @@ -13036,7 +13026,7 @@ fn a_met_precondition_lets_the_rule_run_normally() { "a met precondition is not a filter: {}", stderr(&output) ); - assert_eq!(stdout(&output), "lib.rs:1 needs-the-vendor-tree\n"); + assert_eq!(stdout(&output), "lib.rs:1 rule 'needs-the-vendor-tree'\n"); } /// A ratchet still fires on an empty match set, which the new skip must not @@ -13105,7 +13095,7 @@ fn a_deciding_kind_over_the_same_tree_does_block() { "the control must block, or the approximating case proves nothing: {}", stderr(&output) ); - assert_eq!(stdout(&output), "lib.rs:1 no-todo\n"); + assert_eq!(stdout(&output), "lib.rs:1 rule 'no-todo'\n"); } /// Every kind carries a classification, and the vocabulary is total. diff --git a/crates/batten/tests/it/common/mod.rs b/crates/batten/tests/it/common/mod.rs index 2aee62433..4ede695ab 100644 --- a/crates/batten/tests/it/common/mod.rs +++ b/crates/batten/tests/it/common/mod.rs @@ -160,15 +160,25 @@ fn scan_declared_patterns() -> String { /// /// When no line carries the class, naming what was said. pub(crate) fn printed_pointers(said: &str, class: &str, rule: &str) -> String { + // CLOUD-2075's grammar: `verdict '' rule '' at ; …`. + let opener = format!("verdict '{class}' rule '{rule}' at "); said.lines() .find_map(|line| { - let rest = line.split(&format!("{class} ")).nth(1)?; - let pointers = rest.split(&format!(" {rule}")).next()?; + let rest = line.split(opener.as_str()).nth(1)?; + let pointers = rest.split("; ").next()?.split(" —").next()?; Some(pointers.trim().to_owned()) }) .unwrap_or_else(|| panic!("no line refuses as `{class}`: {said}")) } +/// The rule that refused, read off the labelled finding line through the +/// engine's own reader (CLOUD-2075) — never guessed from word positions. +pub(crate) fn refusing_rule(said: &str) -> Option { + said.lines() + .find_map(batten::refusal::parse_finding) + .map(|parsed| parsed.rule) +} + pub(crate) fn at_root(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/crates/batten/tests/it/config_trust.rs b/crates/batten/tests/it/config_trust.rs index 9b7d26784..896fde072 100644 --- a/crates/batten/tests/it/config_trust.rs +++ b/crates/batten/tests/it/config_trust.rs @@ -84,7 +84,7 @@ fn a_working_tree_that_deletes_a_rule_is_still_judged_by_base_policy() { "base policy must still fire: a violation is exit 2" ); assert!( - stdout(&guarded).contains("lib.rs:2 no-todo"), + stdout(&guarded).contains("lib.rs:2 rule 'no-todo'"), "the base rule's finding must be reported, got: {}", stdout(&guarded) ); @@ -235,7 +235,7 @@ fn without_the_flag_stdout_is_exactly_the_findings() { ); let output = run(&repo, &["check"]); assert_eq!(output.status.code(), Some(2)); - assert_eq!(stdout(&output), "lib.rs:1 no-todo\n"); + assert_eq!(stdout(&output), "lib.rs:1 rule 'no-todo'\n"); } // --- a ref this binary cannot read is a usage error, never a verdict --------- @@ -515,7 +515,7 @@ fn a_deleted_working_config_still_gets_the_base_rule_verdict() { String::from_utf8_lossy(&output.stderr) ); assert!( - stdout(&output).contains("a.rs:1 no-todo"), + stdout(&output).contains("a.rs:1 rule 'no-todo'"), "got: {}", stdout(&output) ); diff --git a/crates/batten/tests/it/connector_verbs.rs b/crates/batten/tests/it/connector_verbs.rs index 567503889..b7f515055 100644 --- a/crates/batten/tests/it/connector_verbs.rs +++ b/crates/batten/tests/it/connector_verbs.rs @@ -145,9 +145,8 @@ fn every_spelling_of_a_decided_verb_is_refused() { // The head rather than the whole line since CLOUD-1637: a first // sighting appends `— ; `, so the line ends with a // route target. On a repeat the head IS the line. - let head = text.split(" — ").next().unwrap_or(&text); assert!( - head.trim().ends_with(rule), + crate::common::refusing_rule(&text).as_deref() == Some(rule), "{tool} must be refused by {rule}, got: {text}" ); } @@ -228,37 +227,11 @@ fn each_refusal_names_its_own_remedy() { assert_eq!(refusal.status.code(), Some(2), "{verb} is refused"); let text = stderr(&refusal); // CLOUD-1286: the remedy is one hop from the rule id on the line, and - // this case still asserts it PER ROW — which is what caught the test - // being wrong before, when it demanded `mise run land` from a verb whose - // remedy is to background the command. A generic assertion, or one that - // only checked the hop resolved, would pass over the same mismatch. - // THE ROUTE IS NOT PART OF THE POINTER (CLOUD-1386). A first sighting - // appends the class's route after an em dash, so "the last word of the - // line" stopped being the rule id — it became the last word of a - // sentence. The pointer half is what this reads, and taking it - // explicitly says so rather than relying on the route's absence. - // AND THE NAME IS THREE WORDS (CLOUD-1638), so the last WORD is one - // third of it. `explain` answers about the CLASS, which is the head's - // first three words on both arms — a discriminating row appends its id - // after the pointers and a collapsed row's id IS the class, so reading - // the front is right in both cases and reading the back is right in - // neither. - let pointer = text.split(" — ").next().unwrap_or(&text); - let words: Vec<&str> = pointer.split_whitespace().collect(); - assert!( - words.len() >= 3, - "{verb}: a deny names the class that fired: {text}" - ); - // THE ROW'S remedy, not the class's: what this case asserts is that the - // refusal reaches the row's own `reason`, and `explain` answers about - // the class. The id is the head's last three words on a discriminating - // row; on a collapsed row it IS the class, so the first three resolve. - let tail = words[words.len() - 3..].join(" "); - let class = words[..3].join(" "); - let mut explained = run(&repo, &["policy", "rule", &tail]); - if explained.status.code() != Some(0) { - explained = run(&repo, &["policy", "rule", &class]); - } + // this case asserts it PER ROW. CLOUD-2075 labels the id, so it is read + // off the line rather than guessed from word positions. + let id = crate::common::refusing_rule(&text) + .unwrap_or_else(|| panic!("{verb}: a deny labels its rule: {text}")); + let explained = run(&repo, &["policy", "rule", &id]); assert_eq!(explained.status.code(), Some(0), "{verb}: the row resolves"); let explained_text = String::from_utf8_lossy(&explained.stdout); assert!( diff --git a/crates/batten/tests/it/emission_census.rs b/crates/batten/tests/it/emission_census.rs index 2b5c3accd..d7c822ccb 100644 --- a/crates/batten/tests/it/emission_census.rs +++ b/crates/batten/tests/it/emission_census.rs @@ -171,3 +171,105 @@ fn the_scan_discriminates() { .is_empty() ); } + +// --- the finding label census (CLOUD-2075) ---------------------------------- + +/// The two labels a finding line carries, which only `refusal::label` spells. +const LABELS: &[&str] = &["verdict '", "rule '"]; + +/// The renderers CLOUD-2075 deleted; their return would be a second grammar. +const RETIRED: &[&str] = &[ + "fn deny_text(", + "fn ask_text(", + "fn first_sighting_line(", + "fn render_advice(", +]; + +/// Every hand-spelled label in `source`'s production half: a `verdict '` or +/// `rule '` inside a string literal, before the first `#[cfg(test)]`, on a line +/// that is not a comment. +fn hand_spelled_labels(source: &str) -> Vec<(usize, String)> { + let production = source.split("#[cfg(test)]").next().unwrap_or(source); + let mut found = Vec::new(); + for (index, line) in production.lines().enumerate() { + if line.trim_start().starts_with("//") { + continue; + } + for label in LABELS { + for (at, _) in line.match_indices(label) { + // Inside a literal when an odd number of quotes precede it. + if line[..at].matches('"').count() % 2 == 1 { + found.push((index + 1, line.trim().to_owned())); + } + } + } + } + found +} + +/// Every `.rs` file under `dir`, recursively. +fn sources(dir: &std::path::Path, into: &mut Vec) { + for entry in fs::read_dir(dir).expect("the source tree is readable") { + let path = entry.expect("a directory entry").path(); + if path.is_dir() { + sources(&path, into); + } else if path.extension().is_some_and(|extension| extension == "rs") { + into.push(path); + } + } +} + +#[test] +fn no_finding_label_is_spelled_outside_the_projection() { + let mut files = Vec::new(); + sources(&at_root("crates/batten/src"), &mut files); + assert!(files.len() > 10, "the census reads the source tree"); + let mut spelled = Vec::new(); + for path in &files { + let source = fs::read_to_string(path).expect("a source file is readable"); + for retired in RETIRED { + assert!( + !source.contains(retired), + "{} brings back `{retired}`", + path.display() + ); + } + if path.ends_with("refusal.rs") { + continue; + } + for (line, text) in hand_spelled_labels(&source) { + spelled.push(format!("{}:{line} {text}", path.display())); + } + } + assert!( + spelled.is_empty(), + "spell a label through `refusal::label`, never by hand: {spelled:#?}" + ); +} + +#[test] +fn the_label_census_discriminates() { + let seeded = "fn a() {\n let rule = format!(\"rule '{}'\", id);\n}\n"; + assert_eq!(hand_spelled_labels(seeded).len(), 1, "a literal is found"); + assert!( + hand_spelled_labels("// rule 'x'\n").is_empty(), + "a comment line is not an emission" + ); + let tail = "fn a() {}\n#[cfg(test)]\nmod tests { const X: &str = \"rule 'x'\"; }\n"; + assert!( + hand_spelled_labels(tail).is_empty(), + "a test tail is not production" + ); +} + +#[test] +fn every_hook_source_declares_its_finding_lifecycle() { + use batten::hook::{HookSource, Lifecycle}; + for source in HookSource::ALL { + let expected = match source { + HookSource::Harness => Lifecycle::Sighted, + HookSource::Cli | HookSource::Git | HookSource::Ci => Lifecycle::PrintedFull, + }; + assert_eq!(source.finding_lifecycle(), expected, "{}", source.as_str()); + } +} diff --git a/crates/batten/tests/it/fail_on_warning.rs b/crates/batten/tests/it/fail_on_warning.rs index 449d1e439..514dacdec 100644 --- a/crates/batten/tests/it/fail_on_warning.rs +++ b/crates/batten/tests/it/fail_on_warning.rs @@ -39,7 +39,7 @@ fn warn_only() -> String { } /// The pointer line the fixture below produces, byte for byte. -const WARN_POINTER: &str = "lib.rs:2 no-todo\n"; +const WARN_POINTER: &str = "lib.rs:2 rule 'no-todo'\n"; /// Create a fresh temp repo containing `config`, a `lib.rs` that trips the rule, /// and optionally a `batten.local.toml`. diff --git a/crates/batten/tests/it/fixture_repos.rs b/crates/batten/tests/it/fixture_repos.rs index 545223895..1a36a14f8 100644 --- a/crates/batten/tests/it/fixture_repos.rs +++ b/crates/batten/tests/it/fixture_repos.rs @@ -273,7 +273,7 @@ impl Case { /// The pointer a failure is reported under — `path:line rule-id`, the same /// shape a finding takes, and never the matched bytes (rule 4). fn pointer(&self) -> String { - format!("{}:{} {}", self.path, self.line, self.rule) + format!("{}:{} rule '{}'", self.path, self.line, self.rule) } } diff --git a/crates/batten/tests/it/forge_read_first.rs b/crates/batten/tests/it/forge_read_first.rs index 2d14beb43..35480ba6a 100644 --- a/crates/batten/tests/it/forge_read_first.rs +++ b/crates/batten/tests/it/forge_read_first.rs @@ -24,11 +24,16 @@ fn root() -> PathBuf { /// A fixture copy of the committed row and class, over the committed module. fn bench(name: &str) -> PathBuf { + bench_with(name, "") +} + +/// [`bench`] with `extra` appended to the fixture's config. +fn bench_with(name: &str, extra: &str) -> PathBuf { let module = std::fs::read_to_string(root().join("policy/forge-read-first.rego")) .expect("the module is readable"); Fixture::new(name) .config( - "version = 1\n\n\ + &("version = 1\n\n\ [[rule]]\nid = \"forge read first\"\nkind = \"policy\"\n\ scope = \"mediated_call\"\nmodule = \"policy/forge-read-first.rego\"\n\ severity = \"warn\"\n\n\ @@ -36,7 +41,9 @@ fn bench(name: &str) -> PathBuf { gloss = \"a code-host call; read the memory documenting this host's GitHub access first\"\n\ class = \"A fixture copy of the committed class.\"\n\n\ [[verdict.route]]\nid = \"memory read first\"\nkind = \"document\"\n\ - target = \".serena/memories/github-access.md\"\n", + target = \".serena/memories/github-access.md\"\n" + .to_owned() + + extra), ) .file("policy/forge-read-first.rego", &module) .git() @@ -45,8 +52,14 @@ fn bench(name: &str) -> PathBuf { } fn bash(command: &str) -> String { + bash_in("s1", command) +} + +/// A Bash call in `session` (CLOUD-2075: the lifecycle is per context). +fn bash_in(session: &str, command: &str) -> String { serde_json::json!({ "hook_event_name": "PreToolUse", + "session_id": session, "tool_name": "Bash", "tool_input": {"command": command}, }) @@ -56,6 +69,7 @@ fn bash(command: &str) -> String { fn tool(name: &str) -> String { serde_json::json!({ "hook_event_name": "PreToolUse", + "session_id": "s1", "tool_name": name, "tool_input": {}, }) @@ -130,3 +144,46 @@ fn the_committed_policy_hands_a_gh_read_its_memory() { "no route repeats its verb: {said}" ); } + +/// A warn advisory is the full arm once per context, then the pointer +/// (CLOUD-2075 §7 case 10). +#[test] +fn a_warn_advisory_is_full_once_then_a_pointer() { + let dir = bench("forge-read-first-lifecycle"); + let (first_code, first) = adjudicate(&dir, &bash_in("s1", "gh pr view 1")); + let (second_code, second) = adjudicate(&dir, &bash_in("s1", "gh pr view 1")); + assert_eq!((first_code, second_code), (Some(0), Some(0))); + let labels = "verdict 'forge read first' rule 'forge read first'"; + assert!(first.contains(labels) && first.contains(" —"), "{first}"); + assert!( + first.contains("code-host call"), + "the gloss rides the full arm: {first}" + ); + assert!( + second.contains(labels) && second.contains(POINTER), + "{second}" + ); + assert!( + !second.contains(" —"), + "the second is the pointer: {second}" + ); +} + +/// A document route into a read-redirected path names its reader (CLOUD-2075 +/// §7 case 11), and only where the consumer declares one. +#[test] +fn a_document_route_into_a_read_redirected_path_names_its_reader() { + let redirected = bench_with( + "forge-read-first-reader", + "\n[[redirect]]\nglob = \".serena/memories/**\"\n\ + mutation = \"use the memory tools\"\nread = \"read_memory\"\n", + ); + let (_, said) = adjudicate(&redirected, &bash("gh pr view 1")); + assert!( + said.contains(&format!("{POINTER} via read_memory")), + "{said}" + ); + let plain = bench("forge-read-first-no-reader"); + let (_, said) = adjudicate(&plain, &bash("gh pr view 1")); + assert!(!said.contains(" via "), "{said}"); +} diff --git a/crates/batten/tests/it/harness_wiring.rs b/crates/batten/tests/it/harness_wiring.rs index fe9a79a7b..9d9395667 100644 --- a/crates/batten/tests/it/harness_wiring.rs +++ b/crates/batten/tests/it/harness_wiring.rs @@ -446,7 +446,7 @@ fn a_committed_sibling_beside_the_mediator_is_refused() { let output = check(&repo, Some(&outside)); assert!(!output.status.success(), "a sibling passed"); assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -495,7 +495,7 @@ fn a_stop_sibling_is_refused_too_so_the_scope_is_every_event() { let output = check(&repo, Some(&outside)); assert!(!output.status.success(), "a Stop sibling passed"); assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -588,7 +588,7 @@ fn the_launcher_hooks_are_refused_rather_than_tolerated() { // A COUNT AND NO PATH: a merged path is under somebody's home directory and // differs per machine, so rule 4 and §6 byte-stability both forbid it travelling. assert!( - findings(&output).contains("2 hook wire missing"), + findings(&output).contains("2 rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -642,7 +642,7 @@ fn the_committed_half_survives_an_absent_merged_surface() { "the committed half went silent with no merged surface — CLOUD-1307 has been reintroduced by recombining the two rows" ); assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -694,7 +694,7 @@ fn a_committed_surface_that_will_not_parse_is_reported() { // was no name to assert on — a count rendered into the pointer field, which // cost a session that could not learn which file the host could not read. assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "the finding does not name the unreadable surface: {}", findings(&output) ); diff --git a/crates/batten/tests/it/init.rs b/crates/batten/tests/it/init.rs index 382ea35ad..41cd2714e 100644 --- a/crates/batten/tests/it/init.rs +++ b/crates/batten/tests/it/init.rs @@ -104,7 +104,8 @@ fn a_second_init_refuses_and_leaves_the_file_untouched() { // `Refusal` type makes the clause impossible to omit, and this proves the // projection reaches the channel rather than stopping at the constructor. assert!( - reason.contains(batten::init::CONFIG_EXISTS) && reason.contains("Fix:"), + reason.contains(batten::init::CONFIG_EXISTS) + && reason.contains("edit batten.toml in place"), "the refusal must name its rule and its fix: {reason}" ); assert_eq!( @@ -200,7 +201,7 @@ fn the_refusal_survives_silent() { let reason = stderr(&output); assert!(reason.contains("batten.toml")); assert!( - reason.contains("Fix:"), + reason.contains("edit batten.toml in place"), "the fix clause is not chatter: {reason}" ); } diff --git a/crates/batten/tests/it/mediated_admission.rs b/crates/batten/tests/it/mediated_admission.rs index 563b764b7..66a8f9f46 100644 --- a/crates/batten/tests/it/mediated_admission.rs +++ b/crates/batten/tests/it/mediated_admission.rs @@ -85,7 +85,7 @@ fn fixture(name: &str) -> PathBuf { fn write_payload(path: &str) -> String { let escaped = serde_json::to_string(path).expect("a path is encodable"); format!( - "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Write\",\ + "{{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"s1\",\"tool_name\":\"Write\",\ \"tool_input\":{{\"file_path\":{escaped},\"content\":\"x\"}}}}" ) } @@ -340,6 +340,42 @@ fn a_subject_copied_from_the_refusal_line_admits_the_write() { ); } +/// The override route on the line IS the request that admits (CLOUD-2075 §7 +/// case 12): on both arms, ready to run, with the subject the refusal binds. +#[test] +fn the_override_route_on_the_line_is_the_request_that_admits() { + let dir = fixture("mediated-admission-route-on-line"); + let opener = format!( + "admit with batten override request --rule '{RULE}' --verdict '{CLASS}' --subject '" + ); + let mut subject = String::new(); + for _ in 0..2 { + let refused = run_with_stdin( + &dir, + &["adjudicate", "--harness", "exit-code"], + &write_payload(GUARDED), + ); + assert_eq!(refused.status.code(), Some(2), "the premise"); + let said = String::from_utf8_lossy(&refused.stderr).into_owned(); + let rest = said + .split(opener.as_str()) + .nth(1) + .unwrap_or_else(|| panic!("no override route on the line: {said}")); + subject = rest + .split('\'') + .next() + .expect("a quoted subject") + .to_owned(); + } + let admission = request(&dir, &subject, "read straight off the override route"); + assert!(spend(&dir, &admission, &subject), "spend must consume it"); + assert_eq!( + verdict(&dir, GUARDED), + Some(0), + "the route's request admits" + ); +} + /// An ISSUED admission does not admit — only a spent one does. /// /// `admission.rs` calls this "the whole economy": a mint that suppressed on its diff --git a/crates/batten/tests/it/mediated_verbs.rs b/crates/batten/tests/it/mediated_verbs.rs index cbb4060c7..0a21f8ff4 100644 --- a/crates/batten/tests/it/mediated_verbs.rs +++ b/crates/batten/tests/it/mediated_verbs.rs @@ -1086,7 +1086,7 @@ fn a_generic_read_of_a_memory_is_refused_and_names_the_tool_that_answers() { "not the shell-substitution class: {refusal}" ); assert!( - refusal.contains(&format!("{GUARDED} read_memory ")), + refusal.contains(&format!("at {GUARDED} read_memory;")), "the declared read route is the subject after the path: {refusal}" ); // The remedy is one hop away since CLOUD-1286, and it must reach the READ diff --git a/crates/batten/tests/it/one_pr.rs b/crates/batten/tests/it/one_pr.rs index 389f02414..1b9e96596 100644 --- a/crates/batten/tests/it/one_pr.rs +++ b/crates/batten/tests/it/one_pr.rs @@ -110,9 +110,8 @@ fn a_second_pr_while_the_first_is_unlanded_is_refused() { let refusal = open_a_pr(&dir); assert_eq!(refusal.status.code(), Some(2), "{}", stderr(&refusal)); let text = stderr(&refusal); - let head = text.split(" — ").next().unwrap_or(&text); assert!( - head.trim().ends_with("review open twice"), + crate::common::refusing_rule(&text).as_deref() == Some("review open twice"), "refused by the one-PR row: {text}" ); } diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 851b9f099..c26317663 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -2260,7 +2260,11 @@ const CENSUS: &[Verb] = &[ path: "adjudicate", args: &["--harness", "exit-code"], stdin: Stdin::MediatedCall, - disposition: Disposition::PointerOnly, + disposition: Disposition::Echoes( + "a finding's full arm carries the declaring row's own `reason`, the class gloss \ + and each override's precondition — the caller's declaration, once per context \ + per compaction cycle (CLOUD-2075)", + ), }, // THE ONE VERB WHOSE ANSWER IS THE PAYLOAD, and `command` is deliberately the // field asked for: it is where `mediated_call` seeds its canary, so any other @@ -3065,16 +3069,16 @@ fn the_corpus_is_live_subject_matter() { let checked = run_in(&corpus, &["check"], Stdin::Nothing); let stdout = String::from_utf8_lossy(&checked.stdout).into_owned(); assert!( - stdout.contains("subject.txt:2 no-canary"), + stdout.contains("subject.txt:2 rule 'no-canary'"), "the forbid rule must fire on the seeded line, or `check` is judging nothing: {stdout}" ); assert!( - stdout.contains("budget.loaded"), + stdout.contains("rule 'budget.loaded'"), "the budget must overflow, or its per-file rendering is never reached: {stdout}" ); let stderr = String::from_utf8_lossy(&checked.stderr).into_owned(); assert!( - stderr.contains("waived subject.txt:2 no-canary-waived"), + stderr.contains("waived subject.txt:2 rule 'no-canary-waived'"), "the waiver must apply, or its audit line is never rendered: {stderr}" ); diff --git a/crates/batten/tests/it/punt_receipt.rs b/crates/batten/tests/it/punt_receipt.rs index 355cd85d8..b9f0ebf9f 100644 --- a/crates/batten/tests/it/punt_receipt.rs +++ b/crates/batten/tests/it/punt_receipt.rs @@ -227,14 +227,11 @@ fn the_refusal_names_the_row_and_its_remedy() { refusal.contains("verify"), "names the check it wants proved: {refusal}" ); - // THE PROSE IS NOT ON THIS CHANNEL, and asserting it were would have been - // this case arguing against the posture its own row follows. `reason` is - // reached through `batten policy rule`, which is where a remedy belongs - // (house-style §6, non-negotiable rule 4): the channel carries a pointer and - // the document carries the payload. + // THE ROW'S REMEDY RIDES THE FULL ARM (CLOUD-2075), once per context per + // compaction cycle; this payload names no session, so every firing is full. assert!( - !refusal.contains("mise run land"), - "the remedy stays in the config the refusal points at: {refusal}" + refusal.contains("mise run land"), + "the full arm carries the row's remedy: {refusal}" ); assert!( !refusal.contains("batten-receipts"), @@ -398,14 +395,10 @@ fn override_as(dir: &Path, class: &str, verb: &[&str], stdin: &str) -> std::proc String::from_utf8_lossy(&refused.stdout), stderr(&refused) ); - let subject = said - .lines() - .find_map(|line| { - let rest = line.split(&format!("{class} ")).nth(1)?; - let artifacts = rest.split(" turn mint ahead").next()?; - Some(artifacts.split_whitespace().collect::>().join(",")) - }) - .unwrap_or_else(|| panic!("the write is refused as `{class}`: {said}")); + let subject = crate::common::printed_pointers(&said, class, "turn mint ahead") + .split_whitespace() + .collect::>() + .join(","); let mut args = vec!["override"]; args.extend_from_slice(verb); args.extend_from_slice(&[ diff --git a/crates/batten/tests/it/refusal_ceiling.rs b/crates/batten/tests/it/refusal_ceiling.rs index 1fb627436..4603039c5 100644 --- a/crates/batten/tests/it/refusal_ceiling.rs +++ b/crates/batten/tests/it/refusal_ceiling.rs @@ -37,6 +37,12 @@ //! `$GIT_DIR` is the only way to observe a genuine first firing without reaching //! into the tree under test. The fixture carries the COMMITTED config, so what it //! measures is still this repository's rows. +//! +//! **EVERY PAYLOAD NAMES A SESSION (CLOUD-2075).** The store is per context — +//! the session, plus the agent id where a subagent reads — and a payload naming +//! no session is full on every firing and marks nothing. So the real-root +//! repeats fire under one session this suite owns, and the fixture cases name +//! theirs explicitly through [`fires_in`]. // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -51,12 +57,28 @@ fn root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } +/// The session the real-root repeats fire under: this suite's own context, so +/// it never consumes a sighting of the working session's. +const SUITE_SESSION: &str = "refusal-ceiling-suite"; + fn payload(command: &str) -> String { - let encoded = serde_json::to_string(command).expect("a command is encodable"); - format!( - "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ - \"tool_input\":{{\"command\":{encoded}}}}}" - ) + payload_in(Some(SUITE_SESSION), None, command) +} + +/// A Bash `PreToolUse` payload in `session` (and `agent`), or naming none. +fn payload_in(session: Option<&str>, agent: Option<&str>, command: &str) -> String { + let mut value = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": command}, + }); + if let Some(session) = session { + value["session_id"] = serde_json::Value::from(session); + } + if let Some(agent) = agent { + value["agent_id"] = serde_json::Value::from(agent); + } + value.to_string() } /// The refusal text a mediated call produces on a REPEAT firing, which is what @@ -98,19 +120,47 @@ fn refusal_once(payload: &str) -> Option { /// ceiling had been raised or deleted, which is the whole failure the /// `refusal-ceiling-raised` weakening exists to report. fn declared_ceiling() -> usize { + declared("max_tokens") +} + +/// The full arm's ceiling, read the same way (CLOUD-2075). +fn declared_first_sighting_ceiling() -> usize { + declared("first_sighting_max_tokens") +} + +fn declared(key: &str) -> usize { let text = std::fs::read_to_string(root().join("batten.toml")) .expect("the committed config is readable"); let config: toml::Value = toml::from_str(&text).expect("the committed config parses"); usize::try_from( config .get("refusal") - .and_then(|table| table.get("max_tokens")) + .and_then(|table| table.get(key)) .and_then(toml::Value::as_integer) - .expect("`[refusal] max_tokens` is declared"), + .unwrap_or_else(|| panic!("`[refusal] {key}` is declared")), ) .expect("a ceiling is not negative") } +/// The committed `reason` of one `[[rule]]` row. +fn rule_reason(id: &str) -> String { + let text = std::fs::read_to_string(root().join("batten.toml")) + .expect("the committed config is readable"); + let config: toml::Value = toml::from_str(&text).expect("the committed config parses"); + config + .get("rule") + .and_then(toml::Value::as_array) + .and_then(|rows| { + rows.iter() + .find(|row| row.get("id").and_then(toml::Value::as_str) == Some(id)) + }) + .and_then(|row| row.get("reason")) + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("`{id}` declares a reason")) + .trim() + .to_owned() +} + /// `budget.rs`'s estimator, which is what the engine's own `Ceiling::over` uses. fn estimated_tokens(line: &str) -> usize { line.len() / 4 @@ -196,8 +246,8 @@ fn a_declared_refusal_emits_its_class_and_its_pointers_and_stops() { // hatch sentence. Each of the four was a copy of something declared once. let line = refusal("sed -n '1,40p' AGENTS.md").expect("the row refuses"); assert!( - line.starts_with("tool run loose"), - "the class leads the line: {line}" + line.starts_with("verdict 'tool run loose' rule 'tool select other'"), + "the labelled class leads the line: {line}" ); for wrapper in ["Refused by", "Fix:", "Bypass with", " ("] { assert!( @@ -250,12 +300,17 @@ fn fixture(name: &str) -> PathBuf { staged.git().base_commit().build() } -/// One firing in a fixture, returning the emitted line. +/// One firing in a fixture under session `s1`, returning the emitted line. fn fires(repo: &Path, command: &str) -> String { + fires_in(repo, Some("s1"), None, command) +} + +/// One firing in a fixture in `session` (plus `agent`), returning the line. +fn fires_in(repo: &Path, session: Option<&str>, agent: Option<&str>, command: &str) -> String { let run = run_with_stdin( repo, &["adjudicate", "--harness", "exit-code"], - &payload(command), + &payload_in(session, agent, command), ); assert_eq!( run.status.code(), @@ -265,6 +320,17 @@ fn fires(repo: &Path, command: &str) -> String { stderr(&run).trim().to_owned() } +/// A hook event other than a Bash call, through the Claude Code adapter. +fn hook(repo: &Path, value: &serde_json::Value) -> std::process::Output { + run_with_stdin( + repo, + &["adjudicate", "--harness", "claude-code"], + &value.to_string(), + ) +} + +const FULL: &str = " — "; + /// The first sighting of a document-only class carries its definition. /// /// **THE CASE THE ROW WAS FILED ON.** `tool run loose` declares exactly one @@ -282,7 +348,7 @@ fn a_first_sighting_carries_the_gloss_and_its_route_by_kind() { let repo = fixture("first-sighting-document-route"); let line = fires(&repo, "head -40 batten.toml"); assert!( - line.starts_with("tool run loose"), + line.starts_with("verdict 'tool run loose'"), "the class still leads the line: {line}" ); assert!( @@ -345,33 +411,258 @@ fn a_shape_first_sighting_names_the_rows_remedy_verb() { ); } -/// The repeat is compact, and a byte PREFIX of the first sighting. +/// The pointer arm is a byte PREFIX of the full arm, and sheds no pointer +/// (CLOUD-2075 §7 case 2). /// -/// The prefix property is what makes the two arms one line rather than two -/// renderings: everything the repeat says, the first sighting said first and in -/// the same order. A reader who has met the class recognises the compact form as -/// the head of what they already read. +/// This reverses the repeat that dropped its routes: everything the pointer arm +/// says, the full arm said first and in the same order, and the subjects and +/// routes — every way out — are the same set on both. #[test] -fn a_repeat_drops_the_definition_and_keeps_the_pointers() { - let repo = fixture("repeat-is-compact"); +fn the_pointer_arm_carries_every_route_and_subject_the_full_arm_does() { + let repo = fixture("pointer-carries-routes"); let first = fires(&repo, "head -40 batten.toml"); let repeat = fires(&repo, "head -40 batten.toml"); assert_ne!(first, repeat, "the two arms differ, or nothing was saved"); assert!( first.starts_with(&repeat), - "the repeat is a byte prefix of the first sighting: {repeat:?} vs {first:?}" + "the pointer is a byte prefix of the full arm: {repeat:?} vs {first:?}" + ); + assert!(!repeat.contains(FULL), "the pointer has no tail: {repeat}"); + let head = first.split(FULL).next().expect("a head"); + assert_eq!(head, repeat, "the full arm's pointers ARE the pointer arm"); + for kept in [ + "rule 'tool select other'", + "batten.toml", + "read rules/scanning.md", + "run batten policy rule 'tool select other'", + ] { + assert!( + repeat.contains(kept), + "the pointer keeps `{kept}`: {repeat}" + ); + } +} + +/// The full arm carries the row's own reason and both labels (CLOUD-2075 §7 +/// case 1), reversing the first sighting that left the reason out. +#[test] +fn a_first_sighting_carries_the_rows_reason_and_both_labels() { + let repo = fixture("first-sighting-reason"); + let line = fires(&repo, "head -40 batten.toml"); + let reason = rule_reason("tool select other"); + let opening: String = reason.chars().take(40).collect(); + for needle in [ + "verdict 'tool run loose'", + "rule 'tool select other'", + "a shell text utility stood in for the structured file surface", + opening.as_str(), + "read rules/scanning.md", + "run batten policy rule 'tool select other'", + "Run batten policy explain 'tool run loose'.", + ] { + assert!(line.contains(needle), "`{needle}` missing: {line}"); + } +} + +/// A collapsed row — id equal to its class — still labels both (CLOUD-2075 §7 +/// case 3), on both arms. +#[test] +fn a_collapsed_row_still_labels_rule_and_verdict() { + let repo = fixture("collapsed-row-labels"); + let command = "git push --force-with-lease origin main"; + let both = "verdict 'branch write unsafe' rule 'branch write unsafe'"; + let first = fires(&repo, command); + let repeat = fires(&repo, command); + assert!(first.starts_with(both), "{first}"); + assert!(repeat.starts_with(both), "{repeat}"); +} + +/// Two contexts in one clone never consume each other's sighting (CLOUD-2075 +/// §7 case 4): session A, session B, and A's subagent each get the full arm. +#[test] +fn two_contexts_in_one_clone_each_get_the_full_text() { + let repo = fixture("two-contexts"); + let command = "head -40 batten.toml"; + let a = fires_in(&repo, Some("A"), None, command); + let b = fires_in(&repo, Some("B"), None, command); + let sub = fires_in(&repo, Some("A"), Some("x"), command); + let again = fires_in(&repo, Some("A"), None, command); + assert!(a.contains(FULL), "{a}"); + assert!(b.contains(FULL), "another session is its own reader: {b}"); + assert!(sub.contains(FULL), "a subagent is its own reader: {sub}"); + assert!( + !again.contains(FULL), + "A's second firing is the pointer: {again}" + ); +} + +/// A `SessionStart` forgets one context and leaves the rest (CLOUD-2075 §7 +/// case 5). +#[test] +fn a_session_start_forgets_only_that_contexts_sightings() { + let repo = fixture("session-start-scoped"); + let command = "head -40 batten.toml"; + let _ = fires_in(&repo, Some("A"), None, command); + let _ = fires_in(&repo, Some("B"), None, command); + let started = hook( + &repo, + &serde_json::json!({ + "hook_event_name": "SessionStart", + "session_id": "A", + "source": "startup", + }), + ); + assert_eq!(started.status.code(), Some(0), "{}", stderr(&started)); + let a = fires_in(&repo, Some("A"), None, command); + let b = fires_in(&repo, Some("B"), None, command); + assert!(a.contains(FULL), "A was forgotten, so A is told again: {a}"); + assert!(!b.contains(FULL), "B was not touched: {b}"); +} + +/// A compaction re-delivers every item the cycle saw, at once (CLOUD-2075 §7 +/// case 6), and keeps it marked. +#[test] +fn a_compaction_redelivers_every_seen_item_once_at_session_start() { + let repo = fixture("compaction-redelivers"); + let command = "head -40 batten.toml"; + let full = fires_in(&repo, Some("A"), None, command); + assert!(full.contains(FULL), "{full}"); + let compacted = hook( + &repo, + &serde_json::json!({ + "hook_event_name": "SessionStart", + "session_id": "A", + "source": "compact", + }), + ); + assert_eq!(compacted.status.code(), Some(0), "{}", stderr(&compacted)); + let document: serde_json::Value = String::from_utf8_lossy(&compacted.stdout) + .lines() + .find_map(|line| serde_json::from_str(line).ok()) + .expect("the session start emits its advisory document"); + let context = document["hookSpecificOutput"]["additionalContext"] + .as_str() + .expect("an additionalContext string"); + assert!( + context.contains(&full), + "the full arm is re-delivered byte for byte: {context}" + ); + let next = fires_in(&repo, Some("A"), None, command); + assert!(!next.contains(FULL), "and stays marked: {next}"); +} + +/// An edit to a definition mid-cycle is a new item (CLOUD-2075 §7 case 7, +/// CLOUD-1582's pair absorbed). +#[test] +fn an_edited_definition_is_a_new_item_mid_cycle() { + let repo = fixture("definition-edited"); + let command = "head -40 batten.toml"; + let first = fires_in(&repo, Some("A"), None, command); + assert!(first.contains(FULL), "{first}"); + let config = repo.join("batten.toml"); + let text = std::fs::read_to_string(&config).expect("the fixture config"); + let reason = rule_reason("tool select other"); + let opening: String = reason.chars().take(40).collect(); + assert!( + text.contains(&opening), + "the fixture carries the committed reason" + ); + let edited = text.replacen(&opening, "An edited sentence names the same remedy", 1); + std::fs::write(&config, edited).expect("rewrite the fixture config"); + let second = fires_in(&repo, Some("A"), None, command); + assert!( + second.contains(FULL) && second.contains("An edited sentence"), + "an edited definition renders in full again: {second}" ); + let third = fires_in(&repo, Some("A"), None, command); + assert!(!third.contains(FULL), "an unchanged one does not: {third}"); +} + +/// A payload naming no session is full on every firing and marks nothing +/// (CLOUD-2075 §7 case 8). +#[test] +fn a_session_less_payload_is_full_on_every_firing() { + let repo = fixture("session-less"); + let command = "head -40 batten.toml"; + let first = fires_in(&repo, None, None, command); + let second = fires_in(&repo, None, None, command); + assert!(first.contains(FULL) && second.contains(FULL), "{second}"); assert!( - repeat.contains("tool select other"), - "the rule id stays on the repeat arm — for 66 rows it is the only \ - discriminator (CLOUD-1637's second amendment): {repeat}" + !repo.join(".git/batten-sightings").exists(), + "a reader that cannot be named shares no key" + ); +} + +/// The `PostToolUse` boundary marks a full arm read from tool output +/// (CLOUD-2075 §7 case 9); a later hook firing in that context is the pointer. +#[test] +fn the_boundary_marks_and_collapses_a_full_arm_in_tool_output() { + let repo = fixture("boundary-marks"); + let command = "head -40 batten.toml"; + let full = fires_in(&repo, Some("B"), None, command); + assert!(full.contains(FULL), "{full}"); + let output = format!("{full}\ncanary\n{full}\n"); + let posted = hook( + &repo, + &serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "A", + "tool_name": "Bash", + "tool_input": {"command": "batten check"}, + "tool_response": {"stdout": output, "stderr": "", "interrupted": false}, + }), ); - for dropped in [" — ", "read rules/scanning.md"] { + assert_eq!(posted.status.code(), Some(0), "{}", stderr(&posted)); + let rewrites = batten::hook::Harness::ClaudeCode + .capabilities() + .rewrites_tool_output + .is_capturable(); + let said = String::from_utf8_lossy(&posted.stdout); + if rewrites { + assert!(said.contains("updatedToolOutput"), "{said}"); + } else { assert!( - !repeat.contains(dropped), - "the repeat drops `{dropped}`: {repeat}" + !said.contains("updatedToolOutput"), + "no rewrite where none is measured: {said}" ); } + let next = fires_in(&repo, Some("A"), None, command); + assert!(!next.contains(FULL), "the boundary marked it: {next}"); +} + +/// Every arm the corpus emits is within its declared ceiling (CLOUD-2075 §7 +/// case 1b): the full arm against `first_sighting_max_tokens`, the pointer arm +/// against `max_tokens`. A measurement, so it prints what it measured. +#[test] +fn every_arm_the_corpus_emits_is_within_its_declared_ceiling() { + let full_ceiling = declared_first_sighting_ceiling(); + let pointer_ceiling = declared_ceiling(); + let repo = fixture("corpus-arms"); + let mut widest = (0_usize, String::new(), 0_usize, String::new()); + let mut over: Vec<(usize, String)> = Vec::new(); + for (index, command) in CORPUS.iter().enumerate() { + let session = format!("corpus-{index}"); + let full = fires_in(&repo, Some(&session), None, command); + let pointer = fires_in(&repo, Some(&session), None, command); + let (full_cost, pointer_cost) = (estimated_tokens(&full), estimated_tokens(&pointer)); + if full_cost > widest.0 { + widest.0 = full_cost; + widest.1.clone_from(&full); + } + if pointer_cost > widest.2 { + widest.2 = pointer_cost; + widest.3.clone_from(&pointer); + } + if full_cost > full_ceiling { + over.push((full_cost, full)); + } + if pointer_cost > pointer_ceiling { + over.push((pointer_cost, pointer)); + } + } + eprintln!("measured full {} {}", widest.0, widest.1); + eprintln!("measured pointer {} {}", widest.2, widest.3); + assert!(over.is_empty(), "over a declared ceiling: {over:?}"); } /// The store is WRITTEN between the two firings, which is what makes the arms @@ -549,11 +840,17 @@ fn the_ceiling_can_fail() { // because the tree passing is the point of the corpus and a tree that could // fail it would be a defect rather than a fixture. let ceiling = declared_ceiling(); - let long = "path write refused ".to_owned() + &"a/very/deep/".repeat(20) + "file.rs"; + let long = "path write refused ".to_owned() + &"a/very/deep/".repeat(60) + "file.rs"; assert!( estimated_tokens(&long) > ceiling, "a line this long must be over the ceiling, or the comparison decides nothing" ); + let full_ceiling = declared_first_sighting_ceiling(); + let long_full = long.clone() + " — " + &"a sentence that runs on ".repeat(80); + assert!( + estimated_tokens(&long_full) > full_ceiling, + "a full arm this long must be over its ceiling, or that comparison decides nothing" + ); let short = refusal("nohup mise run verify &").expect("the row refuses"); assert!( estimated_tokens(&short) <= ceiling, diff --git a/crates/batten/tests/it/refusal_render_bench.rs b/crates/batten/tests/it/refusal_render_bench.rs index 5b6d47dcc..f3730bddd 100644 --- a/crates/batten/tests/it/refusal_render_bench.rs +++ b/crates/batten/tests/it/refusal_render_bench.rs @@ -16,26 +16,13 @@ //! that collapsed every arm to one value would satisfy them all. //! //! The two claims that are the renderer's own are that every compact warm repeat -//! is EXACTLY `Refusal::line()`, and that the full warm rendering is longer by a +//! is EXACTLY the pointer arm, and that the full warm rendering is longer by a //! measured margin. Those are what the report prices. //! -//! **BOTH HALVES HAVE NOW BEEN MEASURED UNDER TWO RENDERERS, and the cases are -//! written to survive the difference.** Before CLOUD-1637, a first sighting -//! appended `command` routes ONLY: `tool run loose` declares none, so it -//! rendered the identical line cold and warm, and `branch write unsafe` declared -//! two whose carried line exceeded the committed `[refusal] max_tokens` of 24 — -//! so `deny_text` dropped them and every emitted margin was ZERO. The margin was -//! real in the renderer and withheld by the budget, which is why every arm -//! carries an unbounded column beside its emitted one: a single `>` over the -//! emitted column would have reported a budget decision as a renderer defect. -//! -//! Since CLOUD-1637 a first sighting carries the class's own definition, both -//! margins are real in what is EMITTED, and the ceiling withholds nothing here. -//! The cases therefore assert the relationships rather than the numbers, and the -//! report's prose is derived from the records rather than stated — the paragraph -//! that explained the zero margins had to be rewritten the moment they stopped -//! being zero, which is the failure that generated prose about a measurement -//! invites. +//! **No renderer takes a ceiling (CLOUD-2075)**, so every figure is the whole +//! arm and there is no unbounded column beside it. The cases assert the +//! relationships rather than the numbers, and the report's prose is derived +//! from the records rather than stated. //! //! # The drift check is here rather than in the task //! @@ -47,8 +34,8 @@ //! to say a number nobody measured against. That is the row's own argument //! against recording the commit SHA, one release later. Measured here twice //! while landing: 0.0.151 → 0.0.152 → 0.0.153, three regenerations, no numbers -//! changed. The baseline is the declared classes and the declared ceiling, which -//! are what the rendering reads. +//! changed. The baseline is the declared classes, which are what the rendering +//! reads. //! //! `refusal_render_report` is re-rendered in this process and diffed against the //! committed `bench/refusal-render/RESULTS.md`, so the report cannot go stale @@ -75,7 +62,7 @@ fn records() -> Vec { .expect("this repository's committed config loads"); let registry = batten::policy::registry_for(&config.verdicts).expect("the committed registry resolves"); - refusal_render(®istry, config.refusal.as_ref()).expect("every measured class is declared") + refusal_render(®istry).expect("every measured class is declared") } fn arm( @@ -94,12 +81,12 @@ fn arm( } /// The compact line the renderer produces for a class, taken from the authority -/// rather than restated: `Refusal::line` is what a repeat sighting must equal. +/// rather than restated: the pointer arm is what a repeat sighting must equal. fn compact_line(class: &str, rule: &str) -> String { let config = batten::config::load(&root().join("batten.toml")).expect("the config loads"); let registry = batten::policy::registry_for(&config.verdicts).expect("the registry resolves"); batten::refusal::Refusal::from_class(rule, ®istry, class, &[], batten::refusal::Fix::None) - .line() + .render_finding(batten::refusal::Arm::Pointer) } #[test] @@ -161,18 +148,9 @@ fn warm_current_equals_warm_first_full_then_compact() { fn a_compact_warm_repeat_is_exactly_the_refusal_line() { // The renderer's own claim, and the case the `current-warm-first-sighting` // mutation must redden: with warm `Current` projected to a first sighting, - // the command-route class renders its routes and stops equalling `line()`. - // - // **THE UNBOUNDED HALF IS WHAT MAKES THAT TRUE, and it was measured rather - // than assumed.** Asserted over the emitted line alone, this case SURVIVES - // the mutation — measured, `mutate sweep` reported exactly that — because - // the emitted line is `Refusal::line()` on BOTH sides of `first_sighting` - // here: the ceiling withholds the routes for `branch write unsafe` and the - // renderer appends none for `tool run loose`. So a projection defect is - // invisible in what this repository emits, which is a fact about the - // repository and not a reason to assert less. The unbounded rendering is - // where `first_sighting` is observable at all, and asserting the claim there - // is what makes the row's declared mutation catchable. + // the full arm carries its definition and stops equalling the pointer arm. + // No renderer takes a ceiling (CLOUD-2075), so the emitted line is where + // `first_sighting` is observable. let records = records(); for (class, rule) in MEASURED_CLASSES { let expected = compact_line(class, rule); @@ -181,15 +159,7 @@ fn a_compact_warm_repeat_is_exactly_the_refusal_line() { assert_eq!( record.line, expected, - "{class} under {} on a warm repeat is exactly Refusal::line()", - strategy.as_str() - ); - assert_eq!( - record.unbounded_characters, - expected.chars().count(), - "{class} under {} on a warm repeat is the compact line with no ceiling to \ - thank for it — a repeat that carries routes when the budget permits them is \ - not a repeat", + "{class} under {} on a warm repeat is exactly the pointer arm", strategy.as_str() ); } @@ -213,19 +183,9 @@ fn a_full_every_time_warm_rendering_is_never_shorter_than_the_compact_repeat() { } #[test] -fn the_command_route_class_pays_a_measured_margin_when_the_budget_permits_it() { - // The half of the previous case that is a strict inequality, and it is stated - // over the UNBOUNDED rendering rather than the emitted one — which is what - // measuring, rather than assuming, changed about this case. - // - // The expectation CLOUD-1606 declared was that a full warm delivery is longer - // than a compact repeat. Over the emitted column that is FALSE in this - // repository, and not because the renderer is wrong: `branch write unsafe` - // declares two command routes, the carried line is ~37 estimated tokens, and - // the committed `[refusal] max_tokens` is 24 — so `deny_text` drops the - // routes and emits the compact line on both arms. The margin the row is about - // exists in the renderer and is withheld by the budget, and asserting it over - // the unbounded column is what says both of those things at once. +fn the_command_route_class_pays_a_measured_margin() { + // The half of the previous case that is a strict inequality: a full warm + // delivery carries the definition a repeat does not (CLOUD-2075). let records = records(); let full = arm( &records, @@ -240,10 +200,10 @@ fn the_command_route_class_pays_a_measured_margin_when_the_budget_permits_it() { Residency::Warm, ); assert!( - full.unbounded_characters > compact.unbounded_characters, - "a class with command routes pays for them on every full delivery ({} against {})", - full.unbounded_characters, - compact.unbounded_characters + full.characters > compact.characters, + "a full delivery pays for the definition on every firing ({} against {})", + full.characters, + compact.characters ); } @@ -280,10 +240,6 @@ fn the_declared_ceiling_bounds_the_repeat_rather_than_the_sighting() { sighting.characters >= repeat.characters, "{class}: a first sighting is never shorter than the repeat it precedes" ); - assert!( - repeat.characters <= repeat.unbounded_characters, - "{class}: an emitted line is never longer than the same arm rendered unbounded" - ); } } @@ -392,8 +348,7 @@ fn the_committed_report_is_what_this_tree_renders() { Path::new("bench/refusal-render/RESULTS.md").display() ) }); - let config = batten::config::load(&root().join("batten.toml")).expect("the config loads"); - let rendered = refusal_render_report(&records(), config.refusal.as_ref()); + let rendered = refusal_render_report(&records()); assert_eq!( committed, rendered, "bench/refusal-render/RESULTS.md is stale — run `mise run refusal-render-bench`" diff --git a/crates/batten/tests/it/release_assets.rs b/crates/batten/tests/it/release_assets.rs index 3ce6abe54..791fe6882 100644 --- a/crates/batten/tests/it/release_assets.rs +++ b/crates/batten/tests/it/release_assets.rs @@ -467,7 +467,7 @@ fn a_list_that_cannot_be_derived_is_partial_never_complete() { // list from, while `release ship missing` carries only an artifact and // falls back to the module's own path. assert!( - text.contains(".github/workflows/release-artifacts.yml release grade other"), + text.contains(".github/workflows/release-artifacts.yml rule 'release grade other'"), "{name}: {text}" ); assert!( diff --git a/crates/batten/tests/it/release_due.rs b/crates/batten/tests/it/release_due.rs index 2d3265fe5..5aec9d491 100644 --- a/crates/batten/tests/it/release_due.rs +++ b/crates/batten/tests/it/release_due.rs @@ -298,7 +298,7 @@ fn a_release_due_window_that_is_absent_or_torn_is_partial() { let decided = against(&dir, &forge, &["check", "--rule", "release grade early"]); assert_eq!(decided.status.code(), Some(2), "{}", said(&decided)); assert!( - said(&decided).contains("release-due-latest release grade early"), + said(&decided).contains("release-due-latest rule 'release grade early'"), "partial, pointing at the window never recorded: {}", said(&decided) ); @@ -307,7 +307,7 @@ fn a_release_due_window_that_is_absent_or_torn_is_partial() { let (code, text) = verdict("empty-trunk", "[]", &latest(Some(3600))); assert_eq!(code, Some(2), "{text}"); assert!( - text.contains("release-due-activity release grade early"), + text.contains("release-due-activity rule 'release grade early'"), "{text}" ); @@ -326,7 +326,7 @@ fn a_release_due_window_that_is_absent_or_torn_is_partial() { let decided = common::run(&torn, &["check", "--rule", "release grade early"]); assert_eq!(decided.status.code(), Some(2), "{}", said(&decided)); assert!( - said(&decided).contains("release-due-activity release grade early"), + said(&decided).contains("release-due-activity rule 'release grade early'"), "torn, not a hold: {}", said(&decided) ); diff --git a/crates/batten/tests/it/released.rs b/crates/batten/tests/it/released.rs index b737df8b5..3ec565480 100644 --- a/crates/batten/tests/it/released.rs +++ b/crates/batten/tests/it/released.rs @@ -167,7 +167,7 @@ fn row(id: &str, column: &str, hold: &str) -> String { /// Which kind it is — held or refused, and the board gate's rule — is the /// record's `issue` and `refusal` lines, echoed above it. fn refusal(text: &str, pointer: &str) -> bool { - let line = format!("{pointer} {RULE}"); + let line = format!("{pointer} rule '{RULE}'"); text.lines().any(|said| said.trim() == line) } @@ -176,7 +176,7 @@ fn refuses(text: &str, id: &str) -> bool { let lead = format!("{id} "); text.lines() .map(str::trim) - .any(|said| said.starts_with(&lead) && said.ends_with(RULE)) + .any(|said| said.starts_with(&lead) && said.ends_with(&format!("rule '{RULE}'"))) } #[test] diff --git a/crates/batten/tests/it/report_only.rs b/crates/batten/tests/it/report_only.rs index 30b800675..3580d5e21 100644 --- a/crates/batten/tests/it/report_only.rs +++ b/crates/batten/tests/it/report_only.rs @@ -222,7 +222,7 @@ fn a_manifest_with_no_verify_task_cannot_be_judged_and_says_so() { let lines: Vec<&str> = said.lines().filter(|line| !line.is_empty()).collect(); assert_eq!( lines, - vec!["mise.toml gate report silent"], + vec!["mise.toml rule 'gate report silent'"], "the could-not-look arm names the manifest, once: {lines:?}" ); } diff --git a/crates/batten/tests/it/review_answered.rs b/crates/batten/tests/it/review_answered.rs index 7af9ae45f..c97d59563 100644 --- a/crates/batten/tests/it/review_answered.rs +++ b/crates/batten/tests/it/review_answered.rs @@ -510,7 +510,10 @@ fn the_measured_shape_a_head_carrying_unresolved_threads_is_refused_naming_the_c // beside it. The retired case read `4 blocking` out of a free string; the // number is the same and it is now a decoded subject, and since CLOUD-1286 // the gloss that used to sit between them is one hop away. - assert!(decision.contains("review answer missing 4"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 4"), + "{decision}" + ); // Pointer-only (non-negotiable rule 4): the ids are not in the engine, so a // refusal naming one would be a payload this channel refuses to carry. assert!(!decision.contains("PRRT_"), "{decision}"); @@ -545,8 +548,14 @@ fn the_discriminating_pair_two_matching_beside_three_that_do_not_records_two() { reviewed(&dir, &declared); let decision = ready(&dir); denied(&decision); - assert!(decision.contains("review answer missing 2"), "{decision}"); - assert!(!decision.contains("review answer missing 5"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 2"), + "{decision}" + ); + assert!( + !decision.contains("rule 'review answer missing' at 5"), + "{decision}" + ); } // --- the conditions the projection carried, restored ------------------------ @@ -569,7 +578,10 @@ fn the_page_guard_an_unread_page_refuses_where_a_full_page_of_the_same_threads_a reviewed(&truncated, &declared); let decision = ready(&truncated); denied(&decision); - assert!(decision.contains("review answer missing 1"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 1"), + "{decision}" + ); } #[test] @@ -584,7 +596,10 @@ fn the_page_guard_adds_to_the_thread_count_rather_than_replacing_it() { reviewed(&dir, &declared); let decision = ready(&dir); denied(&decision); - assert!(decision.contains("review answer missing 3"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 3"), + "{decision}" + ); } #[test] @@ -791,7 +806,10 @@ fn the_bypass_a_compound_command_is_still_a_ready() { reviewed(&dir, &declared); let decision = call(&dir, "cd /repo && gh pr ready 702"); denied(&decision); - assert!(decision.contains("review answer missing 2"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 2"), + "{decision}" + ); } #[test] @@ -880,7 +898,10 @@ fn an_undeclared_class_refuses_with_the_token_and_says_the_registry_is_silent() ); // The count still travels: a subject is decoded from the violation, never // from the registry, which is what makes the undeclared case still useful. - assert!(decision.contains("review answer missing 3"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 3"), + "{decision}" + ); } /// The rows that judge the call: ONE RECEIPT ROW PER CHECK, as the committed diff --git a/crates/batten/tests/it/secrets_kind.rs b/crates/batten/tests/it/secrets_kind.rs index 3fdcd0bb0..1e8a3e648 100644 --- a/crates/batten/tests/it/secrets_kind.rs +++ b/crates/batten/tests/it/secrets_kind.rs @@ -263,7 +263,7 @@ fn a_planted_secret_is_a_pointer_and_never_its_bytes() { ); assert_eq!( String::from_utf8_lossy(&out.stdout), - "app.conf:1 source carry unsafe\n", + "app.conf:1 rule 'source carry unsafe'\n", "stdout is the pointer line and nothing else" ); nowhere(&env, &out, &secret, "text output"); @@ -367,7 +367,7 @@ fn the_same_input_twice_is_byte_identical_and_ordered() { assert_eq!(first.stdout, second.stdout, "text output is byte-stable"); assert_eq!( String::from_utf8_lossy(&first.stdout), - "a.conf:1 source carry unsafe\nb.conf:1 source carry unsafe\n", + "a.conf:1 rule 'source carry unsafe'\nb.conf:1 rule 'source carry unsafe'\n", "ordered by path, not by the order the scanner happened to emit" ); @@ -624,7 +624,7 @@ fn an_erroring_gate_does_not_suppress_another_gates_findings() { ); assert_eq!( String::from_utf8_lossy(&out.stdout), - "lib.rs:1 no-todo\n", + "lib.rs:1 rule 'no-todo'\n", "the surviving gate's finding still reaches stdout" ); let stderr = String::from_utf8_lossy(&out.stderr); diff --git a/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap b/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap index 2276ee2bf..1f89c4681 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap @@ -2,5 +2,5 @@ source: crates/batten/tests/it/snapshots.rs expression: stdout_of(&output) --- -a.rs:1 no-todo -b.rs:1 no-todo +a.rs:1 rule 'no-todo' +b.rs:1 rule 'no-todo' diff --git a/crates/batten/tests/it/waivers.rs b/crates/batten/tests/it/waivers.rs index 2c4161c53..a2ac452d2 100644 --- a/crates/batten/tests/it/waivers.rs +++ b/crates/batten/tests/it/waivers.rs @@ -115,7 +115,7 @@ fn without_a_waiver_the_rule_denies() { let (repo, home) = repo("waiver-baseline", RULE); let (code, stdout, _) = run(&repo, &home, &["check"]); assert_eq!(code, 2, "a deny finding is a policy verdict"); - assert!(stdout.contains("lib.rs:2 no-todo"), "got: {stdout}"); + assert!(stdout.contains("lib.rs:2 rule 'no-todo'"), "got: {stdout}"); } // subsumed: "an exempted entry passes only through a waiver carrying a reason" crates/batten/tests/it/waivers.rs that case was about the waiver SURFACE rather than about `pin add unsafe` — a live waiver clears the verdict and leaves a pointer-only audit line on stderr — and this drives the compiled binary over a `forbid` row to assert exactly that (CLOUD-1137) @@ -129,7 +129,10 @@ fn a_live_waiver_clears_the_verdict_and_audits_on_stderr() { "and is absent from the answer channel: {stdout}" ); // The compensating control: the suppression is on the record. - assert!(stderr.contains("waived lib.rs:2 no-todo"), "got: {stderr}"); + assert!( + stderr.contains("waived lib.rs:2 rule 'no-todo'"), + "got: {stderr}" + ); assert!( stderr.contains(&format!("expires {LIVE}")), "the audit line names the expiry it relied on: {stderr}" @@ -152,7 +155,7 @@ fn a_lapsed_waiver_leaves_the_finding_and_the_verdict_alone() { let (repo, home) = repo("waiver-lapsed", &format!("{RULE}{}", waiver(LAPSED))); let (code, stdout, stderr) = run(&repo, &home, &["check"]); assert_eq!(code, 2, "the rule fires again"); - assert!(stdout.contains("lib.rs:2 no-todo"), "got: {stdout}"); + assert!(stdout.contains("lib.rs:2 rule 'no-todo'"), "got: {stdout}"); assert!( !stderr.contains("waived"), "and nothing is audited as waived: {stderr}" @@ -236,7 +239,10 @@ fn a_narrowed_waiver_leaves_the_rest_of_the_rule_gating() { let home = Fixture::at(root.join("home")).build(); let (code, stdout, stderr) = run(&repo, &home, &["check"]); assert_eq!(code, 2, "the un-waived finding still blocks"); - assert!(stdout.contains("src/mine.rs:1 no-todo"), "got: {stdout}"); + assert!( + stdout.contains("src/mine.rs:1 rule 'no-todo'"), + "got: {stdout}" + ); assert!(!stdout.contains("vendor/dep.rs"), "got: {stdout}"); assert!(stderr.contains("waived vendor/dep.rs:1"), "got: {stderr}"); } @@ -327,7 +333,7 @@ fn a_live_waiver_lets_the_mediated_call_through_and_audits_it() { // The compensating control, in the tree side's shape minus the pointer a // mediated call does not have. assert!( - err.contains(&format!("waived no-merge (expires {LIVE})")), + err.contains(&format!("waived rule 'no-merge' (expires {LIVE})")), "got: {err}" ); // Pointer-only (non-negotiable 4): never the command, never the reason. diff --git a/crates/batten/tests/it/zero_config.rs b/crates/batten/tests/it/zero_config.rs index 6e5fa65fd..aaa36b438 100644 --- a/crates/batten/tests/it/zero_config.rs +++ b/crates/batten/tests/it/zero_config.rs @@ -96,7 +96,7 @@ fn a_seeded_violation_of_a_default_rule_is_a_violation() { assert_eq!(output.status.code(), Some(2), "stderr: {}", stderr(&output)); assert_eq!( stdout(&output), - "src/lib.rs:2 source carry broken\n", + "src/lib.rs:2 rule 'source carry broken'\n", "the finding is a pointer — `path:line rule-id`, never the matched line" ); assert!(stderr(&output).contains(config::DEFAULTS_NOTE)); diff --git a/mise.toml b/mise.toml index ad6ff96bb..eb3c5fa15 100644 --- a/mise.toml +++ b/mise.toml @@ -647,7 +647,7 @@ CI_VERDICT_STEPS = "Run mise run ,Run mise exec -- " # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first" +MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain" # The file inline tasks are declared in, for `mutate`'s `task-` route (CLOUD-1909). # The crate may not spell a consumer's filename (non-negotiable rule 1), so the # manifest is named here, beside the set it serves. Unset, a `task-` gate resolves From 2e1b2093da3126e414b3ba063f1065ed2584f5f2 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 16:27:42 +0000 Subject: [PATCH 08/22] fix(refusal): satisfy clippy over the projection and record the ceiling admission (CLOUD-2075) The `[refusal]` ceiling raise (24 to 96, 64 to 176) is admitted by the owner's recorded answer in `.batten/asked.jsonl`, naming both `refusal-ceiling-raised` pairs. Clippy: a binding renamed in `routed_only_into_protection`, the override precondition pushed rather than `format!`-appended, `decode` and `run_hook` brought under the line ceiling by `present_str` and `preapproval_context`, and the corpus measurement's printout allowed with its reason. Refs: CLOUD-2075 --- .batten/asked.jsonl | 2 ++ crates/batten/src/hook.rs | 20 ++++++++++--------- crates/batten/src/lib.rs | 24 +++++++++++++++++------ crates/batten/src/refusal.rs | 9 ++++----- crates/batten/src/verdict.rs | 4 ++-- crates/batten/tests/it/refusal_ceiling.rs | 12 ++++++++++-- 6 files changed, 47 insertions(+), 24 deletions(-) diff --git a/.batten/asked.jsonl b/.batten/asked.jsonl index cd1bf55a9..6bb36147f 100644 --- a/.batten/asked.jsonl +++ b/.batten/asked.jsonl @@ -6,3 +6,5 @@ {"question":"Is the family above the set you meant, and what should happen next?","options":[{"label":"Fix the board state","description":"Check CLOUD-2037, CLOUD-2017 and CLOUD-122 against the tree, and move any with no PR behind it back to Backlog with a comment."},{"label":"Pick up the open rows","description":"Plan a branch that works through the open error and advisory prose rows, starting with CLOUD-2037, CLOUD-2017 and CLOUD-1327."},{"label":"Different batch","description":"I meant specific tickets filed together. I'll give a key or a phrase from one of them."}],"answer":"Fix the board state","at":1791005849,"head":"4ed344a3941cf1e9bcc37918eedf555f6c119986"} {"question":"Dispatch two sessions, one PR each: Bundle A brief:43e89098ea7db7ba6b3d7acf3326c250067b7faf6dd3d1f1fe39baf4b4a6a666 (hook.rs/refusal.rs/lib.rs: CLOUD-1728, 1826, 1996, 1806, 1893, 1470, 2075, 2078, 1583, 2079) and Bundle B brief:79ee5e255f39abeaf60752e5f347a6fde1e7863e1ce0aed48c0a4d2a74feed41 (config caps and commit-msg classes: CLOUD-1960, 1642, 1643)?","options":[{"label":"Approve dispatch","description":"Start both sessions with these exact briefs. Each claims its rows in order, commits once per row, and lands one PR."},{"label":"Revise briefs","description":"Hold the dispatch; tell me what to change."}],"answer":"Approve dispatch","at":1791029074,"head":"4ed344a3941cf1e9bcc37918eedf555f6c119986"} {"question":"Which permission mode should the two child sessions start in?","options":[{"label":"Auto (Recommended)","description":"They build and land unattended; the PRs and board are the record."},{"label":"Plan","description":"Each child parks for your approval in the web UI before building each row."}],"answer":"Auto (Recommended)","at":1791029074,"head":"4ed344a3941cf1e9bcc37918eedf555f6c119986"} +{"question":"Admit the `[refusal]` ceiling raise? Gate `config-lint` refuses `batten.toml:refusal.max_tokens refusal-ceiling-raised` and `batten.toml:refusal.first_sighting_max_tokens refusal-ceiling-raised`: raising a committed per-line ceiling is a weakening, admitted only by your recorded answer. Why it should not stand: CLOUD-2075's decided mechanism puts labels, every route and the override request on every firing, and no renderer reads the ceiling any more — it only reports. The values (24→96, 64→176) are the measured widest lines, rounded up to a multiple of 16, as the row specifies. Cost if wrong: each refusal line costs up to ~4x the old context per firing (worst measured pointer 84 tokens, full arm 159). I caused this by changing the renderer.","options":[{"label":"Admit the raise","description":"Record the admission; the ceilings stay at 96 and 176 and land proceeds."},{"label":"Refuse","description":"Do not admit; CLOUD-2075 does not land until the ceiling is redesigned."}],"answer":"Admit the raise","at":1791043777,"head":"2f23e7fd7e442fdacae28ce805fdcc3993947e8f"} +{"question":"Admit `refusal-ceiling-raised refusal.max_tokens` and `refusal-ceiling-raised refusal.first_sighting_max_tokens`? (Re-asked: my previous question spelled these pairs in the wrong order, so the ledger could not match your answer to them. Nothing else changed.) Gate `config-lint` reports the [refusal] ceilings raised from 24 to 96 and 64 to 176 as weakenings. Why the raise should stand: CLOUD-2075 puts labels, every route and the override request on every firing; no renderer reads these keys any more, they only report; the values are the measured widest lines (pointer 84, full 159 estimated tokens) rounded up to a multiple of 16. Cost if wrong: each refusal line costs up to ~4x the old context per firing.","options":[{"label":"Admit the raise","description":"Record the admission for both pairs; land proceeds with the ceilings at 96 and 176."},{"label":"Refuse","description":"Do not admit; CLOUD-2075 does not land until the ceilings are redesigned."}],"answer":"Admit the raise","at":1791044811,"head":"2f23e7fd7e442fdacae28ce805fdcc3993947e8f"} diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 15255b5cc..cfe9f192b 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -3337,18 +3337,20 @@ pub fn decode(harness: Harness, raw: &str) -> Option { .filter(|mode| !mode.is_empty()) .map(ToOwned::to_owned), // CLOUD-2075: the sightings context and the SessionStart source. - agent: value - .get("agent_id") - .and_then(Value::as_str) - .filter(|agent| !agent.is_empty()) - .map(ToOwned::to_owned), - start_source: value - .get("source") - .and_then(Value::as_str) - .map(ToOwned::to_owned), + agent: present_str(&value, "agent_id"), + start_source: present_str(&value, "source"), }) } +/// One non-empty string member of a decoded payload, owned. +fn present_str(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + /// Normalize a host's event spelling, applying that host's rename table. /// /// The converged spellings live in [`Event::normalize`] — Claude Code's, which diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 043ff12fb..b6959ffe2 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -16247,12 +16247,7 @@ fn run_hook( // A PRE-APPROVAL TAKES THE ADVICE INTO ITS OWN DOCUMENT (CLOUD-1949): two // documents on one stream is the collision above, with the grant as the // discarded one. - let context = matches!(decision, hook::Decision::Preapproved(_)) && !advice.is_empty(); - let context = context.then(|| { - let mut taken = std::mem::take(&mut advice); - sight_advice(&envelope, &mut taken); - advisory::admit(taken, ceiling).text - }); + let context = preapproval_context(&decision, &envelope, &mut advice, ceiling); emit_channel(harness, &envelope, out, err, advice, ceiling, &decision)?; let rendering = Rendering { context: context.as_deref(), @@ -16260,6 +16255,23 @@ fn run_hook( render(harness, &envelope, decision, &rendering, mode, out, err) } +/// The advice a pre-approval carries in its own document (CLOUD-1949), taken +/// out of `advice` and marked seen at this emission (CLOUD-2075), or `None` +/// when the decision is not a pre-approval or there is nothing to carry. +fn preapproval_context( + decision: &hook::Decision, + envelope: &hook::Envelope, + advice: &mut Vec, + ceiling: Option<&advisory::Channel>, +) -> Option { + if !matches!(decision, hook::Decision::Preapproved(_)) || advice.is_empty() { + return None; + } + let mut taken = std::mem::take(advice); + sight_advice(envelope, &mut taken); + Some(advisory::admit(taken, ceiling).text) +} + /// 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 diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index c9eb5d14f..829cd01ce 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -709,11 +709,10 @@ fn finding_line(refusal: &Refusal, arm: Arm) -> String { line.push_str(&sentence(remedy)); } for (id, precondition) in refusal.preconditions() { - line.push_str(&format!( - " Admissible as {} when {}", - quoted(id), - sentence(precondition) - )); + line.push_str(" Admissible as "); + line.push_str("ed(id)); + line.push_str(" when "); + line.push_str(&sentence(precondition)); } if let Some(class) = refusal.verdict() { line.push_str(" Run batten policy explain "); diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index e3318c776..b3f49e4e7 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -2695,10 +2695,10 @@ pub fn routed_only_into_protection( registry: &[DeclaredVerdict], protects: impl Fn(&str) -> bool, ) -> Vec { - let blocker = Native::ProtectedMutation.id(); + let gate = Native::ProtectedMutation.id(); if registry .iter() - .any(|entry| entry.id == blocker && declares_override(entry)) + .any(|entry| entry.id == gate && declares_override(entry)) { return Vec::new(); } diff --git a/crates/batten/tests/it/refusal_ceiling.rs b/crates/batten/tests/it/refusal_ceiling.rs index 4603039c5..b2754c61c 100644 --- a/crates/batten/tests/it/refusal_ceiling.rs +++ b/crates/batten/tests/it/refusal_ceiling.rs @@ -660,8 +660,16 @@ fn every_arm_the_corpus_emits_is_within_its_declared_ceiling() { over.push((pointer_cost, pointer)); } } - eprintln!("measured full {} {}", widest.0, widest.1); - eprintln!("measured pointer {} {}", widest.2, widest.3); + // THE MEASUREMENT IS THE OUTPUT: the `[refusal]` comment's table is read + // off these two lines (CLOUD-2075 §E), so they print pass or fail. + #[expect( + clippy::print_stderr, + reason = "the case reports the measured widest arm the ceilings are declared from" + )] + { + eprintln!("measured full {} {}", widest.0, widest.1); + eprintln!("measured pointer {} {}", widest.2, widest.3); + } assert!(over.is_empty(), "over a declared ceiling: {over:?}"); } From 418f3085d265540a729856eca26aafe2d08ed7b4 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 18:18:34 +0000 Subject: [PATCH 09/22] fix(patch): a replayed edit keeps its identity when the base moved elsewhere (CLOUD-2098) `patch::identity` hashed a modification's before side by its object id, so the identity carried the whole base file and a commit replayed over a `main` that touched the same file anywhere got a new identity. The push lease's patch-identity admission (CLOUD-2089) then read a branch's own rebased commits as a sibling's work and refused every push. Between two text sides the before side now contributes its mode only; the after side's edit script is the change, and a binary side keeps the exact oid. Refs: CLOUD-2098 --- crates/batten/src/patch.rs | 57 +++++++++++++++++++++++++++++++++++++- mise.toml | 2 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/crates/batten/src/patch.rs b/crates/batten/src/patch.rs index 59b9cd224..cff18237a 100644 --- a/crates/batten/src/patch.rs +++ b/crates/batten/src/patch.rs @@ -118,6 +118,8 @@ pub(crate) fn is_text(bytes: &[u8]) -> bool { /// Infallible: a digest absorbs bytes and cannot refuse them, so there is no /// error path to invent. The caller's fallibility is in READING the objects, not /// in hashing them. +//MUTANT-SUITE crates/batten/src/patch.rs +//MUTANT base-oid-hashed|s@^ if before.text.is_some() \&\& after.text.is_some() {$@ if false {@|an_edit_replayed_onto_a_moved_base_keeps_its_identity pub(crate) fn identity(changes: &mut [Change]) -> Option { if changes.is_empty() { return None; @@ -141,7 +143,17 @@ pub(crate) fn identity(changes: &mut [Change]) -> Option { } Kind::Modified { before, after } => { field(&mut hasher, b"~"); - side(&mut hasher, before, None); + // THE BASE IS NOT THE CHANGE. Between two text sides the edit + // script below IS the change, so the before side contributes its + // mode alone: hashing its object id made the identity depend on + // the whole base file, and a rebase over any edit to that file + // elsewhere minted a new identity for the same change — so a + // replayed branch read as a sibling's work (CLOUD-2089's lease). + if before.text.is_some() && after.text.is_some() { + field(&mut hasher, before.mode.to_le_bytes().as_slice()); + } else { + side(&mut hasher, before, None); + } side(&mut hasher, after, Some(before)); } } @@ -271,6 +283,49 @@ mod tests { } } + /// The same edit replayed onto a base that moved ELSEWHERE in the file is + /// the same change. The base differs only far from the hunk, so the edit + /// script is identical and only the base's object id is not. + #[test] + fn an_edit_replayed_onto_a_moved_base_keeps_its_identity() { + let text = |body: &str| Blob { + oid: format!("{:0>64}", body.len()), + mode: 0o100_644, + text: Some(body.as_bytes().to_vec()), + }; + let filler: String = (0..40).map(|n| format!("line {n}\n")).collect(); + let edit = |base: &str, oid: &str| { + let mut before = text(&format!("a\n{filler}{base}\n")); + before.oid = oid.repeat(64); + Change { + path: b"src/lib.rs".to_vec(), + kind: Kind::Modified { + after: text(&format!("a\nINSERTED\n{filler}{base}\n")), + before, + }, + } + }; + let original = identity(&mut [edit("tail one", "1")]); + let replayed = identity(&mut [edit("tail two", "2")]); + assert!(original.is_some()); + assert_eq!( + original, replayed, + "a base edited elsewhere is not a new change" + ); + let other = Change { + path: b"src/lib.rs".to_vec(), + kind: Kind::Modified { + before: text("a\n"), + after: text("a\nDIFFERENT\n"), + }, + }; + assert_ne!( + original, + identity(&mut [other]), + "a different edit still differs" + ); + } + /// An empty change set has no identity, which is what produces /// `Evidence::NoContent` rather than a hash every empty commit shares. #[test] diff --git a/mise.toml b/mise.toml index eb3c5fa15..093e2fb26 100644 --- a/mise.toml +++ b/mise.toml @@ -647,7 +647,7 @@ CI_VERDICT_STEPS = "Run mise run ,Run mise exec -- " # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain" +MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain,engine-patch" # The file inline tasks are declared in, for `mutate`'s `task-` route (CLOUD-1909). # The crate may not spell a consumer's filename (non-negotiable rule 1), so the # manifest is named here, beside the set it serves. Unset, a `task-` gate resolves From 68903a6e74fa61c0d2f22d40060ce4d37e6e9df5 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 19:00:04 +0000 Subject: [PATCH 10/22] fix(patch): build the identity test's filler without format-collect (CLOUD-2098) Clippy's `format_collect` refused the regression case's filler; it folds into one `String` instead. The four `issue file same` admissions below record that CLOUD-2098 documents the change this PR lands. Refs: CLOUD-2098 Admits: 9e48ed034b0ff2fad61627d8bf317ca506a87c8fdebd1df527fcfdb4bf98a870 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/patch.rs Admits-anchor: finding:4157b384926652bc21352fd2f0970cfe3f628f4dbd37c26afb595a1fa2119ac1 Admits-epoch: 0246b6fb838a05f872d14fba944be05cbe6017457005cc87cc6d45ac746bafe4 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: the defect is fixed in this branch, and the row is the record of that fix Admits-answer-precondition: CLOUD-2098 documents the change this PR lands: its body names crates/batten/src/patch.rs because the fix and its test are in this diff, and PR #1102 closes it in closing form Admits-answer-rejected-route: naming it in the PR body is done (Closes CLOUD-2098), but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block in this environment Admits: a880acac0959d46a22565851690003a0f33b68c6286d14f578b9a85dbbf0cece Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/hook.rs Admits-anchor: finding:abaa68a05119852367afa9d580d87eb0c1637c17f2ea22d4e6437f9301825dd9 Admits-epoch: 0246b6fb838a05f872d14fba944be05cbe6017457005cc87cc6d45ac746bafe4 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: the defect is fixed in this branch, and the row is the record of that fix Admits-answer-precondition: CLOUD-2098 documents the change this PR lands: its body names crates/batten/src/hook.rs because the fix and its test are in this diff, and PR #1102 closes it in closing form Admits-answer-rejected-route: naming it in the PR body is done (Closes CLOUD-2098), but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block in this environment Admits: 2cfb6324fb68d59119f40ef9974cf7738035ca1998cd0342f2d7e3faa31a3841 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/lib.rs Admits-anchor: finding:b0ddbabb159362c15647e67bee3582f45a5fbfc33c48526e59655589c8705d5c Admits-epoch: 0246b6fb838a05f872d14fba944be05cbe6017457005cc87cc6d45ac746bafe4 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: the defect is fixed in this branch, and the row is the record of that fix Admits-answer-precondition: CLOUD-2098 documents the change this PR lands: its body names crates/batten/src/lib.rs because the fix and its test are in this diff, and PR #1102 closes it in closing form Admits-answer-rejected-route: naming it in the PR body is done (Closes CLOUD-2098), but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block in this environment Admits: bb207936867592ff2b9084c44cfbbf4377c9ae05870d3106096ed5353e77937a Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: mise.toml Admits-anchor: finding:65a1fe7ebfb70f5bfd928c61541058fce82c03f35d4e44f1fcd97bbb744de8e7 Admits-epoch: 0246b6fb838a05f872d14fba944be05cbe6017457005cc87cc6d45ac746bafe4 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: the row and its code land together in this PR Admits-answer-precondition: CLOUD-2098 documents the change this PR lands; its body names mise.toml because the engine-patch gate append is in this diff, and PR #1102 closes it Admits-answer-rejected-route: the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block --- crates/batten/src/patch.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/batten/src/patch.rs b/crates/batten/src/patch.rs index cff18237a..f6db4a7d6 100644 --- a/crates/batten/src/patch.rs +++ b/crates/batten/src/patch.rs @@ -293,7 +293,12 @@ mod tests { mode: 0o100_644, text: Some(body.as_bytes().to_vec()), }; - let filler: String = (0..40).map(|n| format!("line {n}\n")).collect(); + let filler = (0..40).fold(String::new(), |mut text, n| { + text.push_str("line "); + text.push_str(&n.to_string()); + text.push('\n'); + text + }); let edit = |base: &str, oid: &str| { let mut before = text(&format!("a\n{filler}{base}\n")); before.oid = oid.repeat(64); From d829e166ff44455611656865b6ccf6a733d98594 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Sat, 3 Oct 2026 19:36:51 +0000 Subject: [PATCH 11/22] fix(refusal): declare the adjudicate echo columns and read a route flag as no label (CLOUD-2075) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's census now types each Echoes row with the columns it echoes; the adjudicate row names the three its full arm carries. The label census read a route's `--verdict '…'` flag as a hand-spelled label once a wrap moved the flag into a literal's first line; a match after `--` is a flag. Refs: CLOUD-2075 --- crates/batten/tests/it/emission_census.rs | 14 ++++++++++++-- crates/batten/tests/it/pointer_only.rs | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/batten/tests/it/emission_census.rs b/crates/batten/tests/it/emission_census.rs index d7c822ccb..5b0d6f651 100644 --- a/crates/batten/tests/it/emission_census.rs +++ b/crates/batten/tests/it/emission_census.rs @@ -197,8 +197,11 @@ fn hand_spelled_labels(source: &str) -> Vec<(usize, String)> { } for label in LABELS { for (at, _) in line.match_indices(label) { - // Inside a literal when an odd number of quotes precede it. - if line[..at].matches('"').count() % 2 == 1 { + // Inside a literal when an odd number of quotes precede it. A + // `--verdict '…'` or `--rule '…'` is a route's command flag, not + // a label: the projection never spells one after `--`. + let flag = line[..at].ends_with("--"); + if !flag && line[..at].matches('"').count() % 2 == 1 { found.push((index + 1, line.trim().to_owned())); } } @@ -255,6 +258,13 @@ fn the_label_census_discriminates() { hand_spelled_labels("// rule 'x'\n").is_empty(), "a comment line is not an emission" ); + assert!( + hand_spelled_labels( + "const R: &str = \"batten override request --verdict 'x' --rule 'y'\";\n" + ) + .is_empty(), + "a route's command flag is not a label" + ); let tail = "fn a() {}\n#[cfg(test)]\nmod tests { const X: &str = \"rule 'x'\"; }\n"; assert!( hand_spelled_labels(tail).is_empty(), diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index c26317663..09d7beda8 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -2264,6 +2264,11 @@ const CENSUS: &[Verb] = &[ "a finding's full arm carries the declaring row's own `reason`, the class gloss \ and each override's precondition — the caller's declaration, once per context \ per compaction cycle (CLOUD-2075)", + Echoed::Columns(&[ + "rule[].reason", + "verdict[].gloss", + "verdict[].route[].precondition", + ]), ), }, // THE ONE VERB WHOSE ANSWER IS THE PAYLOAD, and `command` is deliberately the From ca0b3002a3eb375de8d1fa54c571aca990f9645a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 02:11:13 +0000 Subject: [PATCH 12/22] fix(posture): name a container step handed to the human as a punt (CLOUD-2117) The container is the agent's alone and no human can run a command in it, yet `turn ask early` stayed silent on two final messages telling the operator to run an install or restart the session. `deferral-offered` gains those two witnessed literals, the module's copy and tier follow, and AGENTS.md's punt clause states why the act is never right. Refs: CLOUD-2117 Admits: 5ebfecce0485bed5967084cbb4c877afcd3f5fa878b369e4fab627e145d21d19 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/turn-ask.rego Admits-anchor: call:64bb359baadcff030f7b7e4f91c95e1f8fd2f868 Admits-epoch: 33263071ca00ea318f994d58b3b8b697b43ca7e0fa0b9e5151d73e3942105c74 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: the punt gate stays silent on a container step handed to the human, the defect the operator caught twice Admits-answer-precondition: CLOUD-2117 widens turn ask early with two witnessed sentences; the edit adds the same two literals to the module copy of the pattern and three tests, and the module diff in this PR is where a reviewer sees it Admits-answer-rejected-route: batten policy rule names no command that edits a policy module; a protected module changes only through a recorded admission --- AGENTS.md | 3 ++- batten.toml | 11 ++++++++++- policy/turn-ask.rego | 17 ++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7ef40475..a89e120a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,7 +51,8 @@ gate is a defect, not an answer** — repair it and carry on this session; ticke one is a punt in gate's clothing (CLOUD-597/615). **A punt is any deferral you could have closed**, a predicate not a list: a block reported as a decision (a block is a bug); "that's your call" on what your evidence settles; an authorized -action offered, or handed to a human to run; an unbuilt mechanism awaited over +action offered, or handed to a human to run — **the container is yours alone, +no human can run a command in it** (CLOUD-2117); an unbuilt mechanism awaited over the instance in hand; your own landed work spared. Can do it, do it; can't, file it. **An override ask is ONE yes/no on the override, never a menu of routes** diff --git a/batten.toml b/batten.toml index 776af80ae..7bf328665 100644 --- a/batten.toml +++ b/batten.toml @@ -2047,9 +2047,18 @@ regex = "(?i)worth (noting|flagging|mentioning|naming)|one thing (I would|I['’ # and `would you like me to` fired 0 times and stay OUT — an unwitnessed # literal is the invention CLOUD-323 forbids. CASE-SENSITIVE `Your call,` on # purpose: sentence-initial is the offer; mid-sentence was the report. +# +# THE CONTAINER'S OWN STEP, HANDED OVER (CLOUD-2117). The container is the +# agent's alone and no human can run a command in it, so a turn telling the +# human to run one or to restart the session hands back the one kind of step +# nobody else can take. Witnessed twice on PR #1102, 2026-10-05, both silent +# here: "Once you run `…` or restart the session, I'll push" and "To unblock, +# either: run `…`, or restart the session". Both literals are those sentences' +# own words; `restart the session` cannot be the agent's own act, which is why +# it needs no pronoun. [[pattern]] id = "deferral-offered" -regex = "Your call, |(?i:waiting on your call|both your call|a checkpoint with you)" +regex = "Your call, |(?i:waiting on your call|both your call|a checkpoint with you|once you run|restart the session)" # CLOUD-323's two measured deferral shapes in a PR body, which `record derive # deferral --input shape=deferral-shape` matches per paragraph outside code diff --git a/policy/turn-ask.rego b/policy/turn-ask.rego index cc8fac2ea..b37582f97 100644 --- a/policy/turn-ask.rego +++ b/policy/turn-ask.rego @@ -76,7 +76,7 @@ violation contains { # silent — measured, the first cut of these tests passed their silent half for # exactly that reason. -punt_regex := "Your call, |(?i:waiting on your call|both your call|a checkpoint with you)" +punt_regex := "Your call, |(?i:waiting on your call|both your call|a checkpoint with you|once you run|restart the session)" offered(text) := v if { v := violation with input as {"call": {"final-message": text}} @@ -91,6 +91,21 @@ test_waiting_on_the_human_is_named if { count(offered("Stopping. Waiting on your call about the downgrade.")) == 1 } +# CLOUD-2117: the container's own step handed to the human, both witnessed on +# PR #1102. Nobody but the agent can run a command in its container. +test_a_command_handed_to_the_human_is_named if { + count(offered("Once you run `BATTEN_VERSION=v0.0.202 ./install.sh` in `/home/user/batten`, I'll push.")) == 1 +} + +test_a_session_restart_handed_to_the_human_is_named if { + count(offered("To unblock, either:\n- run `./install.sh`, or\n- restart the session, since start rebuilds it.")) == 1 +} + +test_the_agent_running_it_is_not_a_punt if { + # THE NARROWNESS BOUNDARY: the agent reporting its own run is the work, not an offer. + count(offered("I ran `batten engine update`, then pushed. Once it ran, the hook loaded.")) == 0 +} + test_a_decision_already_made_is_not_a_punt if { # The measured false positives of bare `your call`: each reports a choice the # human already made. From 5f2198b83824a6b69efc354b271056d71f51d365 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 02:11:22 +0000 Subject: [PATCH 13/22] fix(hook): admit the engine's own repair verb over a pin skew (CLOUD-2116) A pin naming another build refused every call, and its remedy named an installer the unloadable-config floor also refused, so the container could run nothing, not even the line its own refusal printed. A config newer than the binary is never repaired by an edit. Both skew messages now name `batten engine update`, and the floor admits exactly that command spelled whole beside the read and the authority write. Refs: CLOUD-2116 --- crates/batten/src/engine.rs | 12 +++++- crates/batten/src/lib.rs | 18 ++++++-- crates/batten/tests/it/adjudicate_absent.rs | 47 +++++++++++++++++++++ crates/batten/tests/it/engine_pin.rs | 13 ++++-- crates/batten/tests/it/engine_update.rs | 4 +- 5 files changed, 83 insertions(+), 11 deletions(-) diff --git a/crates/batten/src/engine.rs b/crates/batten/src/engine.rs index 647fcd708..02ce8a560 100644 --- a/crates/batten/src/engine.rs +++ b/crates/batten/src/engine.rs @@ -133,6 +133,14 @@ pub fn declared(text: &str) -> Option { table.get("engine")?.clone().try_into().ok() } +/// The engine's own repair for a pin this build does not satisfy (CLOUD-2116). +/// +/// One spelling for both halves: [`check`]'s refusal names it, and the hook's +/// floor over an unloadable config admits exactly it. A remedy naming a +/// consumer's installer was one the floor refused, so a skewed container could +/// run nothing at all, including the line its own refusal printed. +pub const REPAIR: &str = "batten engine update"; + //MUTANT-SUITE crates/batten/tests/it/engine_pin.rs //MUTANT pin-unread|s@^ let Some(pin) = declared(text) else {$@ let Some(pin) = None:: else {@|a_release_pin_naming_another_build_is_refused_with_its_install //MUTANT stamp-trusted-when-absent|s@^ if stamp.as_deref() != Some(digest) {$@ if stamp.is_some() \&\& stamp.as_deref() != Some(digest) {@|a_source_pin_without_a_stamp_is_refused @@ -158,7 +166,7 @@ pub fn check(text: &str, source: &str, stamp: impl FnOnce() -> Option) - if tag != running { return Err(UsageError::raise(format!( "{source} pins batten {tag} and this engine is {running}: install the pin \ - with `BATTEN_VERSION={tag} ./install.sh`" + with `{REPAIR}`" ))); } Ok(()) @@ -169,7 +177,7 @@ pub fn check(text: &str, source: &str, stamp: impl FnOnce() -> Option) - let running = stamp.as_deref().unwrap_or("unstamped"); return Err(UsageError::raise(format!( "{source} pins the engine built from source {digest} and this engine is \ - {running}: build and stamp it with `mise run install:local`" + {running}: build and stamp it with `{REPAIR}`" ))); } Ok(()) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index b6959ffe2..7954f52c5 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -16403,8 +16403,9 @@ fn read_envelope( /// /// # What is NOT exempt /// -/// Any command, any MCP call, any subagent spawn, any unclassified tool, and any -/// write naming anything else. A shell command that happens to target the config +/// Any command but the engine's own repair verb spelled whole (CLOUD-2116), any +/// MCP call, any subagent spawn, any unclassified tool, and any write naming +/// anything else. A shell command that happens to target the config /// is refused too — its argv is not decidable from the envelope, the same /// undecidability `batten.toml`'s `protected_readers` comment records for an /// interpreter, and the direction that must refuse rather than pass. @@ -16429,6 +16430,8 @@ fn read_envelope( //MUTANT floor-admits-an-unclassified-call|s@ (hook::Operation::Read, None) => true,@ (_, None) => true,@|a_mutating_mcp_call_is_still_refused_over_a_config_that_will_not_load //MUTANT floor-refuses-inert-tools|s@ (hook::Operation::Other(name), None) if hook::INERT_TOOLS.contains(\&name.as_str()) => true,@@|a_search_still_answers_over_a_config_that_will_not_load //MUTANT floor-admits-any-write|s@ (hook::Operation::Write, Some(path)) => names_the_config_authority(path),@ (hook::Operation::Write, Some(_)) => true,@|a_write_to_another_path_is_still_refused_over_a_config_that_will_not_load +//MUTANT floor-refuses-engine-repair|s@ (hook::Operation::Execute, None) => envelope.command.trim() == engine::REPAIR,@@|the_engine_repair_verb_reaches_a_skewed_pin +//MUTANT floor-admits-a-carried-repair|s@envelope.command.trim() == engine::REPAIR,@envelope.command.contains(engine::REPAIR),@|a_command_carrying_the_repair_verb_is_still_refused fn recoverable_without_rules(envelope: &hook::Envelope) -> bool { // NO `command.is_empty()` GUARD, AND ITS ABSENCE IS THE DECISION. A `Bash` // envelope is `Operation::Execute` with no write, so it lands on `_ => false` @@ -16448,6 +16451,12 @@ fn recoverable_without_rules(envelope: &hook::Envelope) -> bool { (hook::Operation::Other(name), None) if hook::INERT_TOOLS.contains(&name.as_str()) => true, // The repair, and only onto the file that is faulting. (hook::Operation::Write, Some(path)) => names_the_config_authority(path), + // THE OTHER REPAIR, for the fault no edit can end (CLOUD-2116). A pin + // naming a NEWER build than the installed one is fixed by installing + // the pin, never by lowering it, so a floor of read-and-edit bricked the + // container on exactly the remedy its own refusal named. The engine's + // own verb, spelled whole: equality, so no shell composition rides it. + (hook::Operation::Execute, None) => envelope.command.trim() == engine::REPAIR, // EVERY OTHER SHAPE REFUSES, `Execute`, `Mcp`, `Subagent` and `Other` // among them. An operation this build could not classify is // could-not-look, and a could-not-look that mutates is the one thing @@ -16547,10 +16556,11 @@ fn is_source_excerpt(line: &str) -> bool { fn unadjudicable_remedy() -> Fix { Fix::Run(format!( "a `Read` still answers and an `Edit` or `Write` of `{}` or `{}` still lands — \ - repair the file with those; every other call stays refused until it loads, \ - so rebuild the binary instead if the config is newer than it", + repair the file with those; where the config is newer than this binary, \ + `{}` installs its pin and is the one command admitted", config::CONFIG_FILE, resolve::LOCAL_CONFIG_FILE, + engine::REPAIR, )) } diff --git a/crates/batten/tests/it/adjudicate_absent.rs b/crates/batten/tests/it/adjudicate_absent.rs index d8087b58a..8e929c5c5 100644 --- a/crates/batten/tests/it/adjudicate_absent.rs +++ b/crates/batten/tests/it/adjudicate_absent.rs @@ -338,6 +338,53 @@ fn a_command_is_still_refused_over_a_config_that_will_not_load() { ); } +/// A config pinning a release no build of this test carries, so the load refuses +/// on the skew alone: the config is NEWER than the binary, and no edit repairs it. +const PINS_ANOTHER_RELEASE: &str = "version = 1\nengine = { release = \"v9.9.9\" }\n"; + +/// A `Bash` envelope carrying `command`. +fn command_envelope(command: &str) -> String { + envelope( + "Bash", + &format!( + "{{\"command\":{}}}", + serde_json::to_string(command).expect("a command serializes") + ), + ) +} + +#[test] +fn the_engine_repair_verb_reaches_a_skewed_pin() { + // THE MEASURED BRICK (CLOUD-2116). `main` moved the pin past the installed + // binary; the refusal named an installer the floor refused, and so did every + // other command, `git push` included. The engine's own verb is the repair a + // read-and-edit floor cannot perform, so it is the one command admitted. + let dir = fixture("adjudicate-floor-engine-repair", PINS_ANOTHER_RELEASE); + assert_eq!( + code_for(&dir, &command_envelope("batten engine update")), + Some(0), + "the repair for a newer pin must reach the shell" + ); +} + +#[test] +fn a_command_carrying_the_repair_verb_is_still_refused() { + // THE MIRROR. Admitting by containment would let any command ride the + // repair's spelling past a build that judges nothing. + let dir = fixture("adjudicate-floor-engine-carried", PINS_ANOTHER_RELEASE); + for command in [ + "batten engine update && rm -rf notes.md", + "echo x; batten engine update", + "git push", + ] { + assert_eq!( + code_for(&dir, &command_envelope(command)), + Some(2), + "only the verb spelled whole is the repair: {command}" + ); + } +} + #[test] fn a_mutating_mcp_call_is_still_refused_over_a_config_that_will_not_load() { // THE DEFECT THE FIRST DRAFT SHIPPED, pinned so it cannot return. diff --git a/crates/batten/tests/it/engine_pin.rs b/crates/batten/tests/it/engine_pin.rs index 25bc7ed66..d33e5dbbd 100644 --- a/crates/batten/tests/it/engine_pin.rs +++ b/crates/batten/tests/it/engine_pin.rs @@ -82,9 +82,16 @@ fn a_release_pin_naming_another_build_is_refused_with_its_install() { !output.status.success(), "a stale engine must not load: {said}" ); + // THE ENGINE'S OWN VERB, never a consumer installer (CLOUD-2116): it is the one + // command the hook's floor admits over this same skew, so it is the only + // remedy a refused session can actually run. assert!( - said.contains("BATTEN_VERSION=v0.0.1"), - "the refusal names the install: {said}" + said.contains("v0.0.1") && said.contains("`batten engine update`"), + "the refusal names the pin and the install: {said}" + ); + assert!( + !said.contains("install.sh"), + "an installer the floor refuses is not a remedy: {said}" ); } @@ -122,7 +129,7 @@ fn a_source_pin_without_a_stamp_is_refused() { "an unstamped engine must not load: {said}" ); assert!( - said.contains("install:local"), + said.contains("`batten engine update`"), "the refusal names the build: {said}" ); } diff --git a/crates/batten/tests/it/engine_update.rs b/crates/batten/tests/it/engine_update.rs index c6605ab81..7c7749625 100644 --- a/crates/batten/tests/it/engine_update.rs +++ b/crates/batten/tests/it/engine_update.rs @@ -161,7 +161,7 @@ fn a_build_inside_the_checkout_is_never_replaced() { ); assert_eq!(std::fs::read(&binary).unwrap(), before, "nothing replaced"); assert!( - text(&output).contains("install:local"), + text(&output).contains("batten engine update"), "the stale build is refused and the update named: {}", text(&output) ); @@ -186,7 +186,7 @@ fn the_hook_path_never_updates() { text(&output) ); assert!( - text(&output).contains("install:local"), + text(&output).contains("batten engine update"), "the stale engine refuses and names the update: {}", text(&output) ); From 02fb80f7d04716448ea693d94e11b0b15c7bbdf8 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 02:12:12 +0000 Subject: [PATCH 14/22] docs(memory): record that the container is the agent's alone, and its exits (CLOUD-2117) mem:workflow/container-is-yours carries why a command is never handed to the human and the ordered exits when every call is refused, measured across the 2026-10-05 and 2026-10-07 pin skews; mem:core routes to it. Written directly because Serena failed to connect this session. Refs: CLOUD-2117 Admits: ab59fa401d30ad5e9078aec11deaef1a159ba0e574595b73ed3cfbe2068fedbb Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/workflow/container-is-yours.md Admits-anchor: call:b360fdb1b36bb332c0e170762fe00dd0c6cb9c41 Admits-epoch: bef9dc122fb1d31a3c4dfd5d55880dec9b11075751bd31562c6dbeab0b13c84f Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: the next session re-learns that no human can run a command in its container, the defect the operator caught twice Admits-answer-precondition: CLOUD-2117 needs the container-ownership lesson durable beside its gate, and Serena, the surface that owns memory writes, failed to connect this session; .serena/memories/workflow/container-is-yours.md is the memory file and the PR diff is where a reviewer sees it Admits-answer-rejected-route: the redirect names Serena write_memory, which failed to connect (CONNECT_TIMEOUT) this session Admits: 6c00979a27a17779d6c7d37488cbdab3c424edcd2afc181bcc20e41befe3d5e3 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/core.md Admits-anchor: call:b360fdb1b36bb332c0e170762fe00dd0c6cb9c41 Admits-epoch: bef9dc122fb1d31a3c4dfd5d55880dec9b11075751bd31562c6dbeab0b13c84f Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: the next session re-learns that no human can run a command in its container, the defect the operator caught twice Admits-answer-precondition: CLOUD-2117 needs the container-ownership lesson durable beside its gate, and Serena, the surface that owns memory writes, failed to connect this session; .serena/memories/core.md is the memory file and the PR diff is where a reviewer sees it Admits-answer-rejected-route: the redirect names Serena write_memory, which failed to connect (CONNECT_TIMEOUT) this session --- .serena/memories/core.md | 3 ++ .../memories/workflow/container-is-yours.md | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 .serena/memories/workflow/container-is-yours.md diff --git a/.serena/memories/core.md b/.serena/memories/core.md index d069855f0..b3f051332 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -31,6 +31,9 @@ Read on demand, never all of them. - `mem:workflow/sonar-scope` — Sonar refuses your branch; reading or changing `sonar-gate`; a Sonar verdict looks wrong on a SHA; before treating a `final` failure as trunk's, or a check-run's annotations as the whole finding list. +- `mem:workflow/container-is-yours` — **every call is refused** (an engine/pin + skew, `engine-cannot-adjudicate`), or about to tell the human to run, install + or restart anything: no human can reach this container, and the exits are yours. - `mem:session-transcript-access` — asked to read chat history or another session; before probing a session API or credential. - `mem:github-access` — any GitHub op; before claiming the toolchain or CI diff --git a/.serena/memories/workflow/container-is-yours.md b/.serena/memories/workflow/container-is-yours.md new file mode 100644 index 000000000..9b9646052 --- /dev/null +++ b/.serena/memories/workflow/container-is-yours.md @@ -0,0 +1,44 @@ +# The container is yours alone + +**No human can run a command in this container, open a shell in it, or restart +its session for you.** The operator sees chat and the PR; they cannot reach the +filesystem, the binaries or the processes. So a step that needs a command is +yours whatever state the container is in. Telling the human to run something +here, or to "restart the session", hands back the one kind of step nobody else +can take. It is the purest form of AGENTS.md's punt, and `turn ask early` +(`policy/turn-ask.rego`) names it (CLOUD-2117). + +## Measured + +PR #1102, 2026-10-05: `land` replayed onto a `main` whose `batten.toml` pinned a +newer engine than the installed one. The hook refused every call +`engine-cannot-adjudicate`, including the `./install.sh` its own remedy named. +Two final messages then told the operator to run the install or restart the +session. The operator caught both; the gate was silent on both. + +## When every call is refused, the exits are still yours + +Work down this list; never stop at "blocked" and never hand it over. + +1. **Read the refusal's own Fix clause.** The unloadable-config floor + (`recoverable_without_rules`, `crates/batten/src/lib.rs`) always admits a + `Read`, an inert search (`Grep`/`Glob`), and an `Edit`/`Write` of + `batten.toml` or `batten.local.toml`. +2. **A pin newer than the binary: run `batten engine update`, spelled whole.** + Since CLOUD-2116 the floor admits exactly that command, and the skew refusal + names it. +3. **Before that fix is installed, the pin line itself is the exit.** Edit the + pin to the build the hook is running, so the engine loads for one step; do + the repair (install the tree's build, replay onto `main`); then edit the pin + line back so no commit carries it. Measured 2026-10-07, and two traps on it: + - **A non-hook `batten` self-updates to the pin at startup** (CLOUD-2063). + While the pin is edited down, any task that runs `batten` downgrades + `~/.local/bin/batten` to that pin. Install the build you want LAST, after + every task that ran under the edited pin. + - **Do the shell steps in one command.** Each command is judged before it + runs, so `git stash && batten land replay main && git stash pop` passes on + one load; a later command meets whatever pin the tree then holds. +4. **A config fault: repair the file with the admitted `Edit`**, then carry on. +5. If none of these opens a route, the gate is wrongly refusing: repair it + (AGENTS.md) and file it. Say what is blocked and what you are doing about it, + never what the human should do. From dabe16787529aab3c2c90f203047c7bb2aa2b5d5 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 02:31:22 +0000 Subject: [PATCH 15/22] fix(provision): the binary installs the binary; the installer only bootstraps (CLOUD-2123) install.sh has one job: put a batten on a host that has none. The row holding it limited what the installer may do and never who may call it, so session:batten reinstalled the latest release over a working engine every session, and a pin naming any other build then refused every call. session:batten now runs batten engine update; deps-install calls the installer only where no batten resolves; program run loose refuses an unconditional installer call in mise.toml. Refs: CLOUD-2123 --- batten.toml | 22 ++++++++++++++++++++++ mise.toml | 21 ++++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/batten.toml b/batten.toml index 7bf328665..0c979b16e 100644 --- a/batten.toml +++ b/batten.toml @@ -4832,6 +4832,28 @@ severity = "deny" scope = "tree" no_fix_reason = "class 3 (a port behind `batten`): the installer installs the binary and nothing else; a toolchain, a package manager or a shell profile belongs behind `batten` once it is on PATH — a `[[provision]]` row or a `[[startup]]` row, both of which a gate can read" +# AND WHO MAY CALL IT, the half the row above never held (CLOUD-2123). The +# installer's ONE job is putting a `batten` on a host that has none; once one +# exists, every install, update and repair goes through the binary +# (`batten engine update`) or a tool it provisioned. `session:batten` called +# `./install.sh` on every session start over a binary already on PATH, fetching +# the LATEST release, and a pin naming any other build then refused every call — +# measured twice on PR #1102 (2026-10-05, 2026-10-07). +# +# THE PREDICATE IS AN UNCONDITIONAL CALL: the installer as a task's whole `run` +# string or as an argv entry of its own. The bootstrap guard (`else ./install.sh` +# under `command -v batten`) is the one admitted spelling, and prose quoting the +# installer in backticks is not a call. CI runners start with no binary, so the +# workflows' installs are genuine bootstraps and sit outside this glob. +[[rule]] +id = "program run loose" +kind = "forbid" +glob = "mise.toml" +regex = '"\./install\.sh"|run\s*=\s*"\./install\.sh' +severity = "deny" +scope = "tree" +no_fix_reason = "class 3 (a port behind `batten`): which bootstrap guard a task needs is a judgement about the host it runs on; once a binary exists the route is `batten engine update`" + # A GRANT THAT MATCHES NOTHING IS THE DEAD-GATE CLASS, ONE SURFACE OVER, AND ITS # ONLY SYMPTOM IS A PERSON BEING ASKED (CLOUD-1455). # diff --git a/mise.toml b/mise.toml index 093e2fb26..b78365042 100644 --- a/mise.toml +++ b/mise.toml @@ -2985,7 +2985,15 @@ description = "Provision the host dependencies mise does not: the released `batt # and what mise does to an array: the entries run in order and the first failure # ends the task with its own exit code, so `install.sh`'s `1`/`2` still reach the # caller unreworded. -run = ["./install.sh", "batten wiring reclaim -y"] +# +# THE INSTALLER BOOTSTRAPS AND NOTHING ELSE (CLOUD-2123): it runs only where no +# `batten` resolves. A host that has one converges through the binary, because +# reinstalling the latest release over it is how an engine came to disagree +# with its pin. `program run loose` refuses an unconditional installer call. +run = [ + "if command -v batten >/dev/null 2>&1; then batten engine update; else ./install.sh; fi", + "batten wiring reclaim -y", +] # The report half of the same seam, and the setup script calls this one too. # @@ -3231,10 +3239,13 @@ description = "Session start: install the latest RELEASE over whatever `deps-ins # own rather than a flattened `1`. quiet = true silent = "stdout" -# THEN THE PIN (CLOUD-2062): `install.sh` provisions a release so a binary -# exists at all; `batten engine update` then converges it on the engine the -# committed `batten.toml` pins. An unpinned checkout pays nothing for it. -run = ["./install.sh", "batten engine update"] +# THE BINARY INSTALLS THE BINARY (CLOUD-2123). `deps-install` has already put a +# `batten` on PATH by the time this runs, so the installer has no job left here: +# it fetched the LATEST release over that binary every session, and a pin naming +# any other build then refused every call until something rebuilt it, measured +# twice on PR #1102. `batten engine update` converges on the committed pin, and +# an unpinned checkout pays nothing for it. `program run loose` holds the line. +run = "batten engine update" [tasks."session:git-hooks"] description = "Session start: install the repo-owned git hooks, the per-clone step nothing performed for 24 commits (CLOUD-476)" From d1b63f231b506015abea980dd2afed105bc08bf7 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 02:47:53 +0000 Subject: [PATCH 16/22] fix(provision): only an installed engine links the host (CLOUD-2124) A launcher names its interpreter by absolute path, and batten-check runs provision apply through cargo run, so any checkout's debug build rewrote ~/.local/bin/mise to run itself: measured 2026-10-07, a scratch clone's target/debug/batten became the host's mise interpreter. provision::links_host refuses a build inside the checkout it provisions, the line engine update already draws (CLOUD-2063); such a build still fills the repository's cache but neither judges nor writes the host link. Refs: CLOUD-2124 --- crates/batten/src/lib.rs | 6 +++- crates/batten/src/provision.rs | 65 +++++++++++++++++++++++++++++----- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 7954f52c5..1b599d6e6 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -1555,12 +1555,16 @@ fn run_provision_apply( let config = resolve::resolve(Path::new("."), overrides)?; let repo = git::repo_root(Path::new("."))?; let cache = provision::cache_root(&repo)?; + // ONLY AN INSTALLED ENGINE PROVISIONS THE HOST (CLOUD-2124): a build of + // this checkout fills the repository's cache and leaves the host's links + // to the binary that owns them. + let links_host = std::env::current_exe().map_or(true, |exe| provision::links_host(&exe, &repo)); for entry in &config.provisions { // A checksum mismatch propagates as a `Denial` (exit 2) from here, so // the loop stops at the first bad artifact rather than going on to // install the rest under a verdict that already failed. - let applied = provision::apply(entry, &cache, dry_run)?; + let applied = provision::apply(entry, &cache, dry_run, links_host)?; let verb = match applied { provision::Applied::Installed => "installed", provision::Applied::AlreadyFresh => continue, diff --git a/crates/batten/src/provision.rs b/crates/batten/src/provision.rs index 43a652603..cec8a81da 100644 --- a/crates/batten/src/provision.rs +++ b/crates/batten/src/provision.rs @@ -624,13 +624,32 @@ pub fn status(entries: &[Provision], cache_root: &Path) -> Result { report.push(EntryStatus { name: entry.name.clone(), version: entry.version.clone(), - freshness: freshness_of(entry, cache_root)?, + freshness: freshness_of(entry, cache_root, true)?, }); } Ok(Report { entries: report }) } -fn freshness_of(entry: &Provision, cache_root: &Path) -> Result { +/// Whether an engine at `exe` may write the HOST's links for `repo_root` +/// (CLOUD-2124). +/// +/// **A build of the checkout it provisions never does.** Such a binary is +/// `cargo`'s output, not an installed engine — the line `engine update` already +/// draws for the same reason (CLOUD-2063). A launcher names its interpreter by +/// absolute path, so a gate running `provision apply` through `cargo run` +/// rewrote `~/.local/bin/mise` to run a debug build: measured 2026-10-07, a +/// scratch clone's `target/debug/batten` became every `mise` call's +/// interpreter for the whole host. The binary installs the binary; once one is +/// installed, it alone provisions the host. The cache is the repository's own, +/// so a build of it still fills it — the scanner a gate needs stays reachable. +#[must_use] +//MUTANT-SUITE crates/batten/src/provision.rs +//MUTANT checkout-build-links-host|s@^ !exe.starts_with(repo_root)$@ true@|a_build_of_the_checkout_never_links_the_host +pub fn links_host(exe: &Path, repo_root: &Path) -> bool { + !exe.starts_with(repo_root) +} + +fn freshness_of(entry: &Provision, cache_root: &Path, links_host: bool) -> Result { let dir = entry_dir(cache_root, entry); let binary = dir.join(BIN_DIR).join(&entry.binary); if !binary.is_file() { @@ -652,7 +671,7 @@ fn freshness_of(entry: &Provision, cache_root: &Path) -> Result { // `Missing` rather than a fourth verdict: what is missing is the binary at // the place this entry declares it must be, which is the same class as // nothing cached and takes the same repair. - if let Some(dest) = entry.link.as_deref() { + if let Some(dest) = entry.link.as_deref().filter(|_| links_host) { let linked = expand_home(dest)?.join(&entry.binary); if !linked.is_file() { return Ok(Freshness::Missing); @@ -728,8 +747,16 @@ pub enum Applied { /// did make. An /// unsupported URL scheme is a [`UsageError`] (→ exit `1`), since the manifest /// asked for something this build does not do. -pub fn apply(entry: &Provision, cache_root: &Path, dry_run: bool) -> Result { - if freshness_of(entry, cache_root)? == Freshness::Fresh { +/// +/// `links_host` is [`links_host`]'s answer: `false` leaves the declared link — +/// the host's, not the repository's — unjudged and unwritten. +pub fn apply( + entry: &Provision, + cache_root: &Path, + dry_run: bool, + links_host: bool, +) -> Result { + if freshness_of(entry, cache_root, links_host)? == Freshness::Fresh { return Ok(Applied::AlreadyFresh); } if dry_run { @@ -747,7 +774,7 @@ pub fn apply(entry: &Provision, cache_root: &Path, dry_run: bool) -> Result Result Result<()> { +fn install(entry: &Provision, cache_root: &Path, bytes: &[u8], links_host: bool) -> Result<()> { let dir = entry_dir(cache_root, entry); let bin_dir = dir.join(BIN_DIR); fs::create_dir_all(&bin_dir).context("create the provision cache directory")?; @@ -814,7 +841,7 @@ fn install(entry: &Provision, cache_root: &Path, bytes: &[u8]) -> Result<()> { // reading `missing` also leaves the link unmade. A fresh entry whose link // never landed would be the silent half-install this ordering exists to // rule out. - if let Some(dest) = entry.link.as_deref() { + if let Some(dest) = entry.link.as_deref().filter(|_| links_host) { link_onto_path(entry, dest, &cached, &binary)?; } // The artifact is written last, so a crash between the two leaves the entry @@ -2132,6 +2159,26 @@ pub fn binary_path(repo_root: &Path, entry: &Provision) -> Result { mod tests { use super::*; + /// CLOUD-2124: the measured case, a scratch clone's debug build provisioning + /// the host from inside its own checkout, is refused; an installed engine, + /// and a build of ANOTHER tree provisioning a fixture, still link. + #[test] + fn a_build_of_the_checkout_never_links_the_host() { + let repo = Path::new("/scratch/ciclone"); + assert!( + !links_host(Path::new("/scratch/ciclone/target/debug/batten"), repo), + "cargo's output inside the provisioned checkout is not an installed engine" + ); + assert!( + links_host(Path::new("/root/.local/bin/batten"), repo), + "the installed engine provisions the host" + ); + assert!( + links_host(Path::new("/home/user/batten/target/debug/batten"), repo), + "a build of another tree is not a build of this one" + ); + } + // ----------------------------------------------------------------------- // The control credential is DERIVED, never a forge's literal (CLOUD-1615). // From 53fbc5efc60a8dbd0c54bbf944264c999008194f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 02:47:59 +0000 Subject: [PATCH 17/22] fix(provision): no task calls the installer; a fixture holds the rule (CLOUD-2123) mise is itself a provision row batten installs, so a host that can run a mise task already has a binary and no task can be the bootstrap. deps-install runs batten engine update before the reap, and program run loose now refuses the installer inside any quoted task string. The program-run-loose fixture shows an argv entry and a whole run string firing while backticked prose stays silent. The census also names engine-provision for CLOUD-2124's new mutant row. Refs: CLOUD-2123 --- batten.toml | 13 +++++++------ .../repos/program-run-loose/batten.toml.in | 11 +++++++++++ .../fixtures/repos/program-run-loose/expected.in | 5 +++++ .../repos/program-run-loose/mise.toml.in | 9 +++++++++ mise.toml | 16 +++++++--------- 5 files changed, 39 insertions(+), 15 deletions(-) create mode 100644 crates/batten/tests/fixtures/repos/program-run-loose/batten.toml.in create mode 100644 crates/batten/tests/fixtures/repos/program-run-loose/expected.in create mode 100644 crates/batten/tests/fixtures/repos/program-run-loose/mise.toml.in diff --git a/batten.toml b/batten.toml index 0c979b16e..c0c617402 100644 --- a/batten.toml +++ b/batten.toml @@ -4840,16 +4840,17 @@ no_fix_reason = "class 3 (a port behind `batten`): the installer installs the bi # the LATEST release, and a pin naming any other build then refused every call — # measured twice on PR #1102 (2026-10-05, 2026-10-07). # -# THE PREDICATE IS AN UNCONDITIONAL CALL: the installer as a task's whole `run` -# string or as an argv entry of its own. The bootstrap guard (`else ./install.sh` -# under `command -v batten`) is the one admitted spelling, and prose quoting the -# installer in backticks is not a call. CI runners start with no binary, so the -# workflows' installs are genuine bootstraps and sit outside this glob. +# THE PREDICATE IS ANY CALL FROM A TASK, because no task can be the bootstrap: +# `mise` is itself a `[[provision]]` row batten installs, so a host running a +# `mise` task already has a binary. A call is the installer inside a quoted +# string — a `run` value or an argv entry; prose names it in backticks, which +# this does not read. CI runners start with no binary, so the workflows' installs +# are genuine bootstraps and sit outside this glob. [[rule]] id = "program run loose" kind = "forbid" glob = "mise.toml" -regex = '"\./install\.sh"|run\s*=\s*"\./install\.sh' +regex = '"[^"`\n]*\./install\.sh' severity = "deny" scope = "tree" no_fix_reason = "class 3 (a port behind `batten`): which bootstrap guard a task needs is a judgement about the host it runs on; once a binary exists the route is `batten engine update`" diff --git a/crates/batten/tests/fixtures/repos/program-run-loose/batten.toml.in b/crates/batten/tests/fixtures/repos/program-run-loose/batten.toml.in new file mode 100644 index 000000000..13b2c372d --- /dev/null +++ b/crates/batten/tests/fixtures/repos/program-run-loose/batten.toml.in @@ -0,0 +1,11 @@ +version = 1 + +# `program run loose` as `batten.toml` declares it (CLOUD-2123), copied so this +# fixture proves the committed regex rather than a paraphrase of it. +[[rule]] +id = "program run loose" +kind = "forbid" +glob = "mise.toml" +regex = '"[^"`\n]*\./install\.sh' +severity = "deny" +scope = "tree" diff --git a/crates/batten/tests/fixtures/repos/program-run-loose/expected.in b/crates/batten/tests/fixtures/repos/program-run-loose/expected.in new file mode 100644 index 000000000..0eceda3c2 --- /dev/null +++ b/crates/batten/tests/fixtures/repos/program-run-loose/expected.in @@ -0,0 +1,5 @@ +argv: check +exit: 2 +stdout: +mise.toml:3 rule 'program run loose' +mise.toml:6 rule 'program run loose' diff --git a/crates/batten/tests/fixtures/repos/program-run-loose/mise.toml.in b/crates/batten/tests/fixtures/repos/program-run-loose/mise.toml.in new file mode 100644 index 000000000..059346c32 --- /dev/null +++ b/crates/batten/tests/fixtures/repos/program-run-loose/mise.toml.in @@ -0,0 +1,9 @@ +# Prose naming `./install.sh` in backticks is not a call. +[tasks."session:batten"] +run = ["./install.sh", "batten engine update"] + +[tasks."deps-install"] +run = "./install.sh" + +[tasks."ok"] +run = ["batten engine update", "batten wiring reclaim -y"] diff --git a/mise.toml b/mise.toml index b78365042..77853ca3f 100644 --- a/mise.toml +++ b/mise.toml @@ -647,7 +647,7 @@ CI_VERDICT_STEPS = "Run mise run ,Run mise exec -- " # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain,engine-patch" +MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain,engine-patch,engine-provision" # The file inline tasks are declared in, for `mutate`'s `task-` route (CLOUD-1909). # The crate may not spell a consumer's filename (non-negotiable rule 1), so the # manifest is named here, beside the set it serves. Unset, a `task-` gate resolves @@ -2986,14 +2986,12 @@ description = "Provision the host dependencies mise does not: the released `batt # ends the task with its own exit code, so `install.sh`'s `1`/`2` still reach the # caller unreworded. # -# THE INSTALLER BOOTSTRAPS AND NOTHING ELSE (CLOUD-2123): it runs only where no -# `batten` resolves. A host that has one converges through the binary, because -# reinstalling the latest release over it is how an engine came to disagree -# with its pin. `program run loose` refuses an unconditional installer call. -run = [ - "if command -v batten >/dev/null 2>&1; then batten engine update; else ./install.sh; fi", - "batten wiring reclaim -y", -] +# THE INSTALLER HAS NO JOB IN ANY TASK (CLOUD-2123). `mise` is itself a +# `[[provision]]` row batten installs, so a host that can run this task already +# has a `batten`: the bootstrap is the setup script's `curl … | sh`, and from +# then on the binary installs the binary. Reinstalling the latest release here +# is how an engine came to disagree with its pin. `program run loose` holds it. +run = ["batten engine update", "batten wiring reclaim -y"] # The report half of the same seam, and the setup script calls this one too. # From 0e10e06737e98b84be2d6b89e4fad9cd1d48e632 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 02:48:28 +0000 Subject: [PATCH 18/22] test(refusal): read the reference and traversal verbs' findings in the labelled grammar (CLOUD-2075) The two verbs landed on main after this branch forked and asserted the unlabelled ` ` line; every finding now renders ` rule ''`. Refs: CLOUD-2075 --- .../repos/reference-unresolved/expected.in | 2 +- crates/batten/tests/it/reference.rs | 24 ++++++++++++------- crates/batten/tests/it/traversal_chain.rs | 12 +++++----- 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in b/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in index 6d57dec3a..cbb11de26 100644 --- a/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in +++ b/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -n.md:1 dangling unresolved +n.md:1 rule 'dangling' unresolved diff --git a/crates/batten/tests/it/reference.rs b/crates/batten/tests/it/reference.rs index daa824508..85382b945 100644 --- a/crates/batten/tests/it/reference.rs +++ b/crates/batten/tests/it/reference.rs @@ -73,7 +73,10 @@ fn an_absent_key_is_refused_at_its_line() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("notes/n.md:2 dangling unresolved"), "{out}"); + assert!( + out.contains("notes/n.md:2 rule 'dangling' unresolved"), + "{out}" + ); assert!( !out.contains("key-missing"), "pointer-only, never the token: {out}" @@ -115,7 +118,7 @@ fn an_unescaped_pipe_widens_the_row_and_an_escaped_one_is_quiet() { ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("REGISTER.md:3 dangling register-row-width"), + out.contains("REGISTER.md:3 rule 'dangling' register-row-width"), "{out}" ); @@ -160,7 +163,7 @@ register = "keyset" ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("entries/e.md:1 group-unknown unresolved"), + out.contains("entries/e.md:1 rule 'group-unknown' unresolved"), "{out}" ); } @@ -199,7 +202,7 @@ split = ";" ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("groups.md:3 member-unknown unresolved"), + out.contains("groups.md:3 rule 'member-unknown' unresolved"), "{out}" ); } @@ -246,7 +249,7 @@ relative_to = "citer" ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("out/REGISTER.md:5 file-missing unresolved"), + out.contains("out/REGISTER.md:5 rule 'file-missing' unresolved"), "{out}" ); assert!( @@ -286,7 +289,7 @@ inverse = true ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("REGISTER.md:4 roster-phantom uncited"), + out.contains("REGISTER.md:4 rule 'roster-phantom' uncited"), "{out}" ); } @@ -344,7 +347,10 @@ fn a_document_witness_admits_unless_its_only_witness_is_refused() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("middle/r.md:1 unwitness unresolved"), "{out}"); + assert!( + out.contains("middle/r.md:1 rule 'unwitness' unresolved"), + "{out}" + ); } #[test] @@ -371,7 +377,7 @@ regex = '^workspaces/([^/]+)/' ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("workspaces/a/middle/r.md:1 unwitness unresolved"), + out.contains("workspaces/a/middle/r.md:1 rule 'unwitness' unresolved"), "{out}" ); } @@ -420,7 +426,7 @@ fn an_unparseable_witness_is_could_not_look() { ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("witness/1.md:1 unwitness could-not-look"), + out.contains("witness/1.md:1 rule 'unwitness' could-not-look"), "{out}" ); } diff --git a/crates/batten/tests/it/traversal_chain.rs b/crates/batten/tests/it/traversal_chain.rs index 0f3c2bdd2..15239676d 100644 --- a/crates/batten/tests/it/traversal_chain.rs +++ b/crates/batten/tests/it/traversal_chain.rs @@ -125,7 +125,7 @@ fn a_closing_chain_exits_zero() { fn no_record_for_the_slug_breaks_at_the_entry() { let (code, out) = check("chain-no-record", 8, &[("db/e.md", ENTRY)]); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-entry"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-entry'"), "{out}"); } #[test] @@ -136,7 +136,7 @@ fn a_record_citing_no_capture_breaks_at_the_record() { &[("db/e.md", ENTRY), ("middle/e.md", "---\nother: x\n---\n")], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-record"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-record'"), "{out}"); } #[test] @@ -147,7 +147,7 @@ fn a_missing_capture_file_breaks_at_the_capture() { &[("db/e.md", ENTRY), ("middle/e.md", RECORD)], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-leaf"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-leaf'"), "{out}"); } #[test] @@ -162,7 +162,7 @@ fn a_capture_present_but_unregistered_breaks_at_the_register() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-register"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-register'"), "{out}"); } #[test] @@ -179,7 +179,7 @@ fn a_cycle_terminates() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-"), "{out}"); } #[test] @@ -195,7 +195,7 @@ fn a_bound_is_reported_as_bound_exceeded_never_as_a_break() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md chain-bound"), "{out}"); + assert!(out.contains("db/e.md rule 'chain-bound'"), "{out}"); assert!( !out.contains("broke-at"), "a bound says nothing about the chain: {out}" From 5d095ac39ed6be2f47d9f8f465a88d02fa1095a4 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 03:30:26 +0000 Subject: [PATCH 19/22] fix(posture): fit the container clause inside the instruction budget (CLOUD-2117) The clause took AGENTS.md to ~3515 tokens of 3500 and 200 lines of 199. Reworded and reflowed in the same paragraph; the rule and its closing "can do it, do it; can't, file it" are unchanged. Refs: CLOUD-2117 --- AGENTS.md | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a89e120a6..e4524f967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,15 +45,14 @@ decision; the change is outside the scope you were asked — the gates authorize STEPS of agreed work, never whether it is agreed (CLOUD-431, bypass `BATTEN_CLAIM_CHECK_BYPASS`); or an action is destructive and _not_ gated (force-pushing `main`, deleting history, an out-of-band release); a lease-push or -harness denial never is. **Each stops the -fix, never the record**: what you decline to fix, you file. **A WRONGLY refusing -gate is a defect, not an answer** — repair it and carry on this session; ticketing -one is a punt in gate's clothing (CLOUD-597/615). **A punt is any deferral you -could have closed**, a predicate not a list: a block reported as a decision (a -block is a bug); "that's your call" on what your evidence settles; an authorized -action offered, or handed to a human to run — **the container is yours alone, -no human can run a command in it** (CLOUD-2117); an unbuilt mechanism awaited over -the instance in hand; your own landed work spared. Can do it, do it; can't, file it. +harness denial never is. **Each stops the fix, never the record**: what you +decline to fix, you file. **A WRONGLY refusing gate is a defect, not an answer** — +repair it this session; ticketing one is a punt in gate's clothing +(CLOUD-597/615). **A punt is any deferral you could have closed**, a predicate not +a list: a block reported as a decision (a block is a bug); "your call" on what your +evidence settles; an action offered, or handed to a human — **no human can run a +command in your container** (CLOUD-2117); a mechanism awaited over the instance in +hand; your landed work spared. Can do it, do it; can't, file it. **An override ask is ONE yes/no on the override, never a menu of routes** (CLOUD-680). It carries the refusing gate and its verdict string, what the gate From 00ebad9c7517169f32de12b8f4ba7d2932dc9778 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 04:05:29 +0000 Subject: [PATCH 20/22] test(provision): redden the committed installer row, not a fixture copy (CLOUD-2123) The fixture holds the regex as text, so it stayed green over an edit to batten.toml. program_run_loose.rs lifts the row out of the authority, and the #MUTANT row beside it (argv-call-unread) reddens it. Refs: CLOUD-2123 Admits: b68b7f706420c97f59bb5087f04eb7d21b8cef61631214dc3785d519f47fc744 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: batten.toml Admits-anchor: finding:d8354efd96f8248d9262c837c13737546346cc0559cc615b7edb0fb0726733e3 Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: CLOUD-2123 and CLOUD-2124 document changes this PR lands: their bodies name batten.toml because the fix and its test are in this diff, and PR #1102 closes both Admits-answer-rejected-route: the PR body closes both rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block Admits: 7e083932e728868a946e6e604888dba6b7afd23497075195e27690ff97785bdd Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/lib.rs Admits-anchor: finding:2b869752d8eb2a5560f31a51d5cf2be4a781a5238f784a7b7e2abbfae30119e7 Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: e13edef4fa9b72421e8a0a04ab882b6c4171de43dfd7d5a83cc499a4ae0625d7 Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: CLOUD-2123 and CLOUD-2124 document changes this PR lands: their bodies name crates/batten/src/lib.rs because the fix and its test are in this diff, and PR #1102 closes both Admits-answer-rejected-route: the PR body closes both rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block Admits: f1fca2ed06c462009f65098cc27872e639566449faa4e2f9e6905b282b24aa16 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/provision.rs Admits-anchor: finding:f862e7a1c997393e806448727a63adeb4fd33951c1af3260caa7464bb67a8c24 Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: CLOUD-2123 and CLOUD-2124 document changes this PR lands: their bodies name crates/batten/src/provision.rs because the fix and its test are in this diff, and PR #1102 closes both Admits-answer-rejected-route: the PR body closes both rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block --- batten.toml | 2 + crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/program_run_loose.rs | 67 +++++++++++++++++++++ mise.toml | 2 +- 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 crates/batten/tests/it/program_run_loose.rs diff --git a/batten.toml b/batten.toml index c0c617402..cb5a5f03c 100644 --- a/batten.toml +++ b/batten.toml @@ -4846,6 +4846,8 @@ no_fix_reason = "class 3 (a port behind `batten`): the installer installs the bi # string — a `run` value or an argv entry; prose names it in backticks, which # this does not read. CI runners start with no binary, so the workflows' installs # are genuine bootstraps and sit outside this glob. +#MUTANT-SUITE crates/batten/tests/it/program_run_loose.rs +#MUTANT argv-call-unread|s@^regex = '"\[^"`.*$@regex = 'run\\s*=\\s*"\\./install\\.sh'@|a_task_calling_the_installer_is_refused_and_prose_is_not [[rule]] id = "program run loose" kind = "forbid" diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 35ae29f3d..0aa161950 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -325,6 +325,7 @@ mod preset_segments; mod primitives; mod privileged_lane; mod process_group; +mod program_run_loose; mod prose_only; mod prospective_facts; mod provision; diff --git a/crates/batten/tests/it/program_run_loose.rs b/crates/batten/tests/it/program_run_loose.rs new file mode 100644 index 000000000..8e6ef0b2d --- /dev/null +++ b/crates/batten/tests/it/program_run_loose.rs @@ -0,0 +1,67 @@ +//! `program run loose` over the compiled binary (CLOUD-2123): no `mise` task +//! calls the installer. +//! +//! THE COMMITTED ROW, NOT A COPY. The fixture under `tests/fixtures/repos` holds +//! the regex as text, so it would stay green over an edit to `batten.toml`; this +//! lifts the row out of the authority itself, so the `#MUTANT` row beside it has +//! something to redden. + +use crate::common; + +use common::{Fixture, run, stdout}; + +/// The `[[rule]]` block whose id is `program run loose`, as `batten.toml` spells it. +fn committed_row() -> String { + let text = std::fs::read_to_string(common::at_root("batten.toml")).expect("the authority"); + let id = text + .find("id = \"program run loose\"") + .expect("batten.toml declares the row"); + let start = text[..id].rfind("[[rule]]").expect("the row's header"); + let end = text[id..] + .find("\n\n") + .map_or(text.len(), |offset| id + offset); + text[start..end].to_owned() +} + +/// `check` the row over a fixture `mise.toml`; return the exit code and stdout. +fn check(name: &str, tasks: &str) -> (Option, String) { + let config = format!("version = 1\n\n{}\n", committed_row()); + let dir = Fixture::new(name) + .config(&config) + .files(&[("mise.toml", tasks)]) + .base_commit() + .build(); + let out = run(&dir, &["check"]); + (out.status.code(), stdout(&out)) +} + +#[test] +fn a_task_calling_the_installer_is_refused_and_prose_is_not() { + let (code, out) = check( + "program-run-loose-committed", + "# Prose naming `./install.sh` in backticks is not a call.\n\ + [tasks.a]\n\ + run = [\"./install.sh\", \"batten engine update\"]\n\ + [tasks.b]\n\ + run = \"./install.sh\"\n\ + [tasks.c]\n\ + run = [\"batten engine update\", \"batten wiring reclaim -y\"]\n", + ); + assert_eq!(code, Some(2), "{out}"); + assert!( + out.contains("mise.toml:3 rule 'program run loose'"), + "an argv entry: {out}" + ); + assert!( + out.contains("mise.toml:5 rule 'program run loose'"), + "a whole run string: {out}" + ); + assert!( + !out.contains("mise.toml:1 "), + "prose in backticks is not a call: {out}" + ); + assert!( + !out.contains("mise.toml:7 "), + "the binary installing itself is the route: {out}" + ); +} diff --git a/mise.toml b/mise.toml index 77853ca3f..b10b74046 100644 --- a/mise.toml +++ b/mise.toml @@ -647,7 +647,7 @@ CI_VERDICT_STEPS = "Run mise run ,Run mise exec -- " # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain,engine-patch,engine-provision" +MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain,engine-patch,engine-provision,batten.toml" # The file inline tasks are declared in, for `mutate`'s `task-` route (CLOUD-1909). # The crate may not spell a consumer's filename (non-negotiable rule 1), so the # manifest is named here, beside the set it serves. Unset, a `task-` gate resolves From 1d46117f0473bf69d7460ebe9815e7da7f7de7f5 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 04:06:03 +0000 Subject: [PATCH 21/22] fix(admission): a fingerprint selects one of twin findings on a path (CLOUD-2126) Two findings of one rule over one path (issue file same for two rows naming the same file) printed identical pointers, so override request refused both as ambiguous and no subject addressed either. --subject now also takes a finding fingerprint, and the ambiguity refusal names each one. The fingerprint selects and the PATH binds, because apply_admissions looks admissions up by the finding path. Refs: CLOUD-2126 Admits: 8aa55fb3f4c1d5ec663697b4d743deeb061fd8d438aa3609c63ec7a3b5542dcb Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: mise.toml Admits-anchor: finding:0994519e5a89049fd2db6b40554c1c2c7339bcf940befb5a3f512fea7929ead1 Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: bb207936867592ff2b9084c44cfbbf4377c9ae05870d3106096ed5353e77937a Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: the rows PR #1102 closes name mise.toml because their fixes and tests change it in this diff, and the PR body closes each of them Admits-answer-rejected-route: the PR body closes the rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block Admits: 850440f565af4a119c83d22a48c54461276647e22e07e2afe485460928cab1b3 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: mise.toml Admits-anchor: finding:beca79d771bd30b1870affdb89ea450fae346856c288b56954b4ae34e53bcd93 Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: 8aa55fb3f4c1d5ec663697b4d743deeb061fd8d438aa3609c63ec7a3b5542dcb Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: the rows PR #1102 closes name mise.toml because their fixes and tests change it in this diff, and the PR body closes each of them Admits-answer-rejected-route: the PR body closes the rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block Admits: ee08a6f818b76119f765579430066b6faf624875af5dfb64c805681c46a0a1aa Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: mise.toml Admits-anchor: finding:dd3bb7e52a857bbece781f658ebbb22d6de794feac845c21392473f691b10f47 Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: 850440f565af4a119c83d22a48c54461276647e22e07e2afe485460928cab1b3 Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: the rows PR #1102 closes name mise.toml because their fixes and tests change it in this diff, and the PR body closes each of them Admits-answer-rejected-route: the PR body closes the rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block --- crates/batten/src/lib.rs | 53 +++++++++++--- crates/batten/tests/it/admission.rs | 108 ++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 1b599d6e6..6c6b37950 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8237,6 +8237,9 @@ fn run_override_spend( } } +//MUTANT-SUITE crates/batten/tests/it/admission.rs +//MUTANT fingerprint-unaddressed|s@path == subject || fingerprint == subject@path == subject@|twin_findings_on_one_path_are_each_admitted_by_fingerprint +//MUTANT fingerprint-binds|s@Ok((admission::Anchor::Finding(fingerprint), path))@Ok((admission::Anchor::Finding(fingerprint), subject.to_owned()))@|twin_findings_on_one_path_are_each_admitted_by_fingerprint /// The [`admission::Anchor`] a `(rule, subject)` pair is answered about /// (CLOUD-1125). /// @@ -8290,15 +8293,18 @@ fn admission_anchor( config: &resolve::Resolved, rule: &str, subject: &str, -) -> Result { +) -> Result<(admission::Anchor, String)> { // The HEAD is READ, never defaulted. `unwrap_or_default()` here would bind // `call:` on a repository this cannot resolve, so two different // could-not-look states would share one address and an admission minted in // either would spend against the other. - let head = || -> Result { - Ok(admission::Anchor::Call { - head: git::head_commit(root)?, - }) + let head = || -> Result<(admission::Anchor, String)> { + Ok(( + admission::Anchor::Call { + head: git::head_commit(root)?, + }, + subject.to_owned(), + )) }; // `RunOverSelection`, never `Run`: this is a NARROWED read — one rule, one // subject — and registry equality's exhausted half is a property of the whole @@ -8448,16 +8454,31 @@ fn admission_anchor( ) else { return head(); }; - let mut matched: Vec = scan + // THE PATH OR THE FINDING'S OWN FINGERPRINT (CLOUD-2126). A pointer is the + // first path-bearing subject, so two findings of one rule over one path — + // `issue file same` for two rows naming the same file — print the same + // pointer and differ only in identity. Matching the path alone left that + // pair with no admission route at all; the fingerprint is the pointer that + // tells them apart, and the refusal below names each one. + // + // THE FINGERPRINT SELECTS, THE PATH BINDS. `apply_admissions` looks an + // admission up by the finding's PATH, so a binding whose subject were the + // fingerprint would be answered, spent and queried by nothing — the defect + // the ambiguity arm exists to refuse. The path returned is what binds. + let mut matched: Vec<(String, String)> = scan .findings .iter() - .filter(|finding| finding.rule == rule && finding.path == subject) - .map(|finding| finding.identity.fingerprint.to_hex()) + .filter(|finding| finding.rule == rule) + .map(|finding| (finding.path.clone(), finding.identity.fingerprint.to_hex())) + .filter(|(path, fingerprint)| path == subject || fingerprint == subject) .collect(); matched.sort_unstable(); matched.dedup(); match matched.len() { - 1 => Ok(admission::Anchor::Finding(matched.remove(0))), + 1 => { + let (path, fingerprint) = matched.remove(0); + Ok((admission::Anchor::Finding(fingerprint), path)) + } // ZERO SPLITS IN TWO, AND THE SPLIT IS CLOUD-1551 (with CLOUD-1374 and // CLOUD-1378 folded into it). One arm is the honest fallback this // always was; the other is the ambiguity arm's defect wearing a smaller @@ -8516,9 +8537,17 @@ fn admission_anchor( // `apply_admissions` looks up a `Finding` anchor, so a `Call` one stored // for a tree finding is queried by nothing. A refusal that says why beats // an override that appears to work. + // AND IT NAMES THE WAY THROUGH (CLOUD-2126): each candidate's fingerprint, + // which `--subject` accepts, so the refusal is a route rather than a wall. count => Err(UsageError::raise(format!( "{count} findings for rule `{rule}` name subject `{subject}`, so the pair does not \ - address one finding and an admission bound to it would suppress none of them" + address one finding and an admission bound to it would suppress none of them; \ + name one by its fingerprint as the subject: {}", + matched + .iter() + .map(|(_, fingerprint)| fingerprint.as_str()) + .collect::>() + .join(", ") ))), } } @@ -8616,7 +8645,9 @@ fn run_override_request( // // The scan this pays for is the narrowed one (CLOUD-1571), so the // questions-only path costs one rule over one subject rather than the tree. - let anchor = admission_anchor(root, &config, rule, subject)?; + // A fingerprint subject selects one finding; its PATH is what binds. + let (anchor, bound) = admission_anchor(root, &config, rule, subject)?; + let subject = bound.as_str(); let mut raw = String::new(); if std::io::stdin().read_to_string(&mut raw).is_err() { diff --git a/crates/batten/tests/it/admission.rs b/crates/batten/tests/it/admission.rs index 83cb1f903..6bd53398d 100644 --- a/crates/batten/tests/it/admission.rs +++ b/crates/batten/tests/it/admission.rs @@ -1422,6 +1422,114 @@ fn a_mint_addressing_no_finding_of_a_rule_that_fired_refuses_rather_than_binding ); } +/// Two findings over ONE path, told apart only by a second subject — the shape +/// `filed-here` raises when two rows name the same file (CLOUD-2126). +const TWINS: &str = r#" +package batten.admits + +import rego.v1 + +rules contains "always-refuses" + +violation contains { + "rule": "always-refuses", + "verdict": "always probe probe", + "subjects": [{"path": "a.rs"}, {"artifact": row}], +} if { + some row in ["ROW-1", "ROW-2"] +} +"#; + +/// Request an admission for `subject`; return the run. +fn request_for(root: &Path, subject: &str) -> std::process::Output { + common::run_with_stdin( + root, + &[ + "override", + "request", + "--rule", + "always-refuses", + "--verdict", + "always probe probe", + "--subject", + subject, + ], + "precondition=the refusal is the fixture's point\nlost=one twin of two\n\ + rejected-route=admits fix probe has nothing to change\n", + ) +} + +#[test] +fn twin_findings_on_one_path_are_each_admitted_by_fingerprint() { + // CLOUD-2126. Matching the path alone refused both twins as ambiguous and + // left no route at all; the refusal now names each fingerprint, a + // fingerprint selects one finding, and the PATH binds — `apply_admissions` + // looks an admission up by the finding's path, so binding the fingerprint + // would be spent and queried by nothing. + // + // Fails by: bind the fingerprint as the subject and the spend below refuses + // `mismatch`; drop the fingerprint arm and the second request refuses. + let root = admits_fixture_of("twins", TWINS, &["a.rs"]); + let refused = request_for(&root, "a.rs"); + assert_eq!( + refused.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "the path alone addresses two findings: {}", + common::stderr(&refused) + ); + let said = common::stderr(&refused); + let fingerprints: Vec<&str> = said + .rsplit_once(": ") + .map(|(_, listed)| listed.trim().split(", ").collect()) + .unwrap_or_default(); + assert_eq!(fingerprints.len(), 2, "the refusal names both: {said}"); + + for (spent, fingerprint) in fingerprints.iter().enumerate() { + let issued = request_for(&root, fingerprint); + let address = String::from_utf8_lossy(&issued.stdout).trim().to_owned(); + assert_eq!( + address.len(), + 64, + "a fingerprint addresses one: {}", + common::stderr(&issued) + ); + let spend = common::run( + &root, + &[ + "override", + "spend", + "--admission", + &address, + "--rule", + "always-refuses", + "--verdict", + "always probe probe", + "--subject", + "a.rs", + ], + ); + assert_eq!( + spend.status.code(), + Some(batten::exit::ExitCode::Success.code()), + "the path is what the admission bound: {}", + common::stderr(&spend) + ); + let after = common::run(&root, &["check"]); + let expected = if spent == 0 { + batten::exit::ExitCode::Violation + } else { + batten::exit::ExitCode::Success + }; + assert_eq!( + after.status.code(), + Some(expected.code()), + "admission {} of 2 clears exactly its twin: {}", + spent + 1, + common::stderr(&after) + ); + } +} + #[test] fn a_mint_for_a_rule_that_produced_no_finding_still_falls_back_to_the_call() { // THE FAIL-OPEN HALF, and the conjunct the case above does not reach From 31d95e7126069370c7c5c692acc86a59b02747aa Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 7 Oct 2026 04:28:07 +0000 Subject: [PATCH 22/22] refactor(admission): decide the anchor in its own function (CLOUD-2126) admission_anchor grew past clippy's 100-line limit with the fingerprint arm. The decision over the scan's findings moves to anchor_among, unchanged. Refs: CLOUD-2126 Admits: 227417264e92f51fc6def974b8856e87900e28da20f3d8117f65d5c40f174bf2 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: AGENTS.md Admits-anchor: finding:8786eb78364cce3dcb1d0a3ac664a1e1ea58e9aa28b27d26d9cfe7d662ee0bca Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: the rows PR #1102 closes name AGENTS.md because their fixes change it in this diff, and the PR body closes each of them Admits-answer-rejected-route: the PR body closes the rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block Admits: 5bb39b5a5da2fbad5b4d13c96bc9160998f6c5f91abe2a0bc688549afd133c51 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/lib.rs Admits-anchor: finding:60111cd8a01d611cccb66a523b64648c55de58fbfaecf6de162623ef550bd3ac Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: 7e083932e728868a946e6e604888dba6b7afd23497075195e27690ff97785bdd Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: CLOUD-2126 documents the change this PR lands: its body names crates/batten/src/lib.rs because the fix is in this diff, and PR #1102 closes it Admits-answer-rejected-route: the PR body closes the rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block Admits: 44da8ccf25675a57bbec6e6d7801916c54224c38f741b54c85c7bccb0da0c7a3 Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/tests/it/admission.rs Admits-anchor: finding:f8227ac970f008d244ea85da35d695b28d18414a6b4dbd67c52dcfcda7425067 Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: CLOUD-2126 documents the change this PR lands: its body names crates/batten/tests/it/admission.rs because its test is in this diff, and PR #1102 closes it Admits-answer-rejected-route: the PR body closes the rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block Admits: e2ef7fb74278772c63b0eeb131eedffd64809688c1bda6ca5221efef8e05960c Admits-rule: issue file same Admits-verdict: issue file same Admits-subject: crates/batten/src/lib.rs Admits-anchor: finding:30855a0f6f1aed40919caab6d7111522ac7b3a33c7a7f83e0b8eaaa86470f52f Admits-epoch: 70fd040a607fce5583bb1f41f8b49af65afbc619f8fb622bd7d6cb5a9de5075b Admits-author: alec@wenzowski.com Admits-prev: 5bb39b5a5da2fbad5b4d13c96bc9160998f6c5f91abe2a0bc688549afd133c51 Admits-answer-lost: nothing is deferred: each defect is fixed in this branch, and the rows are the record of those fixes Admits-answer-precondition: CLOUD-2101 is a duplicate of CLOUD-1700, which main landed; its commit emptied on the replay and the PR body no longer closes it, but the stale pr-closes record still lists it Admits-answer-rejected-route: the PR body closes the rows, but the pr-closes record cannot refresh here because gh pr view is refused by the GraphQL block --- crates/batten/src/lib.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 6c6b37950..bfb2d6099 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -8454,6 +8454,17 @@ fn admission_anchor( ) else { return head(); }; + anchor_among(&scan.findings, rule, subject, head) +} + +/// Which one of `findings` a `(rule, subject)` pair addresses, and the subject +/// that binds it — [`admission_anchor`]'s decision over the scan it ran. +fn anchor_among( + findings: &[rules::Finding], + rule: &str, + subject: &str, + head: impl FnOnce() -> Result<(admission::Anchor, String)>, +) -> Result<(admission::Anchor, String)> { // THE PATH OR THE FINDING'S OWN FINGERPRINT (CLOUD-2126). A pointer is the // first path-bearing subject, so two findings of one rule over one path — // `issue file same` for two rows naming the same file — print the same @@ -8465,8 +8476,7 @@ fn admission_anchor( // admission up by the finding's PATH, so a binding whose subject were the // fingerprint would be answered, spent and queried by nothing — the defect // the ambiguity arm exists to refuse. The path returned is what binds. - let mut matched: Vec<(String, String)> = scan - .findings + let mut matched: Vec<(String, String)> = findings .iter() .filter(|finding| finding.rule == rule) .map(|finding| (finding.path.clone(), finding.identity.fingerprint.to_hex())) @@ -8494,8 +8504,7 @@ fn admission_anchor( // What the two arms differ on is whether there was a finding to // address, and this scan already holds that fact. 0 => { - let mut named: Vec<&str> = scan - .findings + let mut named: Vec<&str> = findings .iter() .filter(|finding| finding.rule == rule) .map(|finding| finding.path.as_str())