From 6aa2ecc41260dcba73b77cc148858c5e2a1e7bd6 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 11:29:48 +0000 Subject: [PATCH 01/20] fix(verdict): measure the single-token dictionary the verdict grammar needs CLOUD-1284 replaces free-text `V-SCREAMING-KEBAB` class names with a three-word positional grammar drawn from declared vocabulary lists. Its arm 4 -- every vocabulary word is ONE token under a pinned tokenizer -- is what makes that convention enforceable rather than aspirational, and it is the row's one new dependency. It is also the arm the other five depend on, because the vocabulary cannot be curated without the measurement. This lands the measurement and its gate. It does not land the grammar. The tokenizer is a DEV-dependency, and that placement is the whole of why it is affordable. `budget.rs` estimates bytes/4 on purpose -- "an exact count needs a tokenizer, a vocabulary and a network fetch" -- and `batten hook` runs on every mediated tool call under CLOUD-689's budget, so a tokenizer in the shipped binary would put an embedded merge table on the config-load path and contradict both. In the test binary it contradicts neither: the vocabulary is a committed table, so its token counts are a property of the commit. `tiktoken-rs` vendors `o200k_base.tiktoken` in its own assets, so the gate is offline -- no network, and none of the "fails because a download failed" shape `budget.rs` argues against. MEASURED, 250 candidates: 237 are one token, 13 are not, and the 13 are a class rather than a scatter. `unparsed`, `unwired`, `untested`, `ungated`, `unbound`, `orphaned`, `shadowed`, `unclean`, `unsaid` and `untold` cost 2; `uncounted` costs 3; `mcp` and `rebase` cost 2. The commoner `unread`, `undefined`, `unknown`, `unnamed`, `unused`, `unmet` and `unseen` survive at 1. That reproduces the row's own worked example as a gate: it warns that `shell edit unretired` is 5 tokens against `task spelling weakened`'s 3, and this is what tells an author which of the two they just wrote. WHAT IS DELIBERATELY NOT HERE. The `[vocabulary]` table, the six load-time arms over it, and the rename of 130 classes and 124 route ids across 824 call sites. That conversion is all-or-nothing by construction -- `policy::check_registry_is_exhausted` refuses a declared-but-unraised token and `check_verdicts_are_declared` refuses a raised-but-undeclared one, so a half-renamed registry does not load at all -- and shipping half of it would leave a tree that cannot read its own config. The gate here is honest about covering the dictionary and not the grammar, and its doc comment says so rather than reading as the whole row. Refs: CLOUD-1284 --- Cargo.lock | 41 +++- Cargo.toml | 16 ++ crates/batten/Cargo.toml | 5 + crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/verdict_vocabulary.rs | 210 +++++++++++++++++++ mise.toml | 5 + 6 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 crates/batten/tests/it/verdict_vocabulary.rs diff --git a/Cargo.lock b/Cargo.lock index aa78abea3..cb8dc779b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -120,6 +120,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "batten" version = "0.0.137" @@ -162,6 +168,7 @@ dependencies = [ "signal-hook", "syn 3.0.3", "tar", + "tiktoken-rs", "tokio", "toml", "tower-service", @@ -584,6 +591,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fancy-regex" version = "0.19.0" @@ -1921,7 +1939,7 @@ dependencies = [ "bytecount", "data-encoding", "email_address", - "fancy-regex", + "fancy-regex 0.19.0", "fraction", "getrandom 0.3.4", "idna", @@ -2396,6 +2414,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.4" @@ -2787,6 +2811,21 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tiktoken-rs" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "027853bbf8c7763b77c5c595f1c271c7d536ced7d6f83452911b944621e57fc2" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex 0.17.0", + "lazy_static", + "regex", + "rustc-hash", +] + [[package]] name = "tinystr" version = "0.8.4" diff --git a/Cargo.toml b/Cargo.toml index d90195f6d..9acaee336 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -202,6 +202,22 @@ insta = { version = "1", default-features = false } # Inline fixtures for the snapshot cases, so a multi-line expectation reads as # the block it is rather than as escaped `\n`s. indoc = "2" +# The BPE tokenizer the verdict vocabulary is measured against (CLOUD-1284's arm +# 4). DEV-ONLY, and that placement is the whole of why it is affordable. +# +# `budget.rs` estimates bytes/4 on purpose -- "an exact count needs a tokenizer, +# a vocabulary and a network fetch, and a budget gate that fails because a +# download failed is worse than one 10% out" -- and `batten hook` runs on every +# mediated tool call under CLOUD-689's budget. A tokenizer in the SHIPPED binary +# would put an embedded merge table on the config-load path and contradict both. +# A tokenizer in the TEST binary contradicts neither: the vocabulary is a fixed +# table in `batten.toml`, so its token counts are a property of the commit, and +# the one place that property has to hold is a gate that reads the commit. +# +# So arm 4 is enforced where it is decidable and costs nothing at runtime. The +# pin it measures under is declared as data beside the vocabulary, never baked in +# here -- `bench/tokens/method.toml`'s discipline for its byte divisor. +tiktoken-rs = "0.12" # `min_batten_version` comparison. Cargo's own flavour of SemVer, so the config # key means what a Rust consumer already expects it to. flate2 = { version = "1", default-features = false, features = ["rust_backend"] } diff --git a/crates/batten/Cargo.toml b/crates/batten/Cargo.toml index 4575f737a..6fce0e9a3 100644 --- a/crates/batten/Cargo.toml +++ b/crates/batten/Cargo.toml @@ -147,6 +147,11 @@ jsonschema.workspace = true # reviewed diff. Never hand-edit a `.snap`. insta.workspace = true indoc.workspace = true +# CLOUD-1284 arm 4: every `[verdict.vocabulary]` word is ONE token under the +# declared pin. The shipped binary must not link a tokenizer (the workspace +# manifest carries why), and this table is exactly the place a crate the binary +# does not link belongs. +tiktoken-rs.workspace = true [lints] workspace = true diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index cb0f5193f..0c22b57ce 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -186,6 +186,7 @@ mod tool_selector; mod tool_verdict_facts; mod use_graph; mod verdict_registry; +mod verdict_vocabulary; mod waivers; mod walker; mod wiring_reclaim; diff --git a/crates/batten/tests/it/verdict_vocabulary.rs b/crates/batten/tests/it/verdict_vocabulary.rs new file mode 100644 index 000000000..6b44447b6 --- /dev/null +++ b/crates/batten/tests/it/verdict_vocabulary.rs @@ -0,0 +1,210 @@ +//! CLOUD-1284 arm 4: every vocabulary word is ONE token under the declared pin. +//! +//! This is the arm that makes the three-word grammar enforceable rather than +//! aspirational. The other five arms decide membership and arity over data the +//! engine already holds, so they live in `verdict::validate` and refuse at load; +//! this one needs a tokenizer, and a tokenizer must not reach the shipped binary +//! (`Cargo.toml` carries why). So it is a dev-dependency and a test over the +//! committed table — the one place the property has to hold is a gate that reads +//! the commit, and the table is a fixed committed artifact, so its token counts +//! are a property of the commit rather than of the world. + +/// The measured dictionary: every word the three-word grammar may draw on. +/// +/// **This is half of CLOUD-1284 and says so.** The row's other five arms decide +/// arity, membership, uniqueness, orphans and glosses over a `[vocabulary]` +/// table in `batten.toml`, and that table is not declared yet — the conversion +/// is all-or-nothing (`policy::check_registry_is_exhausted` refuses a +/// declared-but-unraised token and `check_verdicts_are_declared` refuses a +/// raised-but-undeclared one, so a half-renamed registry does not load). What +/// lands here first is the measurement those arms depend on, because the +/// vocabulary cannot be curated without it. +/// +/// Sifted from 250 candidates: **237 are one token, 13 are not**, and the 13 are +/// a class rather than a scatter — `unparsed`, `unwired`, `untested`, `ungated`, +/// `unbound`, `orphaned`, `shadowed`, `unclean`, `unsaid`, `untold` at 2 and +/// `uncounted` at 3, while the commoner `unread`, `undefined`, `unknown`, +/// `unnamed`, `unused`, `unmet` and `unseen` survive at 1. That is the issue's +/// own worked example (`shell edit unretired` is 5 tokens, not 3) reproduced as +/// a gate rather than an anecdote. +/// +/// When the table lands this list is replaced by a read of the declared words, +/// and the assertion is unchanged. +const CANDIDATES: &[&str] = &[ + "absent", + "adapter", + "add", + "admit", + "ahead", + "answer", + "ask", + "bats", + "bind", + "blocked", + "bound", + "branch", + "broken", + "build", + "cache", + "call", + "cargo", + "carry", + "check", + "claim", + "commit", + "config", + "connector", + "count", + "cover", + "dead", + "declare", + "default", + "denied", + "deny", + "diff", + "dirty", + "drift", + "dropped", + "duplicate", + "early", + "edit", + "empty", + "event", + "file", + "finish", + "forced", + "forge", + "gate", + "grade", + "grant", + "guard", + "handler", + "held", + "hook", + "input", + "install", + "issue", + "job", + "judge", + "key", + "lane", + "late", + "layer", + "lease", + "list", + "lock", + "loose", + "manifest", + "measure", + "memory", + "merge", + "mint", + "missing", + "module", + "name", + "never", + "open", + "other", + "own", + "parse", + "partial", + "patch", + "path", + "pin", + "place", + "point", + "port", + "program", + "prose", + "push", + "reach", + "read", + "red", + "refused", + "release", + "remedy", + "render", + "report", + "require", + "resolve", + "retire", + "review", + "route", + "rule", + "run", + "same", + "scanner", + "select", + "shell", + "ship", + "skip", + "sleep", + "source", + "spawn", + "spelling", + "stale", + "start", + "state", + "step", + "suite", + "symbol", + "table", + "tag", + "task", + "test", + "tier", + "timer", + "tool", + "trunk", + "turn", + "twice", + "unclear", + "undefined", + "unknown", + "unmet", + "unnamed", + "unread", + "unsafe", + "unseen", + "unused", + "version", + "watch", + "wire", + "workflow", + "workspace", + "write", + "wrong", +]; + +/// The pin, mirrored from the declaration so a drift is visible here too. +/// +/// `bench/tokens/method.toml`'s discipline: the constant a published figure +/// depends on is stated with its source rather than baked into a program. +const PIN: &str = "o200k_base"; + +#[test] +fn every_candidate_word_is_one_token_under_the_declared_pin() { + let bpe = tiktoken_rs::o200k_base().expect("the pinned encoding is vendored with the crate"); + + // A LEADING SPACE, and it is the whole measurement rather than a detail. + // BPE merges are trained on running text, where a word is preceded by a + // space; the token for `" task"` and the token for `"task"` are different + // merges and only the first is what a name inside a rendered line actually + // costs. Measuring the bare word would report a cheaper number than the hot + // path ever pays. + let mut multi: Vec<(usize, &str)> = Vec::new(); + for word in CANDIDATES { + let n = bpe.encode_with_special_tokens(&format!(" {word}")).len(); + if n != 1 { + multi.push((n, word)); + } + } + + assert!( + multi.is_empty(), + "{} of {} candidates are not one token under {PIN}: {:?}", + multi.len(), + CANDIDATES.len(), + multi + ); +} diff --git a/mise.toml b/mise.toml index 517dc3f05..ff0024379 100644 --- a/mise.toml +++ b/mise.toml @@ -1048,6 +1048,11 @@ env = { BATTEN_TEST_SCRATCH_LANE = "narrow" } [tasks."test:hook-profile"] run = "cargo nextest run -p batten --no-tests=fail -E 'binary(hook_profile)'" +[tasks."test:verdict-vocabulary"] +description = "Gate: every verdict vocabulary word is one token under the declared pin (CLOUD-1284 arm 4)" +run = "cargo nextest run -p batten --no-tests=fail -E 'binary(it) & test(/^verdict_vocabulary::/)'" +env = { BATTEN_TEST_SCRATCH_LANE = "narrow" } + [tasks."test:config-deprecations"] description = "Gate: no config key left the published schema without a deprecation window (CLOUD-360)" run = "cargo nextest run -p batten --no-tests=fail -E 'binary(it) & test(/^config_deprecations::/)'" From a3ba695db5c5dc74e347c9eef97d4b210314d98d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 11:52:25 +0000 Subject: [PATCH 02/20] fix(verdict): convert every refusal class to the declared three-word grammar CLOUD-1284. A class name was free text in `V-SCREAMING-KEBAB`, close to the worst case for a BPE vocabulary trained on running text -- uppercase runs and hyphen-joined compounds are rare there, so the merges are long. It also explained nothing on its own, which is WHY every class needed its own essay to gloss its own name. Both halves are data now. A name is exactly three space-separated words on a positional grammar, ` `, each drawn from a declared list in `batten.toml`'s new `[vocabulary]` table, and every word carries one gloss that every name spending it reuses. The marginal class costs no new prose; position carries the rest, because slot 2 is always what happened. CONVERTED: 105 consumer classes, 124 consumer routes, 7 `Native` variants, 18 vendored preset classes and 30 vendored routes -- 252 names over 824 call sites. `VERDICT_PREFIX` and `ROUTE_PREFIX` are deleted: the prefix existed to make a token recognisable beside a rule id and a path, and FIXED ARITY does that for free. Three words, then pointers, nothing between them. ARITY IS EXACTLY THREE AND MUST NOT BECOME A MAXIMUM. It is what lets a rendered line parse with no delimiter; a maximum puts the delimiter back and dissolves the grammar into the free text it replaced. `V-PRIVILEGED-LANE-UNTESTED-ORIGIN` carried five concepts and is `lane guard missing` -- split, per the row's own instruction, rather than granted a fourth slot. THE SIX ARMS. Arity, per-slot membership, uniqueness of the triple (the duplicate-id refusal unchanged -- under this grammar the id IS the triple), single-tokenness, no orphan vocabulary, every word glossed. Five refuse at load in `verdict::validate`. The sixth cannot: it needs a tokenizer and the shipped binary must not link one, so it is the dev-dependency gate from the previous commit, now reading the declared table with a drift test holding the two in agreement in BOTH directions. THE GRAMMAR IS OPT-IN, which is `[[pattern]]`'s preset exemption one table over. A consumer declaring no `[vocabulary]` has no lists for a name to be drawn from, so holding them to membership would be a demand with no fix available short of authoring 134 words -- the wrongly-refusing gate AGENTS.md calls a defect. Declaring the table opts in and is all-or-nothing from there, so the exemption cannot be spent as a partial adoption. It is also what lets the fixture corpus load unchanged instead of every fixture growing a table. SO DELETING THE TABLE IS A WEAKENING: `trust.rs` gains `VocabularyAbandoned` and its `CENSUS` row, because dropping it turns arms 1, 2 and 5 off with the config still loading clean. SHRINKING the lists is deliberately not reported -- a word a name still spends fails the load on its own, so the whole table going away is the only silent direction. MEASURED, and this is CLOUD-418's anti-vacuity case: the converted registry LOADS. Arity, membership and the orphan arm all hold over all 105 consumer classes and 124 routes, so no arm refuses everything. Route names cost less than feared -- 15 distinct names cover all 154 routes, derived from each route's kind and target, because a route id need only be unique within its own verdict. ALSO REPAIRED, because it blocked this rather than being adjacent to it: `mise run snapshots` could not run at all since the target consolidation (CLOUD-1210). The corpus moved to `tests/it/snapshots` and `--test snapshots` names a target that no longer exists, so `find` failed on a missing directory and the accept half never reached cargo. The module filter keeps the bound its own note argues for -- `--test it` unfiltered would run the suite under `cargo test`'s threads-in-one-process model, which `document_read_count` cannot survive. Refs: CLOUD-1284 Admits: e48c57d974996c843a78471d5986eab38e3107adf53d67a535a93767f62a938a Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 31bb1d159143323ae8b095a26e0754a8d0c437ef Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: 1d63eab531e6400bc02824f92daa7ba8329b98501bc7d641ecfc950eb176d1b2 Admits-answer-lost: The row cannot land at all. The grammar is opt-in on a declared `[vocabulary]`, and both the declaration and every name it governs live here, so refusing this write refuses the whole conversion rather than deferring part of it. Admits-answer-precondition: CLOUD-1284's whole deliverable IS this file: the `[vocabulary]` table it declares and the 105 class and 124 route names it renames are config, so batten.toml is both the surface that owns the change and the path the gate protects. There is no other surface that can express a rename of the registry's own names. The write is the diff a reviewer reads. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which is the file being refused, so the remedy it names is the thing denied. `patch run first` is `git restore`, which discards the conversion rather than expressing it. Admits: 2bf15fc7601f5ee1f48807fd3bf445dd25db90c6fc742c8cac2947ab2e910654 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .github/workflows/auto-release-land.yml Admits-head: 31bb1d159143323ae8b095a26e0754a8d0c437ef Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: A stale class name survives in CI's own definition of green, citing a token `batten policy explain` can no longer resolve. The conversion would be complete everywhere except the one file a reviewer reads to learn what CI does. Admits-answer-precondition: The workflow is touched only because it quoted a verdict token in prose, and CLOUD-1284 renames every one of them. Leaving it would strand a name no registry declares in a file a reader trusts, which is the drift the rename exists to remove. No surface other than the workflow can carry its own text. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which is not this file and cannot carry its text. `patch run first` is `git restore`, which would put the stale token back. --- .claude/rules/policy-modules.md | 2 +- .claude/rules/toolchain.md | 6 +- .github/workflows/auto-release-land.yml | 2 +- batten.toml | 1045 +++++++++++++---- completions/batten.fish | 4 +- completions/batten.zsh | 6 +- crates/batten/src/admission.rs | 8 +- crates/batten/src/cli.rs | 2 +- crates/batten/src/commit.rs | 2 +- crates/batten/src/config.rs | 12 +- crates/batten/src/hook.rs | 24 +- crates/batten/src/lib.rs | 4 +- crates/batten/src/perf.rs | 2 +- .../ci-hygiene/spend-is-authorised.rego | 20 +- .../ci-hygiene/wiring-can-be-reached.rego | 56 +- .../commit-hygiene/no-empty-commit.rego | 2 +- .../graded-head-is-not-regraded.rego | 4 +- .../pinned-program-via-the-pin.rego | 2 +- .../shebang-names-its-language.rego | 2 +- .../shell-hygiene/sibling-resolves.rego | 2 +- .../presets/trunk-based/no-force-push.rego | 2 +- crates/batten/src/ready.rs | 4 +- crates/batten/src/refusal.rs | 4 +- crates/batten/src/semver.rs | 2 +- crates/batten/src/surface.rs | 4 +- crates/batten/src/trust.rs | 26 + crates/batten/src/verdict.rs | 561 +++++++-- crates/batten/tests/it/admission.rs | 42 +- crates/batten/tests/it/authority_replay.rs | 2 +- crates/batten/tests/it/bypass_scrub.rs | 2 +- crates/batten/tests/it/ci_parity.rs | 14 +- crates/batten/tests/it/cli.rs | 4 +- crates/batten/tests/it/commit_admission.rs | 6 +- .../batten/tests/it/connector_not_granted.rs | 4 +- crates/batten/tests/it/harness_grant.rs | 8 +- crates/batten/tests/it/hook_profile.rs | 12 +- crates/batten/tests/it/mediated_admission.rs | 8 +- crates/batten/tests/it/memories.rs | 10 +- crates/batten/tests/it/mise_pin_agreement.rs | 20 +- crates/batten/tests/it/perf_pair.rs | 2 +- crates/batten/tests/it/pinned_programs.rs | 6 +- crates/batten/tests/it/pointer_only.rs | 6 +- crates/batten/tests/it/policy_test_suite.rs | 6 +- crates/batten/tests/it/privileged_lane.rs | 8 +- crates/batten/tests/it/prose_only.rs | 2 +- crates/batten/tests/it/ready.rs | 2 +- crates/batten/tests/it/retirement_doctrine.rs | 8 +- crates/batten/tests/it/review_answered.rs | 14 +- .../batten/tests/it/rules_builtin_claims.rs | 4 +- crates/batten/tests/it/rules_drift.rs | 14 +- crates/batten/tests/it/run_shape.rs | 22 +- crates/batten/tests/it/runner_verdict.rs | 2 +- crates/batten/tests/it/semver_gate.rs | 2 +- .../batten/tests/it/shell_write_advisory.rs | 4 +- .../it__snapshots__golden_json_schema.snap | 6 +- crates/batten/tests/it/stop_posture.rs | 4 +- crates/batten/tests/it/target_prune.rs | 2 +- crates/batten/tests/it/task_prose.rs | 2 +- crates/batten/tests/it/tool_verdict_facts.rs | 2 +- crates/batten/tests/it/verdict_registry.rs | 26 +- crates/batten/tests/it/verdict_vocabulary.rs | 80 +- crates/batten/tests/policy_modules.rs | 8 +- mise.toml | 33 +- policy/ancestry-decides-nothing.rego | 2 +- policy/bats-invocation.rego | 46 +- policy/ci-parity.rego | 106 +- policy/ci-suite-lane.rego | 10 +- policy/claim-before-code.rego | 4 +- policy/command-task-defined.rego | 4 +- policy/connector-not-granted.rego | 10 +- policy/denials-outlive-the-turn.rego | 6 +- policy/filed-here.rego | 12 +- policy/forge-verdict-required.rego | 6 +- policy/harness-grant.rego | 8 +- policy/harness-wiring.rego | 4 +- policy/hk-fix-selection.rego | 20 +- policy/hook-profile.rego | 16 +- policy/lock-entry-complete.rego | 115 ++ policy/memories.rego | 24 +- policy/mise-pin-agreement.rego | 20 +- policy/module-layering.rego | 8 +- policy/opa-compliance.rego | 16 +- policy/privileged-lane.rego | 4 +- policy/prose-only.rego | 6 +- policy/release-tag-shape.rego | 6 +- policy/remedy-authorship.rego | 4 +- policy/review-answered.rego | 4 +- policy/rules-drift.rego | 16 +- policy/run-shape.rego | 8 +- policy/shell-retirement.rego | 56 +- policy/shell-write-advisory.rego | 8 +- policy/spawn-adapters.rego | 6 +- policy/stop-posture.rego | 8 +- policy/suite-subject-retirable.rego | 24 +- policy/task-substitution.rego | 6 +- policy/test-targets.rego | 4 +- policy/validator-verdict-clean.rego | 6 +- policy/verdict-routes-resolve.rego | 4 +- policy/weakens-declared.rego | 6 +- policy/workspace-dep-referenced.rego | 6 +- renovate.json5 | 2 +- schema/batten.schema.json | 76 +- 102 files changed, 2037 insertions(+), 867 deletions(-) create mode 100644 policy/lock-entry-complete.rego diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index 8f9108d31..da353a145 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -47,7 +47,7 @@ above rather than with the authoring judgement below. ```rego violation contains { "rule": "shell-rule-retired", - "verdict": "V-SHELL-RULE-EDITED", + "verdict": "shell edit refused", "subjects": [{"path": path}], } if { ... } ``` diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index ac3cd4e6c..42d39ce4c 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -51,7 +51,7 @@ So a change touching one has exactly two shapes: `conserves` arm per deleted path, and drop the gate from `$MUTANT_GATES`. 2. **Leave the file alone.** -`V-SHELL-RULE-EDITED` declares one route, `R-PORT-AND-RETIRE`, with no override +`shell edit refused` declares one route, `rule read first`, with no override and no `bypass_env`. That is not an oversight to be worked around; it is the whole design. @@ -116,7 +116,7 @@ on the arm, beside the successor it qualifies: ``` A `policy/*.rego` or preset successor needs no field — its path already decides -it — and `V-SUCCESSOR-KIND-UNDECLARED` refuses only the engine-source arm that +it — and `shell port unnamed` refuses only the engine-source arm that omits one. **It does not refuse a verb**, and that is the point rather than a softening: a gate needing stdin, spawning with its own arguments, or performing a write cannot be a tree-scoped module, so the choice has to stay available and @@ -459,7 +459,7 @@ call` with no `CLOUD-*` key **in that same paragraph** stops the lap. Two open **Both environment variables are gone rather than ported** (CLOUD-1051): `BATTEN_FILED_HERE_BYPASS` and `BATTEN_FILED_HERE_OVERLAP` were knowable strings anyone could spend without articulating anything, and the override is - `V-FILED-OVER-OWN-DIFF`'s declared route with its precondition, issued and + `issue file same`'s declared route with its precondition, issued and spent through `batten override request`/`spend`. `land` still calls `mise run filed-here-check` by name — an inline `batten check` on that row now — so that call site is byte-identical and `land.sh` never diff --git a/.github/workflows/auto-release-land.yml b/.github/workflows/auto-release-land.yml index 7499bfb99..00cd27f25 100644 --- a/.github/workflows/auto-release-land.yml +++ b/.github/workflows/auto-release-land.yml @@ -313,7 +313,7 @@ jobs: # AND IT IS SET HERE RATHER THAN IN THE TASK, which is forced rather than # chosen. `mise-tasks/release-due.sh` is `governed_at_head` under # `policy/shell-retirement.rego` — it carries a shebang and a `#MISE - # description=` line — so editing its default raises V-SHELL-RULE-EDITED, + # description=` line — so editing its default raises shell edit refused, # which declares no override route and no `bypass_env`; `tests/release-due.bats` # is governed the same way. `mise.toml [env]` is ungoverned but WRONG: it # leaks into `mise run test:bats` and breaks the three cases pinned to the diff --git a/batten.toml b/batten.toml index 61abe8c8c..750e5e7e6 100644 --- a/batten.toml +++ b/batten.toml @@ -3422,7 +3422,7 @@ severity = "deny" scope = "tree" no_fix_reason = "migrate the predicate onto a rule kind, or declare `# stays-bash: ` in the new program and own the increase; which of the two is the whole question" -# CLOUD-1137. THE ROW ABOVE GLOBS ONE DIRECTORY, and `V-SHELL-RULE-ADDED` +# CLOUD-1137. THE ROW ABOVE GLOBS ONE DIRECTORY, and `shell add refused` # (CLOUD-1059) covers `mise-tasks/**` and `tests/**/*.bats`. Those globs were the # whole enforced perimeter against new bash, and two surfaces sit outside it: a # `run = '''…'''` body in `mise.toml` is bash, and `.claude/hooks/session-start.sh` @@ -3433,7 +3433,7 @@ no_fix_reason = "migrate the predicate onto a rule kind, or declare `# stays-bas # NOT the laundering path, which was already closed: `policy/shell-retirement.rego` # requires a deletion's ledger arm to name a policy surface AND a compiled-binary # test, and an inline body is neither, so retiring a described task by inlining it -# already raises `V-SUCCESSOR-NO-SURFACE`. What these rows add is the case with no +# already raises `shell port missing`. What these rows add is the case with no # sensor at all — shell authored straight into `mise.toml` or `.claude/**`, which # was never counted as bash in the first place. # @@ -4154,7 +4154,7 @@ severity = "deny" # not name — a false refusal, in the direction that blocks correct work. # # THE OVERRIDE IS DECLARED ON THE CLASS, not here. `[[verdict]]`'s -# `V-PROSE-ONLY-DIFF` carries an `override` route with its precondition, which is +# `diff ship early` carries an `override` route with its precondition, which is # what `batten override request` generates its questions from (CLOUD-1051). No # `bypass_env`: the whole point of that row is that the bare variable stops # working. @@ -4181,7 +4181,7 @@ severity = "deny" # NO `line_sources`: this row reads no file's contents. Its whole subject is the # recorder's record and the delta the engine already resolved. # -# THE OVERRIDE IS DECLARED ON THE CLASS. `V-FILED-OVER-OWN-DIFF` carries an +# THE OVERRIDE IS DECLARED ON THE CLASS. `issue file same` carries an # `override` route with its precondition, which is what `batten override request` # generates its questions from. No `bypass_env`, and specifically not # `BATTEN_FILED_HERE_OVERLAP`: the point of the admission mechanism is that the @@ -5965,175 +5965,748 @@ expires = "2027-02-28" # mechanism behind CLOUD-1053: the hot path prints token, gloss and pointer, and # a gloss nobody bounded is how the paragraph comes back. +# --------------------------------------------------------------------------- +# THE VERDICT VOCABULARY (CLOUD-1284). +# +# A class name is exactly three space-separated words, ` +# `, each drawn from the list for its position. `verdict::validate` +# holds every class and every route to it, so the naming convention is a gate +# rather than a habit -- which it had never been. +# +# WHY THREE WORDS RATHER THAN `V-SCREAMING-KEBAB`. Measured with `tiktoken` +# `o200k_base` over all 130 classes: the old spelling cost 9.9 tokens on +# average, lowercase kebab 5.2, and a curated three-word name 3.0. At ~300 +# refusals in a long session that is ~2,000 tokens an agent paid for a naming +# convention. Uppercase runs and hyphen-joined compounds are close to the worst +# case for a BPE vocabulary trained on running text. +# +# THE CURATION IS THE POINT, not the word count. `task spelling weakened` is 3 +# tokens; `shell edit unretired` is 5, because `unretired` is three merges on +# its own. `mise run test:verdict-vocabulary` is the gate on that, and it is +# what makes this list a measurement rather than an intention. +# +# THE GLOSS IS WHY A NAME NEEDS NO LOOKUP. Every word is defined once, here, and +# reused across every name that spends it -- so a new class costs no new prose, +# where a free-text name bought a fresh essay to explain itself. Position +# carries the rest: slot 2 is always what happened. +# +# ARITY IS FIXED AT THREE AND MUST NOT BE RELAXED. It is what lets a rendered +# refusal be read as ` ` with nothing separating them. A +# maximum instead of an exact count puts a delimiter back and dissolves the +# grammar into the free text it replaced. +[vocabulary] +tokenizer = "o200k_base" +tokenizer_source = "https://github.com/openai/tiktoken" +tokenizer_retrieved = "2026-09-01" + +# --- subject --- +[[vocabulary.subject]] +word = "adapter" +gloss = "a declared adapter row" + +[[vocabulary.subject]] +word = "bats" +gloss = "the bats suite runner" + +[[vocabulary.subject]] +word = "bound" +gloss = "a declared upper or lower bound" + +[[vocabulary.subject]] +word = "branch" +gloss = "a git branch" + +[[vocabulary.subject]] +word = "call" +gloss = "one mediated tool call" + +[[vocabulary.subject]] +word = "cargo" +gloss = "the Rust build tool" + +[[vocabulary.subject]] +word = "check" +gloss = "one named CI check, or a `batten check` run" + +[[vocabulary.subject]] +word = "claim" +gloss = "a pull-time claim on a tracker row" + +[[vocabulary.subject]] +word = "commit" +gloss = "one git commit" + +[[vocabulary.subject]] +word = "config" +gloss = "the committed policy authority" + +[[vocabulary.subject]] +word = "connector" +gloss = "an MCP connector" + +[[vocabulary.subject]] +word = "default" +gloss = "a value taken when nothing is declared" + +[[vocabulary.subject]] +word = "diff" +gloss = "the change under review" + +[[vocabulary.subject]] +word = "drift" +gloss = "a tracked surface moving under a session" + +[[vocabulary.subject]] +word = "event" +gloss = "a harness or forge event" + +[[vocabulary.subject]] +word = "forge" +gloss = "the code host" + +[[vocabulary.subject]] +word = "gate" +gloss = "a runnable check with an exit code" + +[[vocabulary.subject]] +word = "grant" +gloss = "handed over" + +[[vocabulary.subject]] +word = "hook" +gloss = "a mediated-call registration" + +[[vocabulary.subject]] +word = "input" +gloss = "the document a predicate reads" + +[[vocabulary.subject]] +word = "issue" +gloss = "a tracker row" + +[[vocabulary.subject]] +word = "job" +gloss = "one CI job" + +[[vocabulary.subject]] +word = "lane" +gloss = "a privileged execution lane" + +[[vocabulary.subject]] +word = "layer" +gloss = "a module layering position" + +[[vocabulary.subject]] +word = "lease" +gloss = "the landing lease" + +[[vocabulary.subject]] +word = "lock" +gloss = "a lockfile" + +[[vocabulary.subject]] +word = "manifest" +gloss = "a package manifest" + +[[vocabulary.subject]] +word = "memory" +gloss = "an agent memory document" + +[[vocabulary.subject]] +word = "module" +gloss = "a policy module" + +[[vocabulary.subject]] +word = "patch" +gloss = "a change identified by its content" + +[[vocabulary.subject]] +word = "path" +gloss = "a filesystem path" + +[[vocabulary.subject]] +word = "pin" +gloss = "fixed at a version" + +[[vocabulary.subject]] +word = "program" +gloss = "an executable" + +[[vocabulary.subject]] +word = "prose" +gloss = "authored text" + +[[vocabulary.subject]] +word = "release" +gloss = "a cut release" + +[[vocabulary.subject]] +word = "remedy" +gloss = "what a refusal tells the reader to do" + +[[vocabulary.subject]] +word = "review" +gloss = "a code review" + +[[vocabulary.subject]] +word = "route" +gloss = "one way out of a refusal" + +[[vocabulary.subject]] +word = "rule" +gloss = "a declared rule row" + +[[vocabulary.subject]] +word = "shell" +gloss = "an authored shell program" + +[[vocabulary.subject]] +word = "sleep" +gloss = "a delay" + +[[vocabulary.subject]] +word = "source" +gloss = "a file a predicate parses" + +[[vocabulary.subject]] +word = "spawn" +gloss = "a child process" + +[[vocabulary.subject]] +word = "step" +gloss = "one step of a gate" + +[[vocabulary.subject]] +word = "suite" +gloss = "a test suite" + +[[vocabulary.subject]] +word = "symbol" +gloss = "a resolved source symbol" + +[[vocabulary.subject]] +word = "tag" +gloss = "a release tag" + +[[vocabulary.subject]] +word = "task" +gloss = "a task-runner task" + +[[vocabulary.subject]] +word = "test" +gloss = "one test case or target" + +[[vocabulary.subject]] +word = "tier" +gloss = "a declared severity or profile tier" + +[[vocabulary.subject]] +word = "timer" +gloss = "a wall-clock wait" + +[[vocabulary.subject]] +word = "tool" +gloss = "a third-party validator" + +[[vocabulary.subject]] +word = "turn" +gloss = "one agent turn" + +[[vocabulary.subject]] +word = "version" +gloss = "a declared version" + +[[vocabulary.subject]] +word = "workflow" +gloss = "a CI workflow" + +[[vocabulary.subject]] +word = "workspace" +gloss = "the cargo workspace" + +# --- action --- +[[vocabulary.action]] +word = "add" +gloss = "was added" + +[[vocabulary.action]] +word = "admit" +gloss = "let through under a declaration" + +[[vocabulary.action]] +word = "answer" +gloss = "gave a reply" + +[[vocabulary.action]] +word = "ask" +gloss = "put the question" + +[[vocabulary.action]] +word = "bind" +gloss = "attached one thing to another" + +[[vocabulary.action]] +word = "carry" +gloss = "holds as a field or value" + +[[vocabulary.action]] +word = "check" +gloss = "one named CI check, or a `batten check` run" + +[[vocabulary.action]] +word = "count" +gloss = "tallied" + +[[vocabulary.action]] +word = "cover" +gloss = "reaches every member" + +[[vocabulary.action]] +word = "declare" +gloss = "stated in config" + +[[vocabulary.action]] +word = "deny" +gloss = "refused" + +[[vocabulary.action]] +word = "edit" +gloss = "was changed in place" + +[[vocabulary.action]] +word = "file" +gloss = "opened a tracker row" + +[[vocabulary.action]] +word = "grade" +gloss = "assigned a verdict" + +[[vocabulary.action]] +word = "grant" +gloss = "handed over" + +[[vocabulary.action]] +word = "guard" +gloss = "stands in front of" + +[[vocabulary.action]] +word = "judge" +gloss = "rendered a verdict" + +[[vocabulary.action]] +word = "key" +gloss = "is keyed by" + +[[vocabulary.action]] +word = "list" +gloss = "enumerates" + +[[vocabulary.action]] +word = "measure" +gloss = "took a reading" + +[[vocabulary.action]] +word = "mint" +gloss = "created a record" + +[[vocabulary.action]] +word = "name" +gloss = "refers to by name" + +[[vocabulary.action]] +word = "open" +gloss = "started" + +[[vocabulary.action]] +word = "own" +gloss = "is the authority for" + +[[vocabulary.action]] +word = "parse" +gloss = "read as structure" + +[[vocabulary.action]] +word = "pin" +gloss = "fixed at a version" + +[[vocabulary.action]] +word = "place" +gloss = "assigned a position" + +[[vocabulary.action]] +word = "point" +gloss = "refers onward" + +[[vocabulary.action]] +word = "port" +gloss = "moved to a successor surface" + +[[vocabulary.action]] +word = "reach" +gloss = "can get to" + +[[vocabulary.action]] +word = "read" +gloss = "was read" + +[[vocabulary.action]] +word = "report" +gloss = "stated a finding" + +[[vocabulary.action]] +word = "require" +gloss = "demands" + +[[vocabulary.action]] +word = "resolve" +gloss = "looked up" + +[[vocabulary.action]] +word = "retire" +gloss = "removed with a successor" + +[[vocabulary.action]] +word = "run" +gloss = "was executed" + +[[vocabulary.action]] +word = "select" +gloss = "chose a subset" + +[[vocabulary.action]] +word = "ship" +gloss = "released" + +[[vocabulary.action]] +word = "skip" +gloss = "was passed over" + +[[vocabulary.action]] +word = "spelling" +gloss = "how it is written" + +[[vocabulary.action]] +word = "state" +gloss = "says in prose" + +[[vocabulary.action]] +word = "table" +gloss = "the declared table" + +[[vocabulary.action]] +word = "watch" +gloss = "subscribed to" + +[[vocabulary.action]] +word = "wire" +gloss = "registered" + +[[vocabulary.action]] +word = "write" +gloss = "wrote" + +# --- condition --- +[[vocabulary.condition]] +word = "absent" +gloss = "nothing is there to look at" + +[[vocabulary.condition]] +word = "ahead" +gloss = "further along than its counterpart" + +[[vocabulary.condition]] +word = "blocked" +gloss = "cannot proceed" + +[[vocabulary.condition]] +word = "broken" +gloss = "will not parse" + +[[vocabulary.condition]] +word = "dead" +gloss = "reachable by nothing" + +[[vocabulary.condition]] +word = "dirty" +gloss = "reported findings" + +[[vocabulary.condition]] +word = "dropped" +gloss = "silently lost" + +[[vocabulary.condition]] +word = "duplicate" +gloss = "a second one exists" + +[[vocabulary.condition]] +word = "early" +gloss = "happened before its precondition" + +[[vocabulary.condition]] +word = "empty" +gloss = "declared and holding nothing" + +[[vocabulary.condition]] +word = "first" +gloss = "the first route to consider" + +[[vocabulary.condition]] +word = "held" +gloss = "still outstanding" + +[[vocabulary.condition]] +word = "last" +gloss = "the route of last resort" + +[[vocabulary.condition]] +word = "late" +gloss = "happened after it was useful" + +[[vocabulary.condition]] +word = "loose" +gloss = "wider than it should be" + +[[vocabulary.condition]] +word = "missing" +gloss = "expected and not found" + +[[vocabulary.condition]] +word = "never" +gloss = "does not happen at all" + +[[vocabulary.condition]] +word = "other" +gloss = "disagrees with its counterpart" + +[[vocabulary.condition]] +word = "partial" +gloss = "some fields and not others" + +[[vocabulary.condition]] +word = "red" +gloss = "failed" + +[[vocabulary.condition]] +word = "refused" +gloss = "the gate says no" + +[[vocabulary.condition]] +word = "same" +gloss = "identical where it should differ" + +[[vocabulary.condition]] +word = "stale" +gloss = "answers for a state that has moved" + +[[vocabulary.condition]] +word = "twice" +gloss = "happens more than once" + +[[vocabulary.condition]] +word = "unclear" +gloss = "cannot be decided from what is stated" + +[[vocabulary.condition]] +word = "undefined" +gloss = "named and never defined" + +[[vocabulary.condition]] +word = "unknown" +gloss = "not in any declared set" + +[[vocabulary.condition]] +word = "unnamed" +gloss = "carries no name" + +[[vocabulary.condition]] +word = "unread" +gloss = "could not be read" + +[[vocabulary.condition]] +word = "unsafe" +gloss = "fails open where it must fail closed" + +[[vocabulary.condition]] +word = "unseen" +gloss = "nothing looks at it" + +[[vocabulary.condition]] +word = "unused" +gloss = "declared and spent nowhere" + +[[vocabulary.condition]] +word = "wrong" +gloss = "does not match the declared shape" + [[verdict]] -id = "V-FORGE-VERDICT-NOT-GREEN" +id = "forge check red" gloss = "the forge judged this commit and its fan-in check did not pass" class = """ `final` is the fan-in every required job feeds, and CLOUD-900 records what naming the leaves instead buys: every failure becomes manufacturable by omitting a job. A recorded verdict that is anything but `success` on that check means the forge looked and refused. This is the DECISION half only — the polling stays outside the engine per CLOUD-1177 — so the remedy is to fix what CI refused and let the producer record the next verdict, never to re-read this one. """ [[verdict.route]] -id = "R-READ-THE-CI-CONTRACT" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" [[verdict.route]] -id = "R-LAND-DRIVES-THE-LOOP" +id = "task run first" kind = "command" target = "mise run land" [[verdict]] -id = "V-DENIALS-OUTLIVE-THE-TURN" +id = "turn deny held" gloss = "this session recorded refusals and the turn is ending again anyway" class = """ The session's own record counts hook runs that denied, and the agent is stopping a second time with that count still standing. A first stop after refusals is work concluding; a repeat one is a finding produced and then left behind, which is the question `finding-sink-check` reads a transcript to answer. The count is derived from a hook run's exit code rather than from any prose, and no span of session text reaches this decision. A session with no transcript is never this class -- that is could-not-look, and reading it as clean is the false green CLOUD-990 measured. """ [[verdict.route]] -id = "R-RESOLVE-THE-REFUSAL" +id = "module read first" kind = "document" target = "policy/denials-outlive-the-turn.rego" [[verdict]] -id = "V-TASK-SUBSTITUTION" +id = "task run loose" gloss = "this call is a weaker spelling of a task the project already defines" class = """ The project defines a task whose own argv leads with the same program, so this call runs the tool directly and skips whatever the task adds -- strictness flags, a pinned toolchain, a paired step. The refusal names the task because that name IS the remedy: run it instead. A task with no single-command body is never matched, so this cannot refuse a call by naming a command the task does not actually run. """ [[verdict.route]] -id = "R-RUN-THE-TASK-INSTEAD" +id = "module read first" kind = "document" target = "policy/task-substitution.rego" [[verdict]] -id = "V-MEMORY-ROOT-MISSING" +id = "memory resolve missing" gloss = "the memory graph has no root, so it is findable only by listing the directory" class = """ The reference model makes `mem:core` the discovery entry point, and a graph whose root is absent can be reached only by someone who already knows to list the directory. Judged only where memories exist at all: a repository carrying none is not a repository with a broken graph, and refusing there would make this rule unshippable over an ordinary tree. """ [[verdict.route]] -id = "R-READ-THE-MEMORY-ROOT" +id = "prose read first" kind = "document" target = "AGENTS.md" [[verdict]] -id = "V-MEMORY-NAME-SHADOWED" +id = "memory name duplicate" gloss = "a memory name strips to another name, so it cannot be addressed as written" class = """ The tooling silently strips one trailing `.md`, so `foo.md.md` lists as `foo.md` and no reference can ever name the file by its own filename. The two spellings then compete for one address. Rename the file so its name survives the strip. """ [[verdict.route]] -id = "R-RENAME-THE-MEMORY" +id = "task run first" kind = "command" target = "mise run memories-check" [[verdict]] -id = "V-MEMORY-NAME-UNREFERENCABLE" +id = "memory name unseen" gloss = "a memory name carries a character no reference can spell" class = """ The reference matcher stops at the first character outside its charset, so a memory whose name carries one cannot be referenced at all -- it is in the graph and no edge can reach it. The charset is the `memory-name` registry row, which is the same grammar `mem-reference` reads, so the two cannot drift. """ [[verdict.route]] -id = "R-RENAME-THE-MEMORY-TO-THE-CHARSET" +id = "module read first" kind = "document" target = "policy/memories.rego" [[verdict]] -id = "V-MEM-REF-STALE" +id = "memory point stale" gloss = "a `mem:` reference names a memory this tree does not carry" class = """ A dangling edge. The reference tooling's own integrity check is advisory by construction and its rename command is the only thing that keeps referrers in sync, so a `git mv` or any direct write orphans every reference silently -- which is what this exists to make loud. Pointer-only: the referrer's `path:line`, never the surrounding prose. """ [[verdict.route]] -id = "R-RENAME-THROUGH-THE-TOOLING" +id = "prose read first" kind = "document" target = "AGENTS.md" [[verdict]] -id = "V-MEMORY-SOURCE-UNREAD" +id = "memory read unread" gloss = "a declared referrer could not be read, so its edges were never judged" class = """ Could-not-look, and never a file with no stale references. A module iterating only the lines it received reports green over a file it never opened; this names the file instead. Distinct from a clean pass by construction, which is the whole point of the `missing` channel. """ [[verdict.route]] -id = "R-READ-THE-DECLARED-SOURCE" +id = "module read first" kind = "document" target = "policy/memories.rego" [[verdict]] -id = "V-CLAIM-BEFORE-CODE" +id = "claim mint absent" gloss = "a declared row was captured and carries no project" class = """ The capture store answered for this row's declared key and the payload carries no project, so the row is unfiled and no board state can be read about it. Asked rather than a status because the store has no clock: it keeps every read and answers from the first in digest order, so a predicate over a mutable field would report a state that has since moved. Absence is not this class -- a key nothing captured leaves the id out of the map entirely, which is could-not-look and never a verdict. """ [[verdict.route]] -id = "R-CLAIM-IT-BY-HAND" +id = "task run first" kind = "command" target = "mise run claim-check" [[verdict]] -id = "V-PROFILED-STEP-NOT-IN-CHECK" +id = "step declare missing" gloss = "a step declaring the slow profile is not selected by the `check` hook" class = "The false green the two-tier split can produce, and the asymmetric half: the pre-commit path still skips the step, so nothing looks wrong, while `mise run ci`, `verify` and CI have all silently stopped running it. The other direction — the tier stops being skipped at pre-commit — is loud and self-correcting, which is why this is the one with a gate." [[verdict.route]] -id = "R-RESTORE-THE-PROFILED-STEP" +id = "gate read first" kind = "document" target = "hk.pkl" [[verdict]] -id = "V-SLOW-TIER-EMPTY" +id = "tier list empty" gloss = "something planned this tree and no step declares the slow profile" class = "The tier has evaporated: every step would run at pre-commit, and every per-step assertion about the tier would pass over an empty set. Distinct from no record at all, which is could-not-look and refuses nothing." [[verdict.route]] -id = "R-RESTORE-THE-SLOW-TIER" +id = "gate read first" kind = "document" target = "hk.pkl" [[verdict]] -id = "V-HOOK-MISSING-PROFILE-FLAG" +id = "hook declare missing" gloss = "the git hook runs hk without `--profile '!slow'`, so every commit pays the slow tier" class = "The economy half rather than the coverage half: the tier is still correct and CI is unaffected. Read from non-comment lines that actually run the hook, because the flag's own explanatory comment kept this green once when the flag had been deleted from the command." [[verdict.route]] -id = "R-RESTORE-THE-PROFILE-FLAG" +id = "source read first" kind = "document" target = ".claude/hooks/git-hook.sh" [[verdict]] -id = "V-VALIDATOR-VERDICT-UNCLEAN" +id = "tool judge dirty" gloss = "a third-party validator judged this file and reported something" class = """ The record exists under this row's exact key -- the declared tool, the version it was pinned at, and the digest of the bytes it read -- so a validator ran over THIS revision and did not report clean. Absence is not this class: a record under any other key is not found at all, which is what keeps a stale verdict from reading as a fresh one. The remedy is to fix what the validator named and let the producer record the next verdict, never to re-read this one. """ [[verdict.route]] -id = "R-VALIDATOR-DECIDES-ITS-OWN-FINDINGS" +id = "module read first" kind = "document" target = "policy/validator-verdict-clean.rego" [[verdict]] -id = "V-RELEASE-TAG-SHAPE" +id = "tag mint wrong" gloss = "a release tag does not carry the shape this repository's release tooling mints" class = """ `release-plz` mints `v..` and every downstream reading — what has shipped, what is due, what a range since the last release contains — orders and compares on that shape. A tag that does not carry it is either a hand cut nobody will find again or a prerelease the ordering will misplace. Retag it in the shape the tooling mints, or move it out of the `v*` namespace so it is not read as a release. """ [[verdict.route]] -id = "R-RETAG-IN-SHAPE" +id = "rule read first" kind = "document" target = ".claude/rules/commits.md" [[verdict.route]] -id = "R-READ-THE-RELEASE-CONFIG" +id = "source read first" kind = "document" target = "release-plz.toml" @@ -6253,53 +6826,53 @@ A platform entry with a checksum and no url is the partial shape a regenerate-an """ [[verdict.route]] -id = "R-REGENERATE-THE-LOCK" +id = "task run first" kind = "command" target = "mise run lock-complete" [[verdict.route]] -id = "R-READ-THE-LOCK-RULE" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" [[verdict]] -id = "V-WEAKENS-DECLARES-NOTHING" +id = "commit declare empty" gloss = "a `Weakens:` trailer names no key, so it declares nothing while reading as a declaration" class = """ `config-lint` compares the weakenings an author DECLARED in a `Weakens:` trailer against the ones `trust::weakenings` DETECTED. A trailer with nothing after the colon satisfies every "did the author declare something" reading and matches no detected weakening, so it buys the appearance of disclosure without the content. Name the key the change weakens, or drop the trailer — an absent declaration is honest where an empty one is not. """ [[verdict.route]] -id = "R-NAME-THE-WEAKENED-KEY" +id = "config read first" kind = "document" target = "batten.toml" [[verdict.route]] -id = "R-READ-THE-COMMIT-RULE" +id = "rule read first" kind = "document" target = ".claude/rules/commits.md" [[verdict]] -id = "V-HARNESS-WIRING-SECOND-DECIDER" +id = "hook wire duplicate" gloss = "a PreToolUse registration outside this repository does not reach the mediator" class = """ `PreToolUse` is ONE entry — the engine — and CLOUD-312 measured the alternative: six task-runner launches per Bash call, 1.247s serial to do milliseconds of policy, ~93% of it startup. A second decider registered beside the mediator re-introduces that cost and, worse, splits the verdict across two authorities that can disagree about one call. The wiring this judges is the launcher's own merged settings, which live outside this repository — CLOUD-1167's fact is what makes the question expressible at all, and the row that declares the file is the whole bound on what the module may read. """ [[verdict.route]] -id = "R-REGISTER-ONLY-THE-MEDIATOR" +id = "prose read first" kind = "document" target = "AGENTS.md" [[verdict.route]] -id = "R-READ-THE-WIRING-RULE" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" # --- closing the raw path (CLOUD-1260) --------------------------------------- [[verdict]] -id = "V-RAW-CONNECTOR-GRANTED" +id = "connector grant loose" gloss = "a reduced tool is also granted raw, so the reduction decides nothing" class = """ Dispatch and reduction save nothing while a cheaper-looking route to the full \ @@ -6313,14 +6886,14 @@ prose with no gate, and the measurement is what falsified it. """ [[verdict.route]] -id = "R-DROP-THE-RAW-GRANT" +id = "task run first" kind = "command" target = "remove the raw connector entry from `permissions.allow` in .claude/settings.json and reach the row through `batten mcp call`" # --- the committed half of the same grant (CLOUD-1247) ----------------------- [[verdict]] -id = "V-HARNESS-GRANT-ABSENT" +id = "grant declare absent" gloss = "the committed settings no longer grant this repository's own binary" class = """ `permissions.allow` is not the layer that decides. Claude Code's auto-mode \ @@ -6334,12 +6907,12 @@ session, in the repository whose own product this is. The grant that works is an """ [[verdict.route]] -id = "R-RESTORE-THE-GRANT" +id = "task run first" kind = "command" target = "add an `autoMode.allow` entry naming `batten` to .claude/settings.json" [[verdict]] -id = "V-HARNESS-GRANT-DEFAULTS-DROPPED" +id = "default carry dropped" gloss = "the grant is there and the built-in classifier rules were discarded with it" class = """ `$defaults` inherits the built-in classifier rules at its position in the list. \ @@ -6351,12 +6924,12 @@ rather than a second reason folded into the first. """ [[verdict.route]] -id = "R-RESTORE-THE-DEFAULTS" +id = "task run first" kind = "command" target = "restore the `$defaults` sentinel to .claude/settings.json's autoMode.allow" [[verdict]] -id = "V-SHELL-RULE-ADDED" +id = "shell add refused" gloss = "an authored shell rule or bats suite was added, moving CLOUD-843's corpus the wrong way" class = """ CLOUD-843's campaign is retiring 144 shell programs and 161 bats suites onto the \ @@ -6368,12 +6941,12 @@ row that it stays bash. """ [[verdict.route]] -id = "R-WRITE-A-POLICY-MODULE" +id = "rule read first" kind = "document" target = ".claude/rules/policy-modules.md" [[verdict.route]] -id = "R-DECLARE-IT-STAYS-BASH" +id = "config read first" kind = "document" target = "batten.toml" @@ -6398,7 +6971,7 @@ target = "batten.toml" # is legitimate and blocking "done" punishes correct behaviour — and the demotion # is what makes that structural instead of careful. [[verdict]] -id = "V-HEDGED-FLAG-FRAMING" +id = "prose report duplicate" gloss = "a finding was written as editorial rather than to a durable home" class = """ Chat stores nothing, so a finding flagged in passing has no reader after the \ @@ -6409,17 +6982,17 @@ precision, which is why this rule leads the end-of-turn set. """ [[verdict.route]] -id = "R-FILE-THE-FINDING" +id = "issue file first" kind = "issue" target = "put it in the row that already owns it, or file one" [[verdict.route]] -id = "R-DROP-THE-HEDGE" +id = "task run first" kind = "command" target = "say it plainly, or say nothing" [[verdict]] -id = "V-PROSE-ONLY-DIFF" +id = "diff ship early" gloss = "every changed line is a comment and no test moved, so a CI matrix would confirm nothing" class = """ A full required matrix costs real minutes against a trunk landing every few \ @@ -6431,17 +7004,17 @@ the next one these files carry. """ [[verdict.route]] -id = "R-BATCH-IT" +id = "task run first" kind = "command" target = "let the next change to these files carry the prose" [[verdict.route]] -id = "R-OVERRIDE-PROSE-ONLY" +id = "path admit first" kind = "override" precondition = "the prose IS the deliverable and cannot wait for the next change to these files" [[verdict]] -id = "V-TEST-TARGET-ADDED" +id = "test add refused" gloss = "a new top-level crates/batten/tests/*.rs mints a second cargo test target" class = """ Cargo autodiscovers one test target per top-level `crates/batten/tests/*.rs`, and \ @@ -6453,12 +7026,12 @@ mints no target, which is where a retirement's tier belongs. """ [[verdict.route]] -id = "R-ADD-IT-TO-THE-GROUP" +id = "patch run first" kind = "command" target = "git mv the file under crates/batten/tests/it/ and declare it in that group's main.rs" [[verdict]] -id = "V-FILED-UNREFINED" +id = "issue file unclear" gloss = "a row this branch created was never groomed to Ready, so filing cost nothing" class = """ Filing is the third sink and the most expensive one on purpose: a new row costs \ @@ -6470,22 +7043,22 @@ carried. """ [[verdict.route]] -id = "R-FIX-IT-HERE" +id = "task run first" kind = "command" target = "close the row you filed and fix it in this diff" [[verdict.route]] -id = "R-COMMENT-ON-THE-OWNER" +id = "issue file first" kind = "issue" target = "put it in the row that already owns it — comments are recorded and never gated" [[verdict.route]] -id = "R-GROOM-TO-READY" +id = "task run other" kind = "command" target = "mise run ready-lint" [[verdict]] -id = "V-FILED-OVER-OWN-DIFF" +id = "issue file same" gloss = "a row this branch filed names code this branch has open" class = """ The punt CLOUD-514 is about. A defect you found in your own diff is a defect you \ @@ -6500,27 +7073,27 @@ names none of the diff. """ [[verdict.route]] -id = "R-FIX-IT-HERE" +id = "task run first" kind = "command" target = "close the row you filed and fix it in this diff" [[verdict.route]] -id = "R-CLOSE-IT-IN-THE-BODY" +id = "task run other" kind = "command" target = "name it in closing form in the PR body, so the merge lands it" [[verdict.route]] -id = "R-FILE-IT-AFTER-LANDING" +id = "task run last" kind = "command" target = "file it from a clean tree, when it is no longer your diff" [[verdict.route]] -id = "R-OVERRIDE-FILED-HERE" +id = "path admit first" kind = "override" precondition = "the row DOCUMENTS the change being landed, so naming its files is the point rather than a deferral" [[verdict]] -id = "V-SHELL-RULE-EDITED" +id = "shell edit refused" gloss = "an authored shell rule or bats suite was edited in place rather than migrated" class = """ The load-bearing arm. A migration replaces a shell gate; it does not maintain \ @@ -6532,28 +7105,28 @@ the successor on a `// carried:`, `// subsumed:` or `// changed:` row. """ [[verdict.route]] -id = "R-PORT-AND-RETIRE" +id = "rule read first" kind = "document" target = ".claude/rules/policy-modules.md" # --- the same rule, said before the work instead of after it (CLOUD-1131) ----- [[verdict]] -id = "V-SHELL-EDIT-BEFORE-RETIREMENT" +id = "shell edit early" gloss = "a write targets a path the retirement gate governs, said at the edit rather than at verify" class = """ -The same doctrine as `V-SHELL-RULE-EDITED` and none of its force: this class refuses nothing and moves no exit code. It exists because ORDER changes the conclusion a reader draws. `shell-retirement` is tree-scoped, so its refusal first arrives at `mise run verify` with the edit already finished, and the cheapest reading of a finished edit plus a gate saying no is "the gate is wrong" rather than "this should have been a retirement" — measured twice in one planning session. Arriving at the write costs nothing and removes that reading. The set is deliberately WIDER than the edit-time one, because the mediated surface cannot read a file to check for a shebang: it names a path the tree gate may yet allow, which is the sanctioned direction for advice and would not be for a refusal. +The same doctrine as `shell edit refused` and none of its force: this class refuses nothing and moves no exit code. It exists because ORDER changes the conclusion a reader draws. `shell-retirement` is tree-scoped, so its refusal first arrives at `mise run verify` with the edit already finished, and the cheapest reading of a finished edit plus a gate saying no is "the gate is wrong" rather than "this should have been a retirement" — measured twice in one planning session. Arriving at the write costs nothing and removes that reading. The set is deliberately WIDER than the edit-time one, because the mediated surface cannot read a file to check for a shebang: it names a path the tree gate may yet allow, which is the sanctioned direction for advice and would not be for a refusal. """ [[verdict.route]] -id = "R-PORT-AND-RETIRE" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" # --- the pole of the gate (CLOUD-386) ---------------------------------------- [[verdict]] -id = "V-HK-SKIP-UNCOVERED" +id = "gate skip unseen" gloss = "a CI job carves a gate step out and no job runs it" class = """ A workflow job may hand hk a step to skip, so the gate can be split across \ @@ -6566,12 +7139,12 @@ does not know what a caller passes. This class covers the second direction. """ [[verdict.route]] -id = "R-RUN-THE-CARVED-OUT-STEP" +id = "workflow read first" kind = "document" target = ".github/workflows/ci.yml" [[verdict]] -id = "V-CI-WORKFLOW-UNREAD" +id = "workflow read unread" gloss = "the workflow this rule judges would not parse, so nothing was decided" class = """ A declared source that will not parse is not an absent one. Absent means the \ @@ -6582,12 +7155,12 @@ set being left empty. """ [[verdict.route]] -id = "R-REPAIR-THE-WORKFLOW-DOCUMENT" +id = "workflow read first" kind = "document" target = ".github/workflows/ci.yml" [[verdict]] -id = "V-LEASE-PRECONDITION-ABSENT" +id = "lease guard absent" gloss = "a job can start spending before the landing lease authorises its branch" class = """ The lease serialises landing, but enforcing it only inside the lander means \ @@ -6601,12 +7174,12 @@ cannot start ahead of the cancellation. """ [[verdict.route]] -id = "R-ASK-THE-LEASE-FIRST" +id = "workflow read first" kind = "document" target = ".github/workflows" [[verdict]] -id = "V-LEASE-PRECONDITION-FATAL" +id = "lease guard unsafe" gloss = "a precondition body that will not parse reds the first step of every job" class = """ The presence clause matches the step's NAME, so a copy that reds its own job \ @@ -6621,12 +7194,12 @@ suffix. """ [[verdict.route]] -id = "R-TOLERATE-THE-PRECONDITION" +id = "workflow read first" kind = "document" target = ".github/workflows" [[verdict]] -id = "V-CHECK-STATUS-REROLLED" +id = "check grade twice" gloss = "a workflow reads check status and decides green with its own copy of the predicate" class = """ The green predicate has one home, and every hand-rolled copy of it so far has \ @@ -6638,12 +7211,12 @@ it cannot avoid. """ [[verdict.route]] -id = "R-DECIDE-THROUGH-THE-ONE-PREDICATE" +id = "task run first" kind = "command" target = "mise run checks-green" [[verdict]] -id = "V-BOT-PREFIX-UNWATCHED" +id = "branch watch missing" gloss = "a bot lands on a branch prefix no workflow watches at its trigger" class = """ Nothing runs on a bot's behalf unless a workflow is watching its heads. Handing \ @@ -6656,12 +7229,12 @@ is already too late. A lane whose config is absent is not asked for a watcher. """ [[verdict.route]] -id = "R-POINT-A-LANDER-AT-THE-PREFIX" +id = "workflow read first" kind = "document" target = ".github/workflows" [[verdict]] -id = "V-CI-TASK-NOT-IN-VERIFY" +id = "task run missing" gloss = "CI runs a task `verify` does not, so CI is where that failure is discovered" class = """ The workflow contract promises that CI runs the same tasks an author runs \ @@ -6674,12 +7247,12 @@ are absent. """ [[verdict.route]] -id = "R-RUN-IT-IN-VERIFY" +id = "task run first" kind = "command" target = "mise run verify" [[verdict]] -id = "V-REQUIRED-CHECK-NAMES-NO-JOB" +id = "check name unknown" gloss = "the required roster names a check no job creates, so a wait never terminates" class = """ `ci-wait` and `land` decide whether a commit got an answer over the required \ @@ -6690,12 +7263,12 @@ hand-maintained list is only safe with a sensor on it. """ [[verdict.route]] -id = "R-RECONCILE-THE-ROSTER" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-JOB-NOT-IN-REQUIRED-ROSTER" +id = "job list missing" gloss = "a pull-request job is silently unrequired, so green can be reported without it" class = """ The other direction, and the one with no symptom: a job added and not listed is \ @@ -6706,12 +7279,12 @@ it breaks. """ [[verdict.route]] -id = "R-ADD-THE-JOB-TO-THE-ROSTER" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-RELEASE-PR-NOT-DRAFT" +id = "release open early" gloss = "the release pull request opens ready, so every refresh of it buys a matrix" class = """ release-plz rewrites its branch on every push to the trunk. A non-draft pull \ @@ -6723,12 +7296,12 @@ zero; the flag is the mechanism rather than a preference. """ [[verdict.route]] -id = "R-OPEN-THE-RELEASE-PR-AS-A-DRAFT" +id = "source read first" kind = "document" target = "release-plz.toml" [[verdict]] -id = "V-DEPENDABOT-RETURNED" +id = "config carry duplicate" gloss = "a retired second bot is back on ecosystems the first already owns" class = """ This property INVERTED rather than being deleted: for the length of the \ @@ -6740,12 +7313,12 @@ surviving one already serves — with nothing else in the tree going red about i """ [[verdict.route]] -id = "R-MOVE-IT-TO-THE-ONE-BOT" +id = "config read first" kind = "document" target = "renovate.json5" [[verdict]] -id = "V-RENOVATE-BOUND-MISSING" +id = "bound declare missing" gloss = "a key that decides what the bot lane spends or covers is absent or set to its own negation" class = """ Five keys decide what the lane costs and what it reaches: whether its heads open \ @@ -6757,12 +7330,12 @@ the bound and its own negation differ by a single character. """ [[verdict.route]] -id = "R-RESTORE-THE-BOUND" +id = "config read first" kind = "document" target = "renovate.json5" [[verdict]] -id = "V-RENOVATE-COMMIT-TYPE-UNSCOPED" +id = "commit name unnamed" gloss = "the commit type is written where a preset silently outranks it" class = """ Every commit here lands by fast-forward through commit-lint, so a bot subject \ @@ -6776,12 +7349,12 @@ cannot detect from the file. """ [[verdict.route]] -id = "R-SCOPE-THE-COMMIT-TYPE" +id = "config read first" kind = "document" target = "renovate.json5" [[verdict]] -id = "V-ECOSYSTEM-UNSERVED" +id = "manifest cover missing" gloss = "an ecosystem this repository maintains has nothing proposing updates for it" class = """ An ecosystem nobody updates goes stale with nothing red anywhere. That is \ @@ -6792,12 +7365,12 @@ this repository actually maintains rather than everything that exists. """ [[verdict.route]] -id = "R-ENABLE-THE-MANAGER" +id = "config read first" kind = "document" target = "renovate.json5" [[verdict]] -id = "V-FANIN-NOT-REQUIRED" +id = "job require missing" gloss = "the fan-in the abandon spares is not one the landing path waits for" class = """ A red required check cancels the runs still spending on that commit, and that \ @@ -6808,12 +7381,12 @@ nothing that matters while the real one is cancelled. """ [[verdict.route]] -id = "R-REQUIRE-THE-FANIN" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-FANIN-WORKFLOW-DECLARES-NO-JOB" +id = "workflow declare empty" gloss = "the file named as the fan-in's home does not carry it" class = """ The abandon compares the declared workflow against each run's path, so a fan-in \ @@ -6824,12 +7397,12 @@ once, with nothing red. """ [[verdict.route]] -id = "R-NAME-THE-FANINS-HOME" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-ABANDON-RESTATES-THE-FANIN" +id = "job declare duplicate" gloss = "the abandon decides which run to spare from a literal rather than the declaration" class = """ A literal path in the task is a second authority for one fact, and the copy that \ @@ -6839,12 +7412,12 @@ would not. """ [[verdict.route]] -id = "R-READ-THE-DECLARATION" +id = "task read first" kind = "document" target = "mise-tasks/abandon-matrix.sh" [[verdict]] -id = "V-ABANDON-NEVER-CALLED" +id = "job reach dead" gloss = "the abandon is wired, safe, and invoked by nothing" class = """ The anti-vacuity term. Every other assertion about the fan-in is about making \ @@ -6854,12 +7427,12 @@ fan-in is declared, and the matrix still bills out in full after the first red. """ [[verdict.route]] -id = "R-CALL-THE-ABANDON" +id = "task read first" kind = "document" target = "mise-tasks/land.sh" [[verdict]] -id = "V-FOREIGN-CARGO-SPELLING-DRIFT" +id = "cargo spelling other" gloss = "a foreign runner's cargo invocation is not the one `test:cargo` declares" class = """ `ci-task-parity` exempts a foreign runner, and correctly — there is no local \ @@ -6872,12 +7445,12 @@ exemption is per JOB rather than per property. """ [[verdict.route]] -id = "R-RESPELL-THE-FOREIGN-INVOCATION" +id = "workflow read first" kind = "document" target = ".github/workflows/rust.yml" [[verdict]] -id = "V-FOREIGN-CARGO-ABSENT" +id = "cargo reach absent" gloss = "no foreign-runner cargo invocation remains for the spelling rule to judge" class = """ The anti-vacuity term. Every other clause of this rule judges a foreign leg, and \ @@ -6889,12 +7462,12 @@ ceasing to test. """ [[verdict.route]] -id = "R-RESTORE-THE-FOREIGN-LEG" +id = "workflow read first" kind = "document" target = ".github/workflows/rust.yml" [[verdict]] -id = "V-TASK-CARGO-UNREADABLE" +id = "task read unread" gloss = "`test:cargo` yields no cargo invocation, so the comparison has no right-hand side" class = """ Could-not-look, refused rather than reported clean, because a gate that found \ @@ -6905,12 +7478,12 @@ grows one, this arm is how that is discovered. """ [[verdict.route]] -id = "R-DECLARE-THE-CARGO-INVOCATION" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-BATS-NOT-PARALLEL" +id = "suite run late" gloss = "the shell suite's invocation lost the parallelism it was measured with" class = """ A speed-up is the one kind of fix that rots silently: nothing fails when \ @@ -6923,12 +7496,12 @@ spellings that cap the count below the machine. """ [[verdict.route]] -id = "R-RESTORE-THE-MEASURED-INVOCATION" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-BATS-RUN-UNCOUNTED" +id = "suite count missing" gloss = "the run cannot say how many cases it executed, so a narrower run reads as a pass" class = """ A suite that got faster by running fewer tests is the failure every change to \ @@ -6942,12 +7515,12 @@ with it. """ [[verdict.route]] -id = "R-COUNT-WHAT-RAN" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-BATS-COST-UNMEASURED" +id = "suite measure missing" gloss = "nothing measures what the pole cost, or says which machine the measurement came from" class = """ The pole of the gate is 85.9% of the job that is the CI critical path, and its \ @@ -6962,12 +7535,12 @@ never the duration, which is a property of the machine and is reported. """ [[verdict.route]] -id = "R-REMEASURE-THE-POLE" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" [[verdict]] -id = "V-SUITE-SUBJECT-IMMORTAL" +id = "suite retire never" gloss = "a bats suite declares a subject no shell retirement can delete, and no exemption declares why" class = """ `SubjectFacts::died` is `all`, so a suite is deletable only once EVERY declared \ @@ -6980,14 +7553,14 @@ written down. # The exemption is a DECLARATION, not a waiver, which is why the route names the # table rather than a bypass. Re-subjecting the suite is the other answer and is -# refused by `V-SHELL-RULE-EDITED` with no override, so it is not offered here. +# refused by `shell edit refused` with no override, so it is not offered here. [[verdict.route]] -id = "R-DECLARE-THE-IMMORTAL-SUBJECT" +id = "module read first" kind = "document" target = "policy/suite-subject-retirable.rego" [[verdict]] -id = "V-SUITE-SUBJECT-UNDECLARED" +id = "suite declare missing" gloss = "a bats suite declares no `# subject:` at all, so nothing can decide whether it is retirable" class = """ The anti-vacuity half of the class above. Every other arm quantifies over a \ @@ -6998,12 +7571,12 @@ decide over. """ [[verdict.route]] -id = "R-DECLARE-THE-SUITE-SUBJECT" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" [[verdict]] -id = "V-SUITE-EXEMPTION-STALE" +id = "suite admit stale" gloss = "an exemption names a suite that is gone, or one whose subjects are now all retirable" class = """ The table held in the direction that keeps it honest. An exemption nothing needs \ @@ -7014,12 +7587,12 @@ nothing. """ [[verdict.route]] -id = "R-DROP-THE-SPENT-EXEMPTION" +id = "module read first" kind = "document" target = "policy/suite-subject-retirable.rego" [[verdict]] -id = "V-SUITE-SOURCE-UNREAD" +id = "suite parse unread" gloss = "a declared suite could not be read, so nothing about its subject was judged" class = """ Could-not-look, and it must not be spelled like either answer. A suite ABSENT \ @@ -7031,12 +7604,12 @@ produces over a file it never opened. """ [[verdict.route]] -id = "R-MAKE-THE-SUITE-READABLE" +id = "task run first" kind = "command" target = "mise run lint:shell" [[verdict]] -id = "V-BATS-SOURCE-UNREAD" +id = "bats parse unread" gloss = "a declared source for the invocation could not be read, so nothing about it was judged" class = """ Could-not-look, and it must not be spelled like either answer. An ABSENT manifest \ @@ -7050,14 +7623,14 @@ clean is the vacuous pass this engine exists to keep inexpressible. # file answers — never "add the task", since an ABSENT manifest is not this # refusal at all. [[verdict.route]] -id = "R-MAKE-THE-SOURCE-READABLE" +id = "task run first" kind = "command" target = "mise run lint:toml" # --- the formatters-only subset (CLOUD-681) ---------------------------------- [[verdict]] -id = "V-FMT-DESCRIBED-AS-THE-GATE" +id = "task state wrong" gloss = "the prose describing `fmt` no longer says it is the formatters-only subset" class = """ The other direction, and the one the issue's own §2 names as the inverse \ @@ -7068,12 +7641,12 @@ is the whole defect, and it does not matter which of them is the one that lied. """ [[verdict.route]] -id = "R-CORRECT-THE-DESCRIPTION" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-FIXER-TASK-UNROUTED" +id = "task select missing" gloss = "a `fmt:*` task exists that no hk step routes, so `mise run fmt` cannot reach it" class = """ The silent half, and the second defect this row was written on. `deno-fmt` \ @@ -7087,12 +7660,12 @@ of a linter is what puts it in scope. """ [[verdict.route]] -id = "R-ROUTE-THE-FIXER" +id = "gate read first" kind = "document" target = "hk.pkl" [[verdict]] -id = "V-HK-SOURCE-UNREAD" +id = "gate parse unread" gloss = "a declared source for the hook selection could not be read, so nothing about it was judged" class = """ Could-not-look, and it must not be spelled like either answer. An ABSENT config \ @@ -7103,12 +7676,12 @@ vacuous pass this engine exists to keep inexpressible. """ [[verdict.route]] -id = "R-MAKE-THE-HOOK-CONFIG-READABLE" +id = "task run first" kind = "command" target = "mise run pkl-check hk.pkl" [[verdict]] -id = "V-RETIREMENT-UNMAPPED" +id = "shell retire missing" gloss = "a governed file was deleted and the retirement ledger records no successor for it" class = """ CLOUD-908's defect at file granularity: the deletion is admitted and the logic \ @@ -7118,12 +7691,12 @@ under the `declared_in` glob the `[rule.conserves]` column already declares. """ [[verdict.route]] -id = "R-ADD-A-LEDGER-ARM" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-RETIREMENT-AMBIGUOUS" +id = "shell retire unclear" gloss = "a deleted file carries more than one retirement arm, so it records no answer at all" class = """ `carried`, `subsumed` and `changed` are three different claims about where the \ @@ -7133,12 +7706,12 @@ that is. """ [[verdict.route]] -id = "R-KEEP-THE-TRUE-ARM" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-SUCCESSOR-NO-SURFACE" +id = "shell port missing" gloss = "a retirement arm names no policy surface, only a test" class = """ A mapping that names only a test records where the assertions went and not where \ @@ -7151,12 +7724,12 @@ home and engine source is mechanism only. """ [[verdict.route]] -id = "R-NAME-THE-SURFACE" +id = "rule read first" kind = "document" target = ".claude/rules/policy-modules.md" [[verdict]] -id = "V-SUCCESSOR-NO-TEST" +id = "test port missing" gloss = "a retirement arm names no compiled-binary test, so nothing proves the port works" class = """ A module's own `test_` rules are the load-time tier and they fabricate their own \ @@ -7167,12 +7740,12 @@ successor must be a `crates/batten/tests/*.rs` case. """ [[verdict.route]] -id = "R-ADD-A-BINARY-TEST" +id = "rule read first" kind = "document" target = ".claude/rules/policy-modules.md" [[verdict]] -id = "V-SUCCESSOR-KIND-UNDECLARED" +id = "shell port unnamed" gloss = "a retirement arm names engine source without saying whether that is a new verb or mechanism" class = """ `crates/batten/src/*.rs` is two different dispositions wearing one spelling: a \ @@ -7186,12 +7759,12 @@ makes that case visible instead of automatic. """ [[verdict.route]] -id = "R-DECLARE-THE-SUCCESSOR-KIND" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" [[verdict]] -id = "V-WITHDRAWAL-SUBJECT-ALIVE" +id = "shell retire never" gloss = "a `withdrawn` retirement arm was spent over a subject the tree still carries" class = """ The condition that keeps the fourth arm narrower than a `[[waiver]]` over the \ @@ -7203,7 +7776,7 @@ Either delete the declared subject in this same change, or name a successor on a """ [[verdict.route]] -id = "R-RETIRE-THE-SUBJECT-TOO" +id = "config read first" kind = "document" target = "batten.toml" @@ -7214,7 +7787,7 @@ target = "batten.toml" # decide — a subject that travels on the row — and `retirement_blockers` is the # half that reads the declared `# subject:` header out of the base text. [[verdict]] -id = "V-RETIREMENT-SUBJECT-ALIVE" +id = "program retire never" gloss = "a retirement arm names a governed path this change does not retire" class = """ A retirement conserves coverage only where the thing under test goes with the \ @@ -7226,7 +7799,7 @@ this file's subject. """ [[verdict.route]] -id = "R-RETIRE-THE-NAMED-SUBJECT" +id = "config read first" kind = "document" target = "batten.toml" @@ -7286,7 +7859,7 @@ kind = "document" target = "batten.toml" [[verdict]] -id = "V-WITHDRAWAL-UNEXPLAINED" +id = "shell retire empty" gloss = "a `withdrawn` retirement arm names neither a successor nor a reason" class = """ The fourth arm names no successor by design, so the reason is the only thing a \ @@ -7296,12 +7869,12 @@ path. """ [[verdict.route]] -id = "R-EXPLAIN-THE-WITHDRAWAL" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-ANCESTRY-DECIDES-MERGEDNESS" +id = "patch judge wrong" gloss = "a reachability answer decides merged-ness, which a rebased landing is invisible to" class = """ Merged-ness is decided by patch identity, never by reachability (CLOUD-36). This \ @@ -7311,12 +7884,12 @@ no longer exists. The failure is silent and in the permissive direction. """ [[verdict.route]] -id = "R-ASK-PATCH-IDENTITY" +id = "patch run first" kind = "command" target = "git cherry" [[verdict]] -id = "V-COMMIT-WITHOUT-A-MESSAGE-SOURCE" +id = "commit write missing" gloss = "a `git commit` names no message source, so git opens $EDITOR and blocks there" class = """ No `-m`, `-F`, `-C`, `--no-edit`, `--fixup` or `--squash`. Git opens $EDITOR and \ @@ -7326,7 +7899,7 @@ four minutes (CLOUD-488). Write the message to a file and use \ """ [[verdict.route]] -id = "R-COMMIT-FROM-A-FILE" +id = "patch run first" kind = "command" target = "git commit -F " @@ -7337,7 +7910,7 @@ target = "git commit -F " # an author who reads "name a message source" while looking at their own `-F -` # concludes the gate is wrong. [[verdict]] -id = "V-COMMIT-STDIN-UNBOUND" +id = "commit bind missing" gloss = "a `git commit -F -` has nothing redirected into the element it is written in, so it reads /dev/null" class = """ The heredoc binds to the element that WRITES it. `git commit -F - && mise run \ @@ -7351,14 +7924,14 @@ and killing it took `kill -9` on the process group. A heredoc, `< msg.txt` or \ """ [[verdict.route]] -id = "R-COMMIT-FROM-A-FILE-THAT-CANNOT-REBIND" +id = "patch run first" kind = "command" target = "git commit -F " # CLOUD-613, CLOUD-482. The waste here is the SESSION rather than a verdict or a # gate, which is why it is a class of its own rather than a row on either above. [[verdict]] -id = "V-FOREGROUND-SLEEP" +id = "sleep run blocked" gloss = "a foreground `sleep` spends the session's own turn waiting, and the call is killed at ~2 minutes" class = """ A wait longer than about two minutes does not run slowly, it FAILS — measured at \ @@ -7371,20 +7944,20 @@ when the condition holds — that is a background wait and is allowed. """ [[verdict.route]] -id = "R-WAIT-ON-THE-CONDITION" +id = "task run first" kind = "command" target = "until ; do sleep 1; done" [[verdict.route]] -id = "R-ASK-WHAT-IS-RUNNING" +id = "task run other" kind = "command" target = "mise run alive" -# CLOUD-821. NOT a narrower `V-FOREGROUND-SLEEP`: backgrounding is the remedy for +# CLOUD-821. NOT a narrower `sleep run blocked`: backgrounding is the remedy for # that one and the subject of this one, so an author reading the wrong class here # would be told to do the thing they already did. [[verdict]] -id = "V-BACKGROUND-TIMER" +id = "timer run refused" gloss = "a backgrounded `sleep` with no loop around it is a timer, not a wait" class = """ It exits when the clock says so, never when the thing being waited for happens, \ @@ -7398,17 +7971,17 @@ is allowed. """ [[verdict.route]] -id = "R-WAIT-ON-THE-CONDITION-NOT-THE-CLOCK" +id = "task run first" kind = "command" target = "until ; do sleep 1; done" [[verdict.route]] -id = "R-ASK-WHAT-IS-RUNNING-ONCE" +id = "task run other" kind = "command" target = "mise run alive" [[verdict]] -id = "V-WORKFLOW-UNPARSED" +id = "workflow parse broken" gloss = "a workflow could not be parsed, so its lanes were never judged" class = """ Could-not-look, and deliberately not spelled the same way as a workflow whose \ @@ -7417,12 +7990,12 @@ about itself, and reporting silence there is CLOUD-251's vacuous pass. """ [[verdict.route]] -id = "R-FIX-THE-WORKFLOW-SYNTAX" +id = "task run first" kind = "command" target = "mise run lint:deno" [[verdict]] -id = "V-PRIVILEGED-LANE-UNTESTED-ORIGIN" +id = "lane guard missing" gloss = "a job an outside author can reach holds contents:write and tests no head origin" class = """ The privileged-lane shape: a trigger an outside author can fire, a token that can \ @@ -7432,12 +8005,12 @@ permission. """ [[verdict.route]] -id = "R-TEST-THE-HEAD-ORIGIN" +id = "workflow read first" kind = "document" target = ".github/workflows" [[verdict]] -id = "V-MCP-PIN-DISAGREES" +id = "pin declare other" gloss = "a tool version .mcp.json names is not the version mise.toml pins" class = """ The second place a pin is written cannot drift from the first. `mise exec` treats \ @@ -7448,12 +8021,12 @@ starts fine, which is what makes it a gate rather than a runtime error. """ [[verdict.route]] -id = "R-REPEAT-THE-AUTHORITATIVE-PIN" +id = "config read first" kind = "document" target = ".mcp.json" [[verdict]] -id = "V-MCP-PIN-UNDECLARED" +id = "pin declare missing" gloss = "a tool .mcp.json launches has no plain-string pin in mise.toml at all" class = """ A reference with no authority behind it is not a pin, it is a second opinion. \ @@ -7465,12 +8038,12 @@ wearing a translation's clothes. """ [[verdict.route]] -id = "R-PIN-THE-TOOL" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-MCP-EXEC-UNSCOPED" +id = "call run loose" gloss = "a `mise exec` launch names no tool before `--`, so it provisions the whole toolchain" class = """ The regression CLOUD-316 is actually about, and the reason the pin exists at all. \ @@ -7483,12 +8056,12 @@ not silently exempt. """ [[verdict.route]] -id = "R-SCOPE-THE-EXEC" +id = "config read first" kind = "document" target = ".mcp.json" [[verdict]] -id = "V-PIN-AUTHORITY-UNREADABLE" +id = "pin read unread" gloss = "mise.toml could not be read, so no pin reference could be compared against it" class = """ Could-not-look, kept loud. `mise.toml` carries the pins, so a tree that could not \ @@ -7499,12 +8072,12 @@ is not-applicable rather than could-not-look. """ [[verdict.route]] -id = "R-RESTORE-THE-AUTHORITY" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-TASK-UNDEFINED" +id = "task name undefined" gloss = "a command row names a task this tree does not define" class = """ `mise run ` resolves from the working directory, and the runner is on PATH, \ @@ -7516,17 +8089,17 @@ nothing. """ [[verdict.route]] -id = "R-DEFINE-THE-TASK" +id = "task read first" kind = "document" target = "mise.toml" [[verdict.route]] -id = "R-NAME-A-PROGRAM-ON-PATH" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-AUTHORITY-UNPARSED" +id = "config parse broken" gloss = "the committed authority could not be parsed, so none of its rows could be judged" class = """ `batten.toml` carries the rows, so a tree that could not read it cannot have its \ @@ -7536,12 +8109,12 @@ same output. """ [[verdict.route]] -id = "R-LINT-THE-AUTHORITY" +id = "task run first" kind = "command" target = "mise run config-lint" [[verdict]] -id = "V-LAYERING-EDGE-FORBIDDEN" +id = "layer reach refused" gloss = "a module reaches one the layer table places below it" class = """ A layering this tree documents and, before this rule, nothing enforced. The edge \ @@ -7550,12 +8123,12 @@ through a re-export counts exactly as a direct one does. """ [[verdict.route]] -id = "R-MOVE-THE-DEPENDENCY" +id = "prose read first" kind = "document" target = ".serena/memories/core.md" [[verdict]] -id = "V-LAYER-UNPLACED" +id = "module place missing" gloss = "a module is judged by the layering rule and absent from its table" class = """ An unplaced module is not a module with no constraints — it is a module whose \ @@ -7564,12 +8137,12 @@ narrow the row's selector so it is honestly out of scope. """ [[verdict.route]] -id = "R-PLACE-THE-MODULE" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-LAYER-TABLE-DECIDES-NOTHING" +id = "layer table dead" gloss = "the layer table forbids no edge, so the rule is on and decides nothing" class = """ A gate that cannot refuse is off, and one that is off while reading as configured \ @@ -7578,12 +8151,12 @@ edges, or remove the row. """ [[verdict.route]] -id = "R-DECLARE-THE-EDGES" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-REMEDY-DROPPED-BY-THE-FILTER" +id = "remedy select dropped" gloss = "a stderr line inside a `>&2` block carries no `::error::` prefix, so readers drop it" class = """ `land` and every reader following this tree's filter convention drop an \ @@ -7593,12 +8166,12 @@ route. Prefix it, as `board-payloads` does for every line of its own recipe bloc """ [[verdict.route]] -id = "R-PREFIX-THE-LINE" +id = "task read first" kind = "document" target = "mise-tasks/board-payloads.sh" [[verdict]] -id = "V-REMEDY-HAS-TWO-AUTHORS" +id = "remedy own duplicate" gloss = "a task body names a bypass it does not read, so it is a second author of another gate's remedy" class = """ A second authority drifts. Measured: a caller's copy dropped one route entirely \ @@ -7609,12 +8182,12 @@ because it carries none. """ [[verdict.route]] -id = "R-POINT-AT-THE-GATE" +id = "task read first" kind = "document" target = "mise-tasks/linear-check.sh" [[verdict]] -id = "V-WORKSPACE-DEP-ORPHANED" +id = "workspace declare unused" gloss = "a `[workspace.dependencies]` entry no member references resolves to nothing" class = """ Every graph-reading gate is then green about a dependency that is not in the \ @@ -7624,12 +8197,12 @@ licence or SBOM check walks does not contain it. Reference it with \ """ [[verdict.route]] -id = "R-REFERENCE-OR-DELETE" +id = "manifest read first" kind = "document" target = "Cargo.toml" [[verdict]] -id = "V-MANIFEST-UNPARSED" +id = "manifest parse broken" gloss = "a manifest could not be parsed, so its references were never counted" class = """ An orphan cannot be ruled out over a file nobody read. Could-not-look, reported \ @@ -7637,12 +8210,12 @@ rather than collapsed into the clean answer. """ [[verdict.route]] -id = "R-LINT-THE-MANIFEST" +id = "task run first" kind = "command" target = "mise run lint:toml" [[verdict]] -id = "V-WORKSPACE-TABLE-ABSENT" +id = "workspace table absent" gloss = "no `[workspace.dependencies]` table reached the judged set, so the rule decided nothing" class = """ The vacuity arm. A rule whose input never arrived has not established that there \ @@ -7651,12 +8224,12 @@ selector so it selects the manifest, or remove the row. """ [[verdict.route]] -id = "R-SELECT-THE-MANIFEST" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-COMPLIANCE-SOURCE-UNPARSED" +id = "source parse broken" gloss = "a compliance source could not be parsed, so the checker was never judged against the evaluator" class = """ The pinned type checker and the shipped evaluator have to agree about which OPA \ @@ -7665,12 +8238,12 @@ did not parse contributes neither, which is could-not-look rather than agreement """ [[verdict.route]] -id = "R-LINT-THE-SOURCE" +id = "task run first" kind = "command" target = "mise run lint:toml" [[verdict]] -id = "V-CHECKER-AHEAD-OF-EVALUATOR" +id = "version pin ahead" gloss = "the pinned checker implements a newer OPA level than the shipped evaluator records" class = """ A rule type checked by one and evaluated by the other is a false green: the \ @@ -7680,12 +8253,12 @@ recorded level up once upstream declares it. """ [[verdict.route]] -id = "R-ALIGN-THE-PIN" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-COMPLIANCE-CLAIM-STALE" +id = "claim state stale" gloss = "the recorded compliance level was read against a different evaluator version than the one pinned" class = """ The pin then tracks a claim nobody has checked. Re-read upstream's declared level \ @@ -7694,12 +8267,12 @@ the record a measurement rather than a memory. """ [[verdict.route]] -id = "R-REREAD-AND-MOVE-BOTH" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-COMPLIANCE-DECLARATION-ABSENT" +id = "claim declare absent" gloss = "a declaration this comparison needs is absent, so there is nothing to hold the pin to" class = """ One class rather than four, distinguished by the subject naming which declaration \ @@ -7710,12 +8283,12 @@ tokens for one thing to learn. """ [[verdict.route]] -id = "R-DECLARE-IT" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-VERSION-UNREADABLE" +id = "version read unread" gloss = "a declared version is not MAJOR.MINOR, so no comparison it feeds can be trusted" class = """ The comparison is numeric, so an unparseable version silently orders wrong rather \ @@ -7723,12 +8296,12 @@ than failing. Reported at the declaration, because that is where the fix goes. """ [[verdict.route]] -id = "R-WRITE-A-MAJOR-MINOR" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-ROUTE-TASK-UNDEFINED" +id = "route name undefined" gloss = "a route offers a task this tree does not define" class = """ The worst moment for a task to be missing. A reader has just been refused, has \ @@ -7738,7 +8311,7 @@ program on PATH, which this rule deliberately leaves alone. """ [[verdict.route]] -id = "R-DEFINE-THE-ROUTED-TASK" +id = "task read first" kind = "document" target = "mise.toml" @@ -7750,10 +8323,10 @@ target = "mise.toml" # thread, or no review from anyone but the author", which was true of the `--jq` # projection that folded both into one count. The row is tool-sourced now and # counts one collection plus a page guard, so the second condition is -# `V-REVIEW-ABSENT`'s below. Leaving it named here would send a reader looking for +# `review read absent`'s below. Leaving it named here would send a reader looking for # threads on a head whose only problem is that nobody looked. [[verdict]] -id = "V-REVIEW-UNANSWERED" +id = "review answer missing" gloss = "readying would buy a CI matrix on a head carrying unresolved review threads" class = """ Readying is the event that starts CI, and nothing in `land`'s pre-ready sequence \ @@ -7768,12 +8341,12 @@ thing that can name them. """ [[verdict.route]] -id = "R-ANSWER-THE-THREADS" +id = "task run first" kind = "command" target = "resolve each thread, then read them again with pull_request_read and retry" [[verdict.route]] -id = "R-FORCE-A-REVIEW" +id = "task run other" kind = "command" target = "@coderabbitai full review" @@ -7783,7 +8356,7 @@ target = "@coderabbitai full review" # count being non-zero and this refuses on zero — and because the remedies do not # overlap: there is nothing to answer on a head nobody has looked at. [[verdict]] -id = "V-REVIEW-ABSENT" +id = "review read absent" gloss = "readying would buy a CI matrix on a head nobody has reviewed" class = """ The read found no review at all, which the thread count cannot say: a head with \ @@ -7796,12 +8369,12 @@ express (CLOUD-859 owns that half). """ [[verdict.route]] -id = "R-FORCE-A-FIRST-REVIEW" +id = "task run first" kind = "command" target = "@coderabbitai full review" [[verdict.route]] -id = "R-READ-THE-REVIEWS" +id = "task run other" kind = "command" target = "pull_request_read, method get_reviews, then retry" @@ -7809,7 +8382,7 @@ target = "pull_request_read, method get_reviews, then retry" # one, because a reader meeting an unplaced spawn and a reader meeting an absent # census have nothing to learn from each other. [[verdict]] -id = "V-SPAWN-UNPLACED" +id = "spawn place missing" gloss = "a module the adapter table does not place resolves the spawn type" class = """ The adapter table says which modules own a delegated tool. A spawn outside them \ @@ -7820,17 +8393,17 @@ rather than by spelling: a byte scan would report `surface.rs`, which imports \ """ [[verdict.route]] -id = "R-ROUTE-THROUGH-EXEC" +id = "source read first" kind = "document" target = "crates/batten/src/exec.rs" [[verdict.route]] -id = "R-PLACE-THE-ADAPTER" +id = "module read first" kind = "document" target = "policy/spawn-adapters.rego" [[verdict]] -id = "V-SYMBOL-CENSUS-ABSENT" +id = "symbol count absent" gloss = "the symbol census is absent, so no spawn was placed or refused" class = """ Could-not-look is not clean (CLOUD-251). `input.tree.symbols` is `null` both when \ @@ -7840,12 +8413,12 @@ this gate exists against. """ [[verdict.route]] -id = "R-DECLARE-THE-SYMBOL-FACT" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-ADAPTER-TABLE-EMPTY" +id = "adapter table empty" gloss = "the adapter table places no module, so this rule decides nothing" class = """ The vacuity guard. A table placing nothing cannot refuse anything, and a gate \ @@ -7853,7 +8426,7 @@ that cannot refuse is off rather than passing. """ [[verdict.route]] -id = "R-PLACE-AT-LEAST-ONE-ADAPTER" +id = "module read first" kind = "document" target = "policy/spawn-adapters.rego" @@ -7998,7 +8571,7 @@ id = "fixed-rule-ref" regex = '`data\.batten\.[a-z_]+`' [[pattern]] -id = "policy-rule-const" +id = "module read first" regex = '^const [A-Z_]+_RULE: &str = "[a-z_]+";' # CLOUD-1150 §2. The unit word is load-bearing: a rules file writes plenty of @@ -8039,50 +8612,50 @@ id = "plain-dotted-version" regex = '^[0-9]+(\.[0-9]+)*$' [[verdict]] -id = "V-RESTATED-DEFAULT-DRIFTS" +id = "default state other" gloss = "a rules file restates an env default the mechanism does not have" class = """ A stale parenthetical in a rules file is not a typo: these files are read by an agent that then acts without re-deriving, so it is a false premise delivered with the authority of the rule. The mechanism's own `${VAR:-N}` is the authority. Correct the prose, or drop the value and let the reader read it there — dropping it is always allowed, because this class never demands that a value be restated. """ [[verdict.route]] -id = "R-CORRECT-THE-RESTATED-DEFAULT" +id = "rule read first" kind = "document" target = ".claude/rules/toolchain.md" [[verdict]] -id = "V-NAMED-EVENT-UNWIRED" +id = "event wire missing" gloss = "a sentence says a task runs on a hook event nothing wires it to" class = """ Scoped to a sentence carrying "runs on", so prose stays free to record an accepted gap beside the wiring it qualifies. What cannot stand is the assertion that a task RUNS on an event `.claude/settings.json` does not wire. Either wire it, or say it is absent. """ [[verdict.route]] -id = "R-WIRE-OR-UNSAY-THE-EVENT" +id = "config read first" kind = "document" target = ".claude/settings.json" [[verdict]] -id = "V-NAMED-INPUT-KEY-UNEMITTABLE" +id = "input key dead" gloss = "a rules file names a policy input key the generated schema does not carry" class = """ CLOUD-845's defect in prose. A module copying the key reads an undefined path, Rego reads undefined as does not hold, and the deny set is empty — a dead gate and a clean tree are byte-identical. The generated schemas are the authority; correct the key or drop it. """ [[verdict.route]] -id = "R-CORRECT-THE-INPUT-KEY" +id = "config read first" kind = "document" target = "schema/policy-input.schema.json" [[verdict]] -id = "V-NAMED-FIXED-RULE-UNQUERIED" +id = "rule ask missing" gloss = "a rules file names a fixed rule the evaluator does not query" class = """ The three rule names are the query root, so a module publishing the named spelling contributes nothing to the deny set and fails nothing. `crates/batten/src/policy.rs`'s constants are the authority. Correct the name, or subscript it if the pattern table was meant. """ [[verdict.route]] -id = "R-CORRECT-THE-FIXED-RULE-NAME" +id = "source read first" kind = "document" target = "crates/batten/src/policy.rs" @@ -8118,6 +8691,6 @@ The could-not-look arm, and it is conditioned on there being a claim to judge ra """ [[verdict.route]] -id = "R-RESTORE-THE-DRIFT-AUTHORITY" +id = "task run first" kind = "command" target = "mise run rules-drift" diff --git a/completions/batten.fish b/completions/batten.fish index 5f41b7e74..f837d601f 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -1508,7 +1508,7 @@ complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_s complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -f -a "spend" -d 'Spend an issued admission against the situation it was issued for' complete -c batten -n "__fish_batten_using_subcommand override; and not __fish_seen_subcommand_from request spend help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l rule -d 'The rule whose refusal is being overridden' -r -complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l verdict -d 'The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l verdict -d 'The verdict token that refusal carries, e.g. diff ship early' -r complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l subject -d 'The gate\'s canonical subject, exactly as its refusal names it' -r complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' @@ -1533,7 +1533,7 @@ complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_ complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from request" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l admission -d 'The admission address to spend' -r complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l rule -d 'The rule whose refusal is being overridden' -r -complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l verdict -d 'The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF' -r +complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l verdict -d 'The verdict token that refusal carries, e.g. diff ship early' -r complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l subject -d 'The gate\'s canonical subject, exactly as its refusal names it' -r complete -c batten -n "__fish_batten_using_subcommand override; and __fish_seen_subcommand_from spend" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' diff --git a/completions/batten.zsh b/completions/batten.zsh index d87590d5d..884157207 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -1677,7 +1677,7 @@ trace\:"Add everything"))' \ '--yes[Confirm a destructive operation that would otherwise refuse]' \ '-h[Print help (see more with '\''--help'\'')]' \ '--help[Print help (see more with '\''--help'\'')]' \ -':token -- The verdict token to resolve, e.g. V-TASK-UNDEFINED:_default' \ +':token -- The verdict token to resolve, e.g. task name undefined:_default' \ && ret=0 ;; (help) @@ -2589,7 +2589,7 @@ trace\:"Add everything"))' \ (request) _arguments "${_arguments_options[@]}" : \ '--rule=[The rule whose refusal is being overridden]: :_default' \ -'--verdict=[The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF]: :_default' \ +'--verdict=[The verdict token that refusal carries, e.g. diff ship early]: :_default' \ '--subject=[The gate'\''s canonical subject, exactly as its refusal names it]: :_default' \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" standard\:"The default\: a finding is a violation" @@ -2622,7 +2622,7 @@ trace\:"Add everything"))' \ _arguments "${_arguments_options[@]}" : \ '--admission=[The admission address to spend]: :_default' \ '--rule=[The rule whose refusal is being overridden]: :_default' \ -'--verdict=[The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF]: :_default' \ +'--verdict=[The verdict token that refusal carries, e.g. diff ship early]: :_default' \ '--subject=[The gate'\''s canonical subject, exactly as its refusal names it]: :_default' \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" standard\:"The default\: a finding is a violation" diff --git a/crates/batten/src/admission.rs b/crates/batten/src/admission.rs index 62ac901eb..3b13d7bd4 100644 --- a/crates/batten/src/admission.rs +++ b/crates/batten/src/admission.rs @@ -347,14 +347,14 @@ fn string(text: &str) -> String { /// in front of every reviewer and review bot per-commit. A pull request body is /// worse on all three of the counts that matter here: it is mutable after the /// fact, it is one blob for N commits so the per-write binding is lost, and -/// writing it is `land.sh`'s job — authored shell frozen by `V-SHELL-RULE-EDITED`. +/// writing it is `land.sh`'s job — authored shell frozen by `shell edit refused`. /// /// # The block, and why it is verifiable with the store deleted /// /// ```text /// Admits:
/// Admits-rule: protected-mutation -/// Admits-verdict: V-PROTECTED-MUTATION +/// Admits-verdict: path write refused /// Admits-subject: .claude/rules/toolchain.md /// Admits-head: /// Admits-epoch: @@ -803,7 +803,7 @@ pub fn chain_head(repo_root: &Path, rule: &str, subject: &str) -> Result Binding { Binding { rule: "prose-only".to_owned(), - verdict: "V-PROSE-ONLY-DIFF".to_owned(), + verdict: "diff ship early".to_owned(), subject: "a.rs,b.rs".to_owned(), head: "0123456789abcdef".to_owned(), epoch: "epoch-1".to_owned(), diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index f634cc467..8e6a28d96 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -636,7 +636,7 @@ pub enum PolicyCommand { /// middle re-numbers every later discriminant and `semver` reads that as /// `enum_no_repr_variant_discriminant_changed`. Explain { - /// The token to resolve, e.g. `V-TASK-UNDEFINED`. + /// The token to resolve, e.g. `task name undefined`. token: String, /// Emit the class as byte-stable JSON instead of pointer lines. json: bool, diff --git a/crates/batten/src/commit.rs b/crates/batten/src/commit.rs index 5b9b43dd1..d4b028309 100644 --- a/crates/batten/src/commit.rs +++ b/crates/batten/src/commit.rs @@ -168,7 +168,7 @@ impl Commit { /// /// # What this closes /// -/// `V-PROTECTED-MUTATION`'s override route makes a protected write ADMISSIBLE — +/// `path write refused`'s override route makes a protected write ADMISSIBLE — /// the guarded party articulates, an admission is issued and spent, and the write /// goes through. That much landed. What it did not do is make the articulation /// legible to anyone: the record lives in a container-scoped store, so the diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 388ceec05..00534572e 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -240,6 +240,15 @@ pub struct Config { /// [`crate::verdict`]. #[serde(default, rename = "verdict", skip_serializing_if = "Vec::is_empty")] pub verdicts: Vec, + /// The three positional word lists every class and route name is drawn from + /// (CLOUD-1284), and the tokenizer pin they were measured under. + /// + /// A **dictionary rather than a per-class essay**: a name spends three words + /// and each word's meaning is declared once, so the marginal class costs no + /// new prose. `verdict::validate` holds every name to it, which is what makes + /// the naming convention a gate rather than a habit. + #[serde(default, skip_serializing_if = "crate::verdict::Vocabulary::is_empty")] + pub vocabulary: crate::verdict::Vocabulary, /// The per-path-class redirect table (CLOUD-280): what to run instead, /// keyed by what is protected rather than by the verb reaching for it. /// @@ -1042,7 +1051,7 @@ fn parse_ungated(text: &str, source: &str) -> Result { // that terminates — so it is knowable without a tree and belongs where a // config fault is reported. Registry EQUALITY against what the modules // actually emit needs the compiled bundles and lives in `policy::load`. - crate::verdict::validate(&config.verdicts)?; + crate::verdict::validate(&config.verdicts, &config.vocabulary)?; crate::redirect::validate(&config.redirects)?; // And the MCP table, at load for the identical reason (CLOUD-1260). Every // clause is a property of the TABLE — a duplicated id, a path that would @@ -1229,6 +1238,7 @@ impl Config { rules: Vec::new(), patterns: Vec::new(), verdicts: Vec::new(), + vocabulary: crate::verdict::Vocabulary::default(), scope: Vec::new(), protected: Vec::new(), // No protected paths means the unknown-program clause has nothing to diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 084ff822a..ce2cf9fe2 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -1315,7 +1315,7 @@ impl Harness { // discriminating pair over one command, one word of this list // apart: `jq --version` (which trips `pinned-toolchain-preset`, // a live `severity = "warn"` mediated row) delivered - // `PreToolUse:Bash hook additional context: … V-PIN-BYPASSED …` + // `PreToolUse:Bash hook additional context: … pin reach loose …` // to the agent with the entry present, and delivered nothing with // it absent. The call was ALLOWED both times and the exit code // never moved, which is the half that matters: the host carries a @@ -2083,7 +2083,7 @@ impl Envelope { /// and a glob anchored at `.serena/memories/` does not match a string that /// begins with a filesystem root. Measured over the shipped binary against /// this repository's own committed config: the relative spelling was refused - /// with `V-PROTECTED-MUTATION`, the absolute one was **allowed**, and a live + /// with `path write refused`, the absolute one was **allowed**, and a live /// agent `Write` to a protected path created the file. `memory-guard` retired /// into that gate (CLOUD-442), so the write shapes it denied were ungated on /// the host that sends absolute paths. @@ -2718,11 +2718,11 @@ fn cursor_specialized_input(value: &Value) -> Value { /// /// # It does not suppress the protected-path gate /// -/// One class is outside this hatch's reach: `V-PROTECTED-MUTATION` is adjudicated +/// One class is outside this hatch's reach: `path write refused` is adjudicated /// even when this is set, because it declares an override route and the boundary /// honours a spent admission for it. A refusal whose only way through is a string /// somebody knows is a password rather than a gate, and this repository already -/// retired that shape once — `V-FILED-OVER-OWN-DIFF`'s two variables were deleted +/// retired that shape once — `issue file same`'s two variables were deleted /// rather than kept beside the admission mechanism, on the stated ground that *the /// point of the admission mechanism is that the bare variable stops working*. /// @@ -3719,12 +3719,12 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // THE HATCH NO LONGER ANSWERS FOR THE PROTECTED-PATH GATE, and that is the // whole of what `BATTEN_HOOK_BYPASS` stops being able to do. // - // It was the only way through a `V-PROTECTED-MUTATION` refusal, which made + // It was the only way through a `path write refused` refusal, which made // that refusal a password: a knowable string the guarded party can set, so it // recorded nothing and stopped nobody. §8's property — "an agent's context can // never influence the rules it is judged by" — was already false, because the // agent could set the variable. This repository ruled on exactly that shape - // for `V-FILED-OVER-OWN-DIFF`: *the point of the admission mechanism is that + // for `issue file same`: *the point of the admission mechanism is that // the bare variable stops working*. The class declares an override route now // (`R-ARTICULATE-THE-WRITE`) and the boundary honours a spent admission // (`admit_mediated`), so there is a way through that leaves a record — which @@ -3952,7 +3952,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis /// stopped being single. /// # A class the hatch cannot open must not advertise it /// -/// `V-PROTECTED-MUTATION` is adjudicated under the hatch, so printing "Bypass +/// `path write refused` is adjudicated under the hatch, so printing "Bypass /// with `BATTEN_HOOK_BYPASS`=1" on its refusal would name a remedy that does /// nothing — the defect class `crate::verdict`'s own header exists to kill ("a /// refusal could name no remedy, name a task that does not exist"), reintroduced @@ -10559,7 +10559,7 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { // THE SHARED PROJECTION IS NOW TWO CLAUSES, NOT THREE, and that is a real // split rather than a weakened assertion. This used to require the hatch // sentence on every deny, which was right while the hatch reached every - // row. `V-PROTECTED-MUTATION` is adjudicated under the hatch now, so + // row. `path write refused` is adjudicated under the hatch now, so // printing it there would name a remedy that does nothing — the defect // `crate::verdict`'s header exists to kill. What every deny still owes is // a `Refused by` clause and a `Fix:` clause. @@ -10615,7 +10615,7 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { // A native refusal now names a declared class, and `verdict::validate` // refuses a class with no route and refuses one whose only route is an // override. So the third tier is no longer a generic apology: it is - // `V-PROTECTED-MUTATION`'s own `R-RESTORE-IT`. The tiering is unchanged and + // `path write refused`'s own `patch run first`. The tiering is unchanged and // is asserted by its siblings — a consumer's `[[redirect]]` still wins, and // a verb's own `redirect` still wins over the class — this is only the // floor, and the floor got a verb. @@ -10628,12 +10628,12 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { ); assert_eq!( refusal.verdict(), - Some("V-PROTECTED-MUTATION"), + Some("path write refused"), "and the refusal says which class it belongs to" ); let reason = denial_text(decision); assert!( - reason.contains("V-PROTECTED-MUTATION ("), + reason.contains("path write refused ("), "the hot path leads with the token and its gloss: {reason}" ); assert!( @@ -11862,7 +11862,7 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { /// this is its regression guard. Measured 2026-08-29 as a discriminating pair /// over one command, one word of `delivered_on` apart: `jq --version` — which /// trips the live `severity = "warn"` `pinned-toolchain-preset` row — - /// delivered `PreToolUse:Bash hook additional context: … V-PIN-BYPASSED …` + /// delivered `PreToolUse:Bash hook additional context: … pin reach loose …` /// with the entry present and nothing with it absent, and was ALLOWED both /// times. /// diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 2d686454d..188d6833c 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -4960,10 +4960,10 @@ fn run_hook( /// /// Not a decision — a gap. `adjudicate` is pure by contract, so the deny site /// cannot read a store; and `Refusal` carried no subject, so even at the boundary -/// there was nothing to bind. The consequence was that `V-PROTECTED-MUTATION` — +/// there was nothing to bind. The consequence was that `path write refused` — /// the class most in need of an audited way through, because the surface it names /// as the remedy IS the file it refuses — had only the bare environment variable. -/// This repository already ruled that shape out for `V-FILED-OVER-OWN-DIFF`: *the +/// This repository already ruled that shape out for `issue file same`: *the /// point of the admission mechanism is that the bare variable stops working*. /// /// # What it will not do diff --git a/crates/batten/src/perf.rs b/crates/batten/src/perf.rs index 134bd0a25..c543312ff 100644 --- a/crates/batten/src/perf.rs +++ b/crates/batten/src/perf.rs @@ -906,7 +906,7 @@ fn summarise( // whose whole subject is what an EXTERNAL process costs, so the spawns are the // thing rather than an implementation of it. That table places `perf` and is a // protected path; a sibling module would be an unplaced spawning module and -// `V-SPAWN-UNPLACED` would refuse it. Sharing the module is also what stops a +// `spawn place missing` would refuse it. Sharing the module is also what stops a // second percentile convention, a second record shape and a second hyperfine // invocation from existing — `perf-compare`'s reading is a contract, and two // spellings of it can disagree. diff --git a/crates/batten/src/policy/presets/ci-hygiene/spend-is-authorised.rego b/crates/batten/src/policy/presets/ci-hygiene/spend-is-authorised.rego index 9695d2575..65bf5df4e 100644 --- a/crates/batten/src/policy/presets/ci-hygiene/spend-is-authorised.rego +++ b/crates/batten/src/policy/presets/ci-hygiene/spend-is-authorised.rego @@ -112,7 +112,7 @@ job_is_draft_gated(path, name) if { violation contains { "rule": "no-job-runs-on-a-draft", - "verdict": "V-JOB-RUNS-ON-DRAFT", + "verdict": "job run early", "subjects": [{"path": path}, {"artifact": name}], } if { some path, _ in workflow @@ -131,7 +131,7 @@ supersedes_itself(path) if workflow[path].concurrency["cancel-in-progress"] == t violation contains { "rule": "pull-request-workflow-supersedes-itself", - "verdict": "V-PR-WORKFLOW-NOT-SUPERSEDED", + "verdict": "workflow run twice", "subjects": [{"path": path}], } if { some path, _ in workflow @@ -172,7 +172,7 @@ races_itself(path) if { violation contains { "rule": "workflow-declares-a-concurrency-group", - "verdict": "V-WORKFLOW-NO-CONCURRENCY", + "verdict": "workflow declare missing", "subjects": [{"path": path}], } if { some path, _ in workflow @@ -197,7 +197,7 @@ subscribes_to_ready(path) if { violation contains { "rule": "draft-gated-workflow-subscribes-to-ready", - "verdict": "V-READY-FOR-REVIEW-UNSUBSCRIBED", + "verdict": "review watch missing", "subjects": [{"path": path}], } if { some path, _ in workflow @@ -240,7 +240,7 @@ test_an_ungated_job_is_refused_and_named if { found := violation with input as with_workflow(doc) count(found) == 1 some f in found - f.verdict == "V-JOB-RUNS-ON-DRAFT" + f.verdict == "job run early" some s in f.subjects s.artifact == "build" } @@ -267,7 +267,7 @@ test_a_workflow_that_never_supersedes_is_refused if { found := violation with input as with_workflow(doc) count(found) == 1 some f in found - f.verdict == "V-PR-WORKFLOW-NOT-SUPERSEDED" + f.verdict == "workflow run twice" } # THE STRING SPELLING IS NOT THE VALUE. A parsed `true` is a boolean, so a module @@ -277,7 +277,7 @@ test_a_quoted_cancel_flag_is_not_the_boolean if { found := violation with input as with_workflow(doc) count(found) == 1 some f in found - f.verdict == "V-PR-WORKFLOW-NOT-SUPERSEDED" + f.verdict == "workflow run twice" } test_a_workflow_with_no_concurrency_at_all_is_refused if { @@ -288,7 +288,7 @@ test_a_workflow_with_no_concurrency_at_all_is_refused if { found := violation with input as with_workflow(doc) count(found) == 1 some f in found - f.verdict == "V-WORKFLOW-NO-CONCURRENCY" + f.verdict == "workflow declare missing" } # A SCHEDULED WORKFLOW IS NOT ASKED TO CANCEL ITSELF, which is the whole reason @@ -307,7 +307,7 @@ test_a_draft_gated_workflow_missing_ready_for_review_is_refused if { found := violation with input as with_workflow(doc) count(found) == 1 some f in found - f.verdict == "V-READY-FOR-REVIEW-UNSUBSCRIBED" + f.verdict == "review watch missing" } # NOT DRAFT-GATED, NOT ASKED. A workflow whose jobs run on a draft has no skipped @@ -321,7 +321,7 @@ test_a_workflow_that_does_not_draft_gate_is_not_asked_for_ready if { } found := violation with input as with_workflow(doc) every f in found { - f.verdict != "V-READY-FOR-REVIEW-UNSUBSCRIBED" + f.verdict != "review watch missing" } } diff --git a/crates/batten/src/policy/presets/ci-hygiene/wiring-can-be-reached.rego b/crates/batten/src/policy/presets/ci-hygiene/wiring-can-be-reached.rego index 49bd16d4d..446a922b0 100644 --- a/crates/batten/src/policy/presets/ci-hygiene/wiring-can-be-reached.rego +++ b/crates/batten/src/policy/presets/ci-hygiene/wiring-can-be-reached.rego @@ -71,7 +71,7 @@ trigger_filters_branches(path) if _ := triggers(path).workflow_run.branches violation contains { "rule": "workflow-run-filters-at-the-trigger", - "verdict": "V-WORKFLOW-RUN-UNSCOPED", + "verdict": "workflow run loose", "subjects": [{"path": path}], } if { some path, _ in workflow @@ -93,7 +93,7 @@ violation contains { violation contains { "rule": "comment-trigger-is-anchored", - "verdict": "V-COMMENT-TRIGGER-UNANCHORED", + "verdict": "event bind loose", "subjects": [{"path": path}], } if { some path, _ in workflow @@ -123,7 +123,7 @@ reads_draft_state(path) if { violation contains { "rule": "comment-merge-reads-draft-state", - "verdict": "V-COMMENT-MERGE-IGNORES-DRAFT", + "verdict": "merge run early", "subjects": [{"path": path}], } if { some path, _ in workflow @@ -157,7 +157,7 @@ admits(path, "workflow_run") if contains(job_conditions[path], "github.event.wor violation contains { "rule": "declared-trigger-reaches-a-job", - "verdict": "V-TRIGGER-REACHES-NO-JOB", + "verdict": "event reach dead", "subjects": [{"path": path}, {"artifact": trigger}], } if { some path, _ in workflow @@ -187,7 +187,7 @@ colliding contains expr if { violation contains { "rule": "schedules-do-not-collide", - "verdict": "V-CRON-COLLISION", + "verdict": "job start same", "subjects": [{"path": path}, {"artifact": expr}], } if { some expr in colliding @@ -219,7 +219,7 @@ names_the_dependency(path, name, dep) if contains(job_body(path, name), sprintf( violation contains { "rule": "fan-in-asserts-its-whole-needs", - "verdict": "V-FANIN-NEEDS-UNASSERTED", + "verdict": "job require unseen", "subjects": [{"path": path}, {"artifact": dep}], } if { some path, _ in workflow @@ -266,7 +266,7 @@ guarded_on_cache_hit(path, name, index) if { violation contains { "rule": "cache-warm-compile-is-guarded", - "verdict": "V-WARM-COMPILE-UNGUARDED", + "verdict": "cache build loose", "subjects": [{"path": path}, {"artifact": name}], } if { some path, _ in workflow @@ -303,7 +303,7 @@ step_id_exists(path, id) if { violation contains { "rule": "cache-warm-compile-is-guarded", - "verdict": "V-WARM-GUARD-NAMES-MISSING-ID", + "verdict": "cache name unknown", "subjects": [{"path": path}, {"artifact": id}], } if { some path, _ in workflow @@ -357,7 +357,7 @@ swallowed(line) if { violation contains { "rule": "interpolation-is-not-swallowed", - "verdict": "V-INTERPOLATION-SWALLOWED", + "verdict": "input render dropped", "subjects": [{"path": path, "line": number}], } if { some path, lines in input.tree.lines @@ -381,7 +381,7 @@ test_a_workflow_run_scoped_only_in_a_job_condition_is_refused if { } found := violation with input as wf(doc) some f in found - f.verdict == "V-WORKFLOW-RUN-UNSCOPED" + f.verdict == "workflow run loose" } test_a_trigger_level_branches_filter_satisfies_it if { @@ -395,7 +395,7 @@ test_a_trigger_level_branches_filter_satisfies_it if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-WORKFLOW-RUN-UNSCOPED" + f.verdict != "workflow run loose" } } @@ -409,7 +409,7 @@ test_a_workflow_run_with_no_branch_condition_is_not_asked if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-WORKFLOW-RUN-UNSCOPED" + f.verdict != "workflow run loose" } } @@ -421,7 +421,7 @@ test_an_unanchored_comment_predicate_is_refused if { } found := violation with input as wf(doc) some f in found - f.verdict == "V-COMMENT-TRIGGER-UNANCHORED" + f.verdict == "event bind loose" } test_an_anchored_comment_predicate_passes if { @@ -432,7 +432,7 @@ test_an_anchored_comment_predicate_passes if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-COMMENT-TRIGGER-UNANCHORED" + f.verdict != "event bind loose" } } @@ -447,7 +447,7 @@ test_a_comment_merge_that_ignores_draft_state_is_refused if { } found := violation with input as wf(doc) some f in found - f.verdict == "V-COMMENT-MERGE-IGNORES-DRAFT" + f.verdict == "merge run early" } # A comment-triggered workflow that does NOT merge is not asked the draft @@ -463,7 +463,7 @@ test_a_comment_workflow_that_does_not_merge_is_not_asked if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-COMMENT-MERGE-IGNORES-DRAFT" + f.verdict != "merge run early" } } @@ -475,7 +475,7 @@ test_a_trigger_no_condition_admits_is_refused_and_named if { } found := violation with input as wf(doc) some f in found - f.verdict == "V-TRIGGER-REACHES-NO-JOB" + f.verdict == "event reach dead" some s in f.subjects s.artifact == "workflow_dispatch" } @@ -491,7 +491,7 @@ test_a_workflow_admitting_both_triggers_passes if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-TRIGGER-REACHES-NO-JOB" + f.verdict != "event reach dead" } } @@ -505,7 +505,7 @@ test_a_condition_mentioning_no_event_is_not_judged if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-TRIGGER-REACHES-NO-JOB" + f.verdict != "event reach dead" } } @@ -525,7 +525,7 @@ test_two_workflows_sharing_a_cron_are_refused if { found := violation with input as {"tree": {"documents": docs}} count({p | some f in found - f.verdict == "V-CRON-COLLISION" + f.verdict == "job start same" some s in f.subjects p := s.path }) == 2 @@ -546,7 +546,7 @@ test_a_staggered_pair_passes if { } found := violation with input as {"tree": {"documents": docs}} every f in found { - f.verdict != "V-CRON-COLLISION" + f.verdict != "job start same" } } @@ -568,7 +568,7 @@ test_an_overlapping_but_distinct_expression_is_not_a_collision if { } found := violation with input as {"tree": {"documents": docs}} every f in found { - f.verdict != "V-CRON-COLLISION" + f.verdict != "job start same" } } @@ -584,7 +584,7 @@ test_a_fan_in_that_enumerates_only_some_of_its_needs_is_refused if { } found := violation with input as wf(doc) some f in found - f.verdict == "V-FANIN-NEEDS-UNASSERTED" + f.verdict == "job require unseen" some s in f.subjects s.artifact == "b" } @@ -602,7 +602,7 @@ test_a_fan_in_asserting_over_the_whole_set_passes if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-FANIN-NEEDS-UNASSERTED" + f.verdict != "job require unseen" } } @@ -616,7 +616,7 @@ test_a_job_that_names_no_dependency_is_not_judged if { } found := violation with input as wf(doc) every f in found { - f.verdict != "V-FANIN-NEEDS-UNASSERTED" + f.verdict != "job require unseen" } } @@ -631,7 +631,7 @@ test_an_unguarded_cache_warm_compile_is_refused if { } found := violation with input as wf(doc) some f in found - f.verdict == "V-WARM-COMPILE-UNGUARDED" + f.verdict == "cache build loose" } test_a_guarded_cache_warm_compile_passes if { @@ -660,7 +660,7 @@ test_a_guard_naming_a_missing_step_id_is_refused if { } found := violation with input as wf(doc) some f in found - f.verdict == "V-WARM-GUARD-NAMES-MISSING-ID" + f.verdict == "cache name unknown" some s in f.subjects s.artifact == "cache" } @@ -684,7 +684,7 @@ test_an_unquoted_hash_that_swallows_an_interpolation_is_refused if { ]}}} count(found) == 1 some f in found - f.verdict == "V-INTERPOLATION-SWALLOWED" + f.verdict == "input render dropped" some s in f.subjects s.line == 2 } diff --git a/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego b/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego index b2992c0de..c6e43d0d4 100644 --- a/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego +++ b/crates/batten/src/policy/presets/commit-hygiene/no-empty-commit.rego @@ -17,7 +17,7 @@ rules contains "no-empty-commit" violation contains { "rule": "no-empty-commit", - "verdict": "V-EMPTY-COMMIT", + "verdict": "commit ship empty", } if { # PER SEGMENT (CLOUD-857), the identical anchoring defect its sibling # `no-force-push` carried: `split(input.call.command, " ")` asks about the diff --git a/crates/batten/src/policy/presets/landing-loop/graded-head-is-not-regraded.rego b/crates/batten/src/policy/presets/landing-loop/graded-head-is-not-regraded.rego index 5b953ad1b..d9f687e7c 100644 --- a/crates/batten/src/policy/presets/landing-loop/graded-head-is-not-regraded.rego +++ b/crates/batten/src/policy/presets/landing-loop/graded-head-is-not-regraded.rego @@ -73,7 +73,7 @@ graded contains sha if { # than mysterious. Never a check body, never a fetched payload. violation contains { "rule": "graded-head-is-not-regraded", - "verdict": "V-GRADED-HEAD-REGRADED", + "verdict": "head grade twice", "subjects": [{"artifact": sha}], } if { some sha in graded @@ -91,7 +91,7 @@ recorded(checks) := {"tree": {"forge": {"1111111": checks}}} test_a_judged_commit_is_refused if { some v in violation with input as recorded({"final": "success"}) - v.verdict == "V-GRADED-HEAD-REGRADED" + v.verdict == "head grade twice" } # A red verdict is still a verdict, and re-running it is still spend. The diff --git a/crates/batten/src/policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego b/crates/batten/src/policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego index ba40e9426..09bf19870 100644 --- a/crates/batten/src/policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego +++ b/crates/batten/src/policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego @@ -49,7 +49,7 @@ provided contains name if { # exists to prevent. violation contains { "rule": "pinned-program-via-the-pin", - "verdict": "V-PIN-BYPASSED", + "verdict": "pin reach loose", "subjects": [{"artifact": entry.name}], } if { some entry in input.call.programs diff --git a/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego b/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego index 8ec44f5c5..f74748145 100644 --- a/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego +++ b/crates/batten/src/policy/presets/shell-hygiene/shebang-names-its-language.rego @@ -47,7 +47,7 @@ names_shell(path) if endswith(path, ".bash") violation contains { "rule": "shebang-names-its-language", - "verdict": "V-SHEBANG-UNNAMED-LANGUAGE", + "verdict": "program name unnamed", "subjects": [{"path": path}], } if { some path, _ in input.tree.lines diff --git a/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego b/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego index 506411cad..d22dddb62 100644 --- a/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego +++ b/crates/batten/src/policy/presets/shell-hygiene/sibling-resolves.rego @@ -140,7 +140,7 @@ violation contains { # because that is where the fix goes; the path it computed comes second # because that is what the reader has to reconcile. Reversing them would send # a reader to a file that does not exist. - "verdict": "V-SIBLING-UNRESOLVED", + "verdict": "program resolve missing", "subjects": [{"path": path}, {"path": resolved}], } if { some path, _ in input.tree.lines diff --git a/crates/batten/src/policy/presets/trunk-based/no-force-push.rego b/crates/batten/src/policy/presets/trunk-based/no-force-push.rego index dd494035a..603a5c9d5 100644 --- a/crates/batten/src/policy/presets/trunk-based/no-force-push.rego +++ b/crates/batten/src/policy/presets/trunk-based/no-force-push.rego @@ -18,7 +18,7 @@ rules contains "no-force-push" violation contains { "rule": "no-force-push", - "verdict": "V-FORCE-PUSH-AT-TRUNK", + "verdict": "trunk push forced", } if { # PER SEGMENT, NOT PER LINE (CLOUD-857). This read # `split(input.call.command, " ")` and anchored `words[0] == "git"` over the diff --git a/crates/batten/src/ready.rs b/crates/batten/src/ready.rs index b44613137..e1f13f040 100644 --- a/crates/batten/src/ready.rs +++ b/crates/batten/src/ready.rs @@ -788,7 +788,7 @@ fn check_bump( // // **The consumer is not touched, and that is the point rather than a // shortcut.** `graph-check.sh` keys its exemption on the literal `none`; it - // is a governed shell rule that cannot retire, so `V-SHELL-RULE-EDITED` + // is a governed shell rule that cannot retire, so `shell edit refused` // refuses any edit to it with one route and no override. Changing which rows // the producer spends that token on fixes the contradiction with the consumer // byte-unchanged — which also makes its unedited suite the evidence that the @@ -960,7 +960,7 @@ fn check_claims( /// The question is not dropped, it is somewhere better: `batten.toml`'s /// `command-task-defined` row already decides whether a named task exists, over /// the consumer's own declaration of where tasks live, and raises -/// `V-TASK-UNDEFINED` with `R-DEFINE-THE-TASK`. Re-deriving it here would be a +/// `task name undefined` with `task read first`. Re-deriving it here would be a /// second authority over one fact with only the newer one deciding — CLOUD-351's /// class — on top of the rule 1 violation. /// diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index eb18b70c1..05569b552 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -341,9 +341,9 @@ mod tests { }], Fix::None, ); - assert_eq!(refusal.verdict(), Some("V-SCANNER-UNPROVISIONED")); + assert_eq!(refusal.verdict(), Some("scanner install missing")); assert!( - refusal.reason().starts_with("V-SCANNER-UNPROVISIONED ("), + refusal.reason().starts_with("scanner install missing ("), "the hot path leads with the token: {}", refusal.reason() ); diff --git a/crates/batten/src/semver.rs b/crates/batten/src/semver.rs index 3082e99f2..df8b30ae2 100644 --- a/crates/batten/src/semver.rs +++ b/crates/batten/src/semver.rs @@ -2,7 +2,7 @@ //! //! Ported from `mise-tasks/semver.sh` under CLOUD-1059, which is the rule //! working on its author: repairing that gate meant editing it, an edit is -//! `V-SHELL-RULE-EDITED`, and that verdict declares no override route. So the +//! `shell edit refused`, and that verdict declares no override route. So the //! maintenance was completed by migrating it, which is the campaign's whole //! claim. //! diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index 6f07da362..d677f6021 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -794,7 +794,7 @@ const VERDICT_TOKEN: FlagDecl = FlagDecl { id: "token", long: None, short: None, - help: "The verdict token to resolve, e.g. V-TASK-UNDEFINED", + help: "The verdict token to resolve, e.g. task name undefined", env: EnvDecl::None, global: false, positional: true, @@ -926,7 +926,7 @@ const OVERRIDE_VERDICT: FlagDecl = FlagDecl { id: "verdict", long: Some("verdict"), short: None, - help: "The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF", + help: "The verdict token that refusal carries, e.g. diff ship early", env: EnvDecl::None, global: false, positional: false, diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index c030831b2..526752c13 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -685,6 +685,16 @@ pub enum WeakeningKind { /// to make; that a class went from having no hatch to having one is a /// predicate. VerdictOverrideAdded, + /// A declared `[vocabulary]` is gone, so the naming grammar stopped applying. + /// + /// The grammar is opt-in (`verdict::validate`'s own note says why: a consumer + /// with no lists cannot satisfy membership). That makes DELETING the table a + /// weakening in the one direction that matters — arms 1, 2 and 5 stop + /// deciding anything, and every name becomes free text again, silently and + /// with the config still loading clean. Shrinking the lists is NOT reported: + /// a word a name still spends fails the load on its own, so the dangerous + /// case is the whole table going away rather than part of it. + VocabularyAbandoned, /// A `[[marker]]` row is gone, so its suppressions stop being counted. MarkerRemoved, /// An `[[exec_pattern]]` row is gone, so a lying exit `0` carrying it stops @@ -843,6 +853,7 @@ impl WeakeningKind { WeakeningKind::VerbRemoved => "verb-removed", WeakeningKind::PatternRemoved => "pattern-removed", WeakeningKind::VerdictOverrideAdded => "verdict-override-added", + WeakeningKind::VocabularyAbandoned => "vocabulary-abandoned", WeakeningKind::FactCommandChanged => "fact-command-changed", WeakeningKind::FactReturnsLoosened => "fact-returns-loosened", WeakeningKind::FactCountingChanged => "fact-counting-changed", @@ -981,6 +992,10 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "verdicts", coverage: Coverage::Compared(&[WeakeningKind::VerdictOverrideAdded]), }, + FieldCoverage { + field: "vocabulary", + coverage: Coverage::Compared(&[WeakeningKind::VocabularyAbandoned]), + }, FieldCoverage { field: "facts", coverage: Coverage::Compared(&[ @@ -1621,6 +1636,17 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { )); } + // The naming grammar (CLOUD-1284). Declared-then-absent only, per + // `VocabularyAbandoned`. + if !base.vocabulary.is_empty() && working.vocabulary.is_empty() { + found.push(Weakening { + kind: WeakeningKind::VocabularyAbandoned, + key: "vocabulary".to_owned(), + base: "declared".to_owned(), + working: "absent".to_owned(), + }); + } + // The agent-sourced facts (CLOUD-776). Removal is reported and is a // tightening; a CHANGED command is the dangerous direction, because the same // string is both what the agent is told to run and what the record is checked diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index db96bc75f..6930a7862 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -50,16 +50,19 @@ use serde::{Deserialize, Serialize}; use crate::error::UsageError; -/// The prefix every verdict token carries. -/// -/// A token has to be recognisable **as a token** in a line of output that also -/// carries a rule id and a path, and a fixed prefix is what makes that free -/// rather than a convention a reader has to know. `R-` is its sibling for -/// routes. -pub const VERDICT_PREFIX: &str = "V-"; - -/// The prefix every route id carries. See [`VERDICT_PREFIX`]. -pub const ROUTE_PREFIX: &str = "R-"; +// THE `V-` AND `R-` PREFIXES ARE GONE (CLOUD-1284). +// +// They existed to make a token recognisable AS a token in a line that also +// carries a rule id and a path, and a fixed prefix bought that without a +// convention a reader had to know. The three-word grammar buys the same thing +// and more cheaply: fixed arity is what separates the name from the pointers, +// so the prefix was paying tokens for a job the arity now does for free. +// +// Measured over all 130 classes with `tiktoken` `o200k_base`, and the prefix is +// most of the bill rather than a rounding error: `V-SCREAMING-KEBAB` costs 9.9 +// tokens on average against a curated three-word name's 3.0, and the drop from +// `V-` plus the uppercase run alone is 9.9 -> 5.2. At ~300 refusals a session +// that is ~2,000 tokens the agent used to pay for a sigil. /// The longest a gloss may be. /// @@ -108,9 +111,9 @@ impl RouteKind { #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct Route { - /// The id a rendered refusal names, e.g. `R-DEFINE-THE-TASK`. + /// The id a rendered refusal names, e.g. `task read first`. /// - /// Stable and referenceable: an agent told `R-DEFINE-THE-TASK` twice has + /// Stable and referenceable: an agent told `task read first` twice has /// been told the same thing twice, which a paraphrase cannot establish. pub id: String, /// Which kind of way out this is. @@ -124,11 +127,103 @@ pub struct Route { pub precondition: Option, } +/// One declared vocabulary word: the spelling, and what it means in a name. +/// +/// The gloss is what makes dropping the per-class essay from the hot path safe +/// rather than merely cheap (CLOUD-1284). A class used to buy a new essay to +/// explain its own free-text name; a word buys one gloss that every name using +/// it reuses, so the *marginal* class costs no new prose at all. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct VocabularyWord { + /// The spelling, as it appears in a name. One token under the declared pin. + pub word: String, + /// What this word contributes to a name that uses it. + pub gloss: String, +} + +/// The three positional lists a name is drawn from, and the pin they were +/// measured under (CLOUD-1284). +/// +/// # Why the middle list is `action` and not `verb` +/// +/// The grammar is ` ` and the issue writes the +/// middle slot as "verb". The config key cannot be `verb`: `[[verb]]` is already +/// this config's table of mutating **shell** verbs, and two unrelated tables one +/// letter apart is the drift a reader pays for every time. The prose keeps the +/// grammatical word; the key states which table it belongs to. +/// +/// # Why the pin is data and not a constant +/// +/// The token counts are model-specific, and `bench/tokens/method.toml` already +/// sets this repository's discipline for a constant a published figure depends +/// on: state it with its source and the date it was read, so a reader checks the +/// arithmetic against the primary rather than trusting a program. The *ratio* +/// argument survives a different tokenizer — common English words are +/// single-merge in every modern BPE vocabulary — but the exact integer does not, +/// so the integer's provenance travels with it. +#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Vocabulary { + /// The encoding every word's token count was measured under. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokenizer: Option, + /// Where that encoding is published. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokenizer_source: Option, + /// When it was read. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokenizer_retrieved: Option, + /// Slot 1: what the finding is about. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subject: Vec, + /// Slot 2: what was done, or what relation is being judged. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub action: Vec, + /// Slot 3: the state that makes it a refusal. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub condition: Vec, +} + +impl Vocabulary { + /// The list for one slot, by position. + fn slot(&self, position: usize) -> &[VocabularyWord] { + match position { + 0 => &self.subject, + 1 => &self.action, + _ => &self.condition, + } + } + + /// Whether this vocabulary declares anything at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.subject.is_empty() && self.action.is_empty() && self.condition.is_empty() + } + + /// Every declared word, with the slot it was declared in. + fn words(&self) -> impl Iterator { + (0..SLOTS).flat_map(move |slot| self.slot(slot).iter().map(move |word| (slot, word))) + } +} + +/// The name of each slot, for a refusal that has to say which one failed. +const SLOT_NAMES: [&str; SLOTS] = ["subject", "action", "condition"]; + +/// Fixed arity, and it is load-bearing rather than stylistic (CLOUD-1284). +/// +/// **Exactly three, never `<= N`.** Fixed arity is what lets ` ` +/// parse on one line with no delimiter between the class and the first pointer: +/// a reader — human or machine — takes three words and everything after them is +/// pointers. Relax it and the line needs a separator, which is the free-text +/// namespace this grammar replaced, wearing a delimiter. +const SLOTS: usize = 3; + /// One declared refusal class. #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] #[serde(deny_unknown_fields)] pub struct DeclaredVerdict { - /// The token, e.g. `V-TASK-UNDEFINED`. + /// The token, e.g. `task name undefined`. pub id: String, /// One line, the hot path's whole payload. pub gloss: String, @@ -285,7 +380,7 @@ pub fn render_subjects(subjects: &[Subject]) -> String { /// (CLOUD-1053). /// /// ```text -/// V-TASK-UNDEFINED (a command row names a task this tree does not define) batten.toml:1604 +/// task name undefined (a command row names a task this tree does not define) batten.toml:1604 /// ``` /// /// **The subject stays inline** rather than being dereferenced through @@ -371,30 +466,168 @@ pub fn first_command_route<'a>(registry: &'a [DeclaredVerdict], token: &str) -> /// A [`UsageError`] (exit `1`) naming the offending token. The declaration is /// the config author's own text — the class `config show` exists to echo — so /// naming it is inside rule 4. -pub fn validate(verdicts: &[DeclaredVerdict]) -> anyhow::Result<()> { +pub fn validate(verdicts: &[DeclaredVerdict], vocabulary: &Vocabulary) -> anyhow::Result<()> { + validate_vocabulary(vocabulary)?; + // THE GRAMMAR IS OPT-IN, AND THAT IS THE SAME EXEMPTION `[[pattern]]` MAKES. + // + // A consumer who declares no `[vocabulary]` has no lists for a name to be + // drawn from, so holding their classes to membership would refuse every + // config that has not adopted the grammar — the wrongly-refusing gate + // AGENTS.md calls a defect, with no fix available short of authoring 130 + // words. `crate::pattern`'s preset exemption is the landed precedent for + // exactly this shape: a demand a consumer cannot satisfy is unsatisfiable + // rather than strict. + // + // Declaring the table is what opts in, and it is all-or-nothing from there: + // every class and every route is held, and arm 5 refuses a word nothing + // spends. So the exemption cannot be spent as a partial adoption, which is + // the direction that would let it rot. + let grammar = if vocabulary.is_empty() { + None + } else { + Some(vocabulary) + }; let mut seen: BTreeSet<&str> = BTreeSet::new(); + // Arm 5's evidence, gathered while walking rather than by a second pass: a + // word is used if some class or route name spends it in its own slot. + let mut used: BTreeSet<(usize, &str)> = BTreeSet::new(); for verdict in verdicts { - validate_one(verdict)?; + validate_one(verdict, grammar, &mut used)?; + // ARM 3, uniqueness of the TRIPLE — and it is the duplicate-id refusal + // unchanged, because under this grammar the id IS the triple. There is no + // second uniqueness question to ask. if !seen.insert(verdict.id.as_str()) { return Err(UsageError::raise(format!( - "verdict `{}` is declared twice; one class, one token — \ + "verdict `{}` is declared twice; one class, one name — \ `batten policy explain {}` cannot resolve to two definitions", verdict.id, verdict.id ))); } } + if grammar.is_some() { + validate_no_orphan_words(vocabulary, &used)?; + } validate_chains(verdicts, &seen) } -/// The per-entry half of [`validate`]. -fn validate_one(verdict: &DeclaredVerdict) -> anyhow::Result<()> { - let id = verdict.id.as_str(); - if !id.starts_with(VERDICT_PREFIX) || id.len() <= VERDICT_PREFIX.len() { +/// ARM 6, and the shape arms over the vocabulary table itself. +/// +/// Separate from [`validate_one`] because these decide the DICTIONARY, not a +/// name that spends it — the same division [`crate::pattern::validate`] draws +/// between a pattern registry and the rows that cite it. +fn validate_vocabulary(vocabulary: &Vocabulary) -> anyhow::Result<()> { + let mut seen: BTreeSet<(usize, &str)> = BTreeSet::new(); + for (slot, entry) in vocabulary.words() { + let word = entry.word.as_str(); + let name = SLOT_NAMES[slot]; + if word.is_empty() || word.split_whitespace().count() != 1 { + return Err(UsageError::raise(format!( + "vocabulary `{name}`: `{word}` is not a single word — a slot holds one \ + word, and a spelling carrying a space would make a three-word name \ + parse as four" + ))); + } + if word.chars().any(|c| !c.is_ascii_lowercase()) { + return Err(UsageError::raise(format!( + "vocabulary `{name}`: `{word}` is not lowercase ASCII — the measured cost \ + of this grammar is a property of the spelling, and an uppercase run is \ + the worst case for a BPE vocabulary trained on running text" + ))); + } + // ARM 6. A word with no gloss is the free-text namespace back again: the + // dictionary is what lets a reader take `task spelling weakened` with no + // lookup, and a word that explains nothing explains nothing three hundred + // times over. + if entry.gloss.trim().is_empty() { + return Err(UsageError::raise(format!( + "vocabulary `{name}`: `{word}` carries no gloss — the dictionary is what \ + replaced the per-class essay, so a word that means nothing declared makes \ + every name spending it unreadable" + ))); + } + if !seen.insert((slot, word)) { + return Err(UsageError::raise(format!( + "vocabulary `{name}`: `{word}` is declared twice; one word, one meaning" + ))); + } + } + Ok(()) +} + +/// ARM 5: a word no name spends fails the load. +/// +/// The mirror of the landed rule that a `[[verdict]]` row nothing raises fails +/// the load, one level down. Dead vocabulary reads as available headroom while +/// nothing has ever walked it, which is the same defect as a class no gate +/// reaches — and the headroom argument for three 64-word lists only holds if the +/// lists are honest about what is in them. +fn validate_no_orphan_words( + vocabulary: &Vocabulary, + used: &BTreeSet<(usize, &str)>, +) -> anyhow::Result<()> { + for (slot, entry) in vocabulary.words() { + if !used.contains(&(slot, entry.word.as_str())) { + return Err(UsageError::raise(format!( + "vocabulary `{}`: `{}` is declared and no class or route name spends it — \ + a word nothing uses is dead vocabulary, which reads as headroom while \ + nothing has walked it", + SLOT_NAMES[slot], entry.word + ))); + } + } + Ok(()) +} + +/// ARMS 1 and 2 over one name: exact arity, then membership per position. +/// +/// `kind` names what is being judged (`verdict` or a verdict's `route`) so the +/// refusal points at the right table. +fn check_name<'a>( + kind: &str, + name: &'a str, + vocabulary: &Vocabulary, + used: &mut BTreeSet<(usize, &'a str)>, +) -> anyhow::Result<()> { + let words: Vec<&str> = name.split(' ').collect(); + // ARM 1. + if words.len() != SLOTS { return Err(UsageError::raise(format!( - "verdict `{id}`: a token is `{VERDICT_PREFIX}` followed by a name — \ - the prefix is what makes it readable as a token beside a rule id and a path" + "{kind} `{name}` is {} words — a name is exactly {SLOTS}, \ + ` `. The arity is fixed rather than a maximum \ + because it is what lets a rendered line be read as a name followed by \ + pointers with nothing separating them", + words.len() ))); } + // ARM 2. + for (slot, word) in words.iter().enumerate() { + let declared = vocabulary + .slot(slot) + .iter() + .any(|entry| entry.word == *word); + if !declared { + return Err(UsageError::raise(format!( + "{kind} `{name}`: `{word}` is not in the declared `{}` list — \ + a name is drawn from the vocabulary, which is what makes position \ + carry meaning and a new name cost no new prose", + SLOT_NAMES[slot] + ))); + } + used.insert((slot, word)); + } + Ok(()) +} + +/// The per-entry half of [`validate`]. +fn validate_one<'a>( + verdict: &'a DeclaredVerdict, + grammar: Option<&Vocabulary>, + used: &mut BTreeSet<(usize, &'a str)>, +) -> anyhow::Result<()> { + let id = verdict.id.as_str(); + if let Some(vocabulary) = grammar { + check_name("verdict", id, vocabulary, used)?; + } if verdict.gloss.trim().is_empty() { return Err(UsageError::raise(format!( "verdict `{id}`: `gloss` is the hot path's whole payload, and an empty one \ @@ -457,7 +690,7 @@ fn validate_one(verdict: &DeclaredVerdict) -> anyhow::Result<()> { } let mut route_ids: BTreeSet<&str> = BTreeSet::new(); for route in &verdict.routes { - validate_route(id, route)?; + validate_route(id, route, grammar, used)?; if !route_ids.insert(route.id.as_str()) { return Err(UsageError::raise(format!( "verdict `{id}` declares the route `{}` twice; a route id is what a \ @@ -470,12 +703,15 @@ fn validate_one(verdict: &DeclaredVerdict) -> anyhow::Result<()> { } /// The per-route half of [`validate_one`]. -fn validate_route(verdict: &str, route: &Route) -> anyhow::Result<()> { +fn validate_route<'a>( + verdict: &str, + route: &'a Route, + grammar: Option<&Vocabulary>, + used: &mut BTreeSet<(usize, &'a str)>, +) -> anyhow::Result<()> { let id = route.id.as_str(); - if !id.starts_with(ROUTE_PREFIX) || id.len() <= ROUTE_PREFIX.len() { - return Err(UsageError::raise(format!( - "verdict `{verdict}`: route `{id}` is not `{ROUTE_PREFIX}`-prefixed" - ))); + if let Some(vocabulary) = grammar { + check_name(&format!("verdict `{verdict}`: route"), id, vocabulary, used)?; } match route.kind { RouteKind::Override => { @@ -662,13 +898,13 @@ impl Native { #[must_use] pub fn id(self) -> &'static str { match self { - Native::ProtectedMutation => "V-PROTECTED-MUTATION", - Native::InitWouldOverwrite => "V-AUTHORITY-EXISTS", - Native::HandlerDenied => "V-HANDLER-DENIED", - Native::ScannerUnpinned => "V-SCANNER-UNPINNED", - Native::ScannerUnprovisioned => "V-SCANNER-UNPROVISIONED", - Native::SpawningRuleOnReadVerb => "V-SPAWN-ON-READ-VERB", - Native::StopConditionUnmet => "V-STOP-CONDITION-UNMET", + Native::ProtectedMutation => "path write refused", + Native::InitWouldOverwrite => "config write refused", + Native::HandlerDenied => "handler answer denied", + Native::ScannerUnpinned => "scanner pin missing", + Native::ScannerUnprovisioned => "scanner install missing", + Native::SpawningRuleOnReadVerb => "spawn run refused", + Native::StopConditionUnmet => "turn finish unmet", } } } @@ -759,7 +995,7 @@ const fn admit(id: &'static str, precondition: &'static str) -> VendoredRoute { const VENDORED: &[VendoredVerdict] = &[ // ── native ────────────────────────────────────────────────────────────── VendoredVerdict { - id: "V-PROTECTED-MUTATION", + id: "path write refused", gloss: "a mutating verb was aimed at a path the config protects", class: "The path is in the `protected` set, so a write to it is refused before it \ happens rather than reported after. The set is the consumer's own declaration; what is \ @@ -767,14 +1003,14 @@ protected is a question about their repository, and the engine only enforces it. narrower remedy may be declared per path class through `[[redirect]]`, which is what the \ refusal names when one exists.", routes: &[ - read("R-USE-THE-OWNING-SURFACE", "batten.toml"), - run("R-RESTORE-IT", "git restore"), + read("config read first", "batten.toml"), + run("patch run first", "git restore"), // THE CLASS COULD NOT BE OVERRIDDEN, AND THAT LEFT ONLY THE PASSWORD. // // The two routes above are real and are the right first answers, but // neither reaches a path whose owning surface IS the protected file — // registering a rule, adding a redirect, retiring a gate onto a config - // row. For that class of change `R-USE-THE-OWNING-SURFACE` names the + // row. For that class of change `config read first` names the // file being refused, so the remedy is the thing denied. // // With no override route, `admission::questions_for` returns `None` and @@ -782,7 +1018,7 @@ refusal names when one exists.", // it cannot be overridden". The only remaining exit was // `BATTEN_HOOK_BYPASS` — a knowable string the guarded party can set, // which records nothing and stops nobody. This repository already ruled - // on that shape for `V-FILED-OVER-OWN-DIFF`: *the point of the admission + // on that shape for `issue file same`: *the point of the admission // mechanism is that the bare variable stops working*. // // The precondition is what the asker must be ABLE TO STATE, never a @@ -798,86 +1034,86 @@ in the diff it lands in", ], }, VendoredVerdict { - id: "V-AUTHORITY-EXISTS", + id: "config write refused", gloss: "`init` will not overwrite the committed authority", class: "House style §8 gives a repository ONE committed authority, and `init` \ writes it. Overwriting an existing one would replace a reviewed policy with a default \ set, silently, in a verb whose whole purpose is that there was nothing there before. \ Edit the file that exists, or move it aside deliberately.", - routes: &[read("R-EDIT-THE-AUTHORITY", "batten.toml")], + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "V-HANDLER-DENIED", + id: "handler answer denied", gloss: "a configured hook handler denied the call", class: "The refusal is the handler's, not the engine's: a `[hook.handler]` row \ names a program, the program answered deny, and this carries that answer through. The \ handler's own reason is free text the consumer configured, so no remedy is invented here \ — the handler is where a remedy would have to be declared.", - routes: &[read("R-READ-THE-HANDLER-ROW", "batten.toml")], + routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { - id: "V-SCANNER-UNPINNED", + id: "scanner pin missing", gloss: "a `secrets` rule needs its scanner pinned and none is declared", class: "A `secrets` rule delegates to an external scanner, and which scanner \ decides what the rule means. An unpinned one would resolve to whatever is ambient, so a \ green run would say nothing about the tree — the same defect a bare `cargo` has against \ a pinned toolchain. Declare the scanner as a `[[provision]]` entry.", routes: &[ - run("R-PROVISION-THE-SCANNER", "batten provision"), - read("R-DECLARE-THE-ENTRY", "batten.toml"), + run("check run first", "batten provision"), + read("config read first", "batten.toml"), ], }, VendoredVerdict { - id: "V-SCANNER-UNPROVISIONED", + id: "scanner install missing", gloss: "the pinned scanner is not in the provision cache, so nothing was scanned", class: "The scanner is declared and absent. This is could-not-look rather than a \ clean tree, and it is reported as a refusal precisely so the two are not spelled the \ same way: a secrets rule that scanned no file and reported nothing is the vacuous pass \ this engine argues against everywhere.", - routes: &[run("R-PROVISION-THE-SCANNER", "batten provision")], + routes: &[run("check run first", "batten provision")], }, VendoredVerdict { - id: "V-SPAWN-ON-READ-VERB", + id: "spawn run refused", gloss: "this rule kind runs a configured command, which a read-effect verb will not do", class: "The effect model (house style §5) puts every verb in one class and holds \ it there. A rule kind that spawns is `Effect`, and `check` is `Read`, so reaching one \ through the other would make the read-only allowlist a claim nobody could rely on. The \ rule is not wrong; the verb is.", - routes: &[run("R-USE-THE-SPAWNING-VERB", "batten enforce")], + routes: &[run("check run first", "batten enforce")], }, VendoredVerdict { - id: "V-STOP-CONDITION-UNMET", + id: "turn finish unmet", gloss: "the end-of-turn facts do not permit stopping", class: "A stop is a completion signal, and this engine's whole subject is keeping \ that signal aligned with landed-and-verified work. The facts the turn ended on say it is \ not, and the refusal names which. Each has its own route; the shared one is to finish \ the thing rather than to re-declare that it is finished.", routes: &[ - run("R-LAND-IT", "mise run land"), - read("R-READ-THE-FACTS", "batten.toml"), + run("task run first", "mise run land"), + read("config read first", "batten.toml"), ], }, // ── vendored presets ──────────────────────────────────────────────────── VendoredVerdict { - id: "V-EMPTY-COMMIT", + id: "commit ship empty", gloss: "an empty commit records that somebody wanted a new SHA", class: "A commit records a change. The reachable use of an empty one is kicking a \ pipeline, which spends a run to re-ask a question the previous run already answered and \ leaves a commit in the history no reader can act on. If the goal is a fresh run, re-run \ the pipeline.", - routes: &[run("R-RERUN-THE-PIPELINE", "re-run the pipeline")], + routes: &[run("task run first", "re-run the pipeline")], }, VendoredVerdict { - id: "V-FORCE-PUSH-AT-TRUNK", + id: "trunk push forced", gloss: "a force push rewrites a shared branch under whoever already fetched it", class: "Rewriting a published branch invalidates every checkout of it that \ already exists, and the holder finds out by having their next pull fail in a way that \ looks like their own mistake. `--force-with-lease` refuses when the remote moved, which \ is the same operation with the one check that makes it safe.", - routes: &[run("R-LEASE-THE-FORCE", "git push --force-with-lease")], + routes: &[run("patch run first", "git push --force-with-lease")], }, VendoredVerdict { - id: "V-PIN-BYPASSED", + id: "pin reach loose", gloss: "a program the project's pin provides was reached around the pin", class: "The pinned toolchain is what makes one machine's run mean anything about \ another's, and it supplies an ENVIRONMENT as well as a binary. A program reached around \ @@ -887,52 +1123,52 @@ like a wrong invocation. Measured on one consumer: sixty runs of a test suite di unset variable instead of on the assertion, and the report that followed was published \ as three claims about the tree, all false.", routes: &[run( - "R-REACH-IT-THROUGH-THE-PIN", + "task run first", "run the declared task, or invoke the program through the pin", )], }, VendoredVerdict { - id: "V-SHEBANG-UNNAMED-LANGUAGE", + id: "program name unnamed", gloss: "the file runs a shell and its name does not say so", class: "Every instrument that selects by extension — a formatter, a linter, a \ CI path filter — covers this file silently and exits 0. A green run over it therefore \ means nothing was looked at rather than nothing was found, which is worse than a red \ one. Name the language in the filename, or declare the file's coverage another way.", - routes: &[run("R-NAME-THE-LANGUAGE", "git mv")], + routes: &[run("patch run first", "git mv")], }, VendoredVerdict { - id: "V-SIBLING-UNRESOLVED", + id: "program resolve missing", gloss: "a run-time sibling path is computed and the tree carries no such file", class: "The shape resolves a path beside the running program and then guards it \ with a test that exits 0, so the reference does not fail — it goes silent, and the \ behaviour it was reaching for simply never happens. A path that must exist should be \ asserted rather than tested.", - routes: &[read("R-ADD-THE-SIBLING", "the computed path")], + routes: &[read("source read first", "the computed path")], }, VendoredVerdict { - id: "V-JOB-RUNS-ON-DRAFT", + id: "job run early", gloss: "a job spends a runner on a pull request still being verified locally", class: "A draft says the author is still verifying locally, and it is also the lever a \ red run pulls: a lander that re-drafts stops further spend while the failure is diagnosed. A \ single job missing the guard defeats both, and the run it buys is one nobody reads. Measured on \ one repository: a workflow triggered by any pull request touching a workflow file spent a runner \ on every push to a draft for its whole life, and re-drafting did not close the tap.", - routes: &[read("R-GATE-THE-JOB-ON-DRAFT", "the job's condition")], + routes: &[read("source read first", "the job's condition")], }, VendoredVerdict { - id: "V-PR-WORKFLOW-NOT-SUPERSEDED", + id: "workflow run twice", gloss: "a pull-request workflow pays out a run its own next push made obsolete", class: "A landing lap rebases and pushes. Without `cancel-in-progress` the superseded \ commit's run is billed in full for a verdict nobody will read, and a lander loses the ability to \ cancel a doomed run by simply pushing the next one. Declaring the group is not enough — the \ value is what does the work, and it is a boolean rather than the string `true`.", routes: &[read( - "R-SUPERSEDE-THE-RUN", + "source read first", "the workflow's concurrency block", )], }, VendoredVerdict { - id: "V-WORKFLOW-NO-CONCURRENCY", + id: "workflow declare missing", gloss: "a workflow can have two runs racing at all", class: "Superseding is the pull-request half of this and is not the whole of it: a \ comment- or schedule-triggered workflow never reaches that guard, so the property that matters \ @@ -940,10 +1176,10 @@ off the landing path — that a workflow cannot race itself — reaches none of concurrent comment invocations ran N concurrent attempts to advance a trunk branch, at 245 \ refusals against 6 merges in half an hour. A scheduled workflow must NOT cancel its own previous \ tick, so declaring a group is all this asks.", - routes: &[read("R-DECLARE-A-CONCURRENCY-GROUP", "the workflow")], + routes: &[read("source read first", "the workflow")], }, VendoredVerdict { - id: "V-READY-FOR-REVIEW-UNSUBSCRIBED", + id: "review watch missing", gloss: "a draft-gated workflow can never be superseded once it skips", class: "Omitting `types:` defaults to `[opened, synchronize, reopened]`. Where the jobs \ are draft-gated, a pull request created as a draft mints a skipped run on `opened`, and with no \ @@ -951,79 +1187,79 @@ are draft-gated, a pull request created as a draft mints a skipped run on `opene read a skip as an answer and polls forever. Measured as a deadlock across two pull requests at \ once, both fully green but for one such name.", routes: &[read( - "R-SUBSCRIBE-TO-READY-FOR-REVIEW", + "source read first", "the pull_request trigger's types", )], }, VendoredVerdict { - id: "V-WORKFLOW-RUN-UNSCOPED", + id: "workflow run loose", gloss: "a branch scope written where filtering is already too late", class: "A job condition is evaluated AFTER the run exists, so a branch scope expressed \ only there creates a run and then skips it. Measured on one lane: 1131 inserted-and-skipped runs \ in 25 hours — no runner minutes, which is why it survived, but 46% of every run in the \ repository, enough that paginating the run list stops being stable. The filter belongs on the \ trigger, where it is free.", - routes: &[read("R-FILTER-AT-THE-TRIGGER", "the workflow_run trigger")], + routes: &[read("source read first", "the workflow_run trigger")], }, VendoredVerdict { - id: "V-COMMENT-TRIGGER-UNANCHORED", + id: "event bind loose", gloss: "a comment predicate fires from anywhere in a body anyone can write", class: "An unanchored substring test fires from mid-sentence, from inside backticks, from \ a quoted block. That makes the repository's own writing ABOUT a trigger an invocation of it, and \ every artifact that has to name the token in order to be about it a live round. The class is the \ unanchored read of a body anyone can write, not the one token read that way.", - routes: &[read("R-ANCHOR-THE-PREDICATE", "the job condition")], + routes: &[read("source read first", "the job condition")], }, VendoredVerdict { - id: "V-COMMENT-MERGE-IGNORES-DRAFT", + id: "merge run early", gloss: "a comment-triggered merge delegates the draft question to the ruleset", class: "A draft head grades no checks where every pull-request workflow is draft-gated, \ and a branch ruleset admits that empty set as satisfying required-checks-green. So a merge path \ that never reads the draft state has no draft check at all, and can advance the trunk to a commit \ CI never ran on. Deciding not to ask is not the same as asking.", - routes: &[read("R-READ-THE-DRAFT-STATE", "the merge job")], + routes: &[read("source read first", "the merge job")], }, VendoredVerdict { - id: "V-TRIGGER-REACHES-NO-JOB", + id: "event reach dead", gloss: "a declared trigger starts a run in which every job skips", class: "The trigger exists and does nothing: the run list shows a run, and only the job's \ conclusion says it did not happen. Measured on one lane where a manual trigger was added so it \ could be exercised without waiting on a late cron, and every job's condition still admitted only \ the two original events. Judged only where a condition MENTIONS the event name at all, since a \ workflow that does not discriminate by event answers for every trigger it declares.", - routes: &[read("R-ADMIT-THE-TRIGGER", "the job conditions")], + routes: &[read("source read first", "the job conditions")], }, VendoredVerdict { - id: "V-CRON-COLLISION", + id: "job start same", gloss: "two scheduled workflows contend for the same runners at the same minute", class: "Every scheduled workflow's header tends to claim a staggered slot and nothing \ checks it, so two pairs drifted onto the same minute and the second pair landed after the first \ was found. Compared as LITERAL expressions rather than firing times: an every-30-minutes \ schedule genuinely overlaps every hourly slot, and flagging that would make the class fire \ forever on a workflow doing nothing wrong.", - routes: &[read("R-STAGGER-THE-SCHEDULE", "the schedule trigger")], + routes: &[read("source read first", "the schedule trigger")], }, VendoredVerdict { - id: "V-FANIN-NEEDS-UNASSERTED", + id: "job require unseen", gloss: "a fan-in enumerates its own dependencies and has gone stale", class: "Branch protection points at one aggregating job so that adding a leg never needs \ a ruleset change — which only holds if that job's assertion follows its dependency list by \ itself. Measured: a fan-in enumerated three of its four dependencies, so a red fourth left green \ the one check the host requires. A set-wide predicate cannot go stale, because it names nothing.", - routes: &[read("R-ASSERT-OVER-THE-WHOLE-SET", "the fan-in job")], + routes: &[read("source read first", "the fan-in job")], }, VendoredVerdict { - id: "V-WARM-COMPILE-UNGUARDED", + id: "cache build loose", gloss: "a cache-warming build recompiles and writes nothing on every run", class: "A build that compiles to fill a cache and runs nothing judges nothing, which is \ why it is exempt from parity rules — and that exemption is what makes it easy to leave running \ for nothing. Measured: two cache entries carrying the same key across five merges, each cycle \ compiling for ~145s and saving nothing, because the restore skips saving when the key already \ exists. One condition reading the restore's hit flag is the whole fix.", - routes: &[read("R-GUARD-ON-THE-CACHE-HIT", "the compile step")], + routes: &[read("source read first", "the compile step")], }, VendoredVerdict { - id: "V-WARM-GUARD-NAMES-MISSING-ID", + id: "cache name unknown", gloss: "the cache guard names a step that does not exist, so it admits every run", class: "The other direction of the same defect, and it has the same symptom with no \ signal. If the action stops emitting the hit flag the expression is empty, the guard holds, and \ @@ -1031,10 +1267,10 @@ the compile runs — wasteful, but visible in the bill. If the step id is droppe the guard keeps naming it, the expression is ALSO empty and the build silently reverts to \ compiling every time. So the class names both halves: the guard must be present, and the step it \ reads must exist.", - routes: &[read("R-DECLARE-THE-STEP-ID", "the restore step")], + routes: &[read("source read first", "the restore step")], }, VendoredVerdict { - id: "V-INTERPOLATION-SWALLOWED", + id: "input render dropped", gloss: "an unquoted comment truncates a value before it ever reaches the forge", class: "YAML opens a comment at an unquoted space-hash, so a value carrying an \ interpolation after one parses to the bare text before it and the rest is discarded. Measured: \ @@ -1042,10 +1278,10 @@ one workflow carried exactly that for a day and 30 consecutive runs reported a t workflow name, so a caller keying on the interpolated value could never match. Linters pass over \ the line because a comment is legal YAML, and review reads it as the thing it was meant to be. \ Read pre-parse, because the parse is what destroys the evidence. Quoting the value is the fix.", - routes: &[read("R-QUOTE-THE-VALUE", "the truncated line")], + routes: &[read("source read first", "the truncated line")], }, VendoredVerdict { - id: "V-GRADED-HEAD-REGRADED", + id: "head grade twice", gloss: "the forge already judged this commit and a second run would re-ask it", class: "A commit that has not changed cannot get a different verdict, so a second \ run over it buys an answer that is already recorded and spends the metered tier to do it. \ @@ -1053,10 +1289,7 @@ Measured on one consumer's landing bot over a half hour: 400 runs, 248 executed, merges. Read the recorded verdict rather than asking for it again; if the intent was to \ judge different work, the commit is what has to change.", routes: &[ - read( - "R-READ-THE-RECORDED-VERDICT", - "the forge record for this commit", - ), + read("source read first", "the forge record for this commit"), // THE PRECONDITION IS THE WHOLE OF THIS ROUTE. A re-grade is // legitimate when the recorded verdict is about the RUNNER rather // than about the commit — a lost agent, an evicted node, an @@ -1064,7 +1297,7 @@ judge different work, the commit is what has to change.", // nobody asked. It is not legitimate because the answer was // unwelcome, which is the case this condition exists to exclude. admit( - "R-OVERRIDE-THE-REGRADE", + "path admit first", "the recorded verdict is about a runner fault rather than about this commit", ), ], @@ -1180,36 +1413,125 @@ mod tests { #[test] fn a_conforming_entry_validates() { - validate(&[entry("V-ONE")]).expect("a conforming registry loads"); + validate(&[entry("V-ONE")], &Vocabulary::default()).expect("a conforming registry loads"); + } + + /// A three-slot fixture vocabulary, enough to spell `task read first`. + fn vocab() -> Vocabulary { + let word = |w: &str| VocabularyWord { + word: w.to_owned(), + gloss: "a word".to_owned(), + }; + Vocabulary { + tokenizer: Some("o200k_base".to_owned()), + tokenizer_source: None, + tokenizer_retrieved: None, + subject: vec![word("task"), word("shell")], + action: vec![word("read"), word("edit")], + condition: vec![word("first"), word("refused")], + } + } + + /// A class named in the grammar, with every word spent so arm 5 is quiet. + fn named(id: &str) -> DeclaredVerdict { + let mut e = entry(id); + e.routes[0].id = "shell edit refused".to_owned(); + e } #[test] - fn a_token_without_the_prefix_is_refused() { - let mut bad = entry("V-ONE"); - bad.id = "TASK-UNDEFINED".to_owned(); - assert!(validate(&[bad]).is_err()); + fn the_declared_registry_passes_its_own_grammar() { + // ANTI-VACUITY, and it is the load-bearing case (CLOUD-418): an arm that + // refused everything would satisfy every negative case below and get + // switched off the first time somebody ran it. + validate(&[named("task read first")], &vocab()).expect("a conforming registry loads"); + } + + #[test] + fn a_four_word_class_is_refused() { + // ARM 1. Fixed arity, not a maximum — this is the constraint an + // implementer will want to relax, and relaxing it is what puts a + // delimiter back between the name and the pointers. + assert!(validate(&[named("task read first refused")], &vocab()).is_err()); + assert!(validate(&[named("task read")], &vocab()).is_err()); + } + + #[test] + fn a_word_outside_the_declared_list_is_refused() { + // ARM 2, and in the right SLOT: `edit` is declared, as an action, so a + // class spelling it in slot 1 must still be refused. A membership check + // that ignored position would pass this and the grammar would mean + // nothing. + assert!(validate(&[named("cargo read first")], &vocab()).is_err()); + assert!(validate(&[named("edit read first")], &vocab()).is_err()); + } + + #[test] + fn a_duplicate_triple_is_refused() { + // ARM 3. Under this grammar the id IS the triple, so the duplicate-id + // refusal is the uniqueness-of-the-triple refusal; there is no second + // question to ask. + assert!( + validate( + &[named("task read first"), named("task read first")], + &vocab() + ) + .is_err() + ); + } + + #[test] + fn a_word_no_name_spends_is_refused() { + // ARM 5. The mirror of the landed rule that a `[[verdict]]` row nothing + // raises fails the load: dead vocabulary reads as headroom while nothing + // has walked it. + let mut wider = vocab(); + wider.condition.push(VocabularyWord { + word: "stale".to_owned(), + gloss: "answers for a state that has moved".to_owned(), + }); + assert!(validate(&[named("task read first")], &wider).is_err()); + } + + #[test] + fn a_word_with_no_gloss_is_refused() { + // ARM 6. A word that explains nothing explains nothing in every name + // that spends it, which is the free-text namespace back again. + let mut blank = vocab(); + blank.subject[0].gloss = " ".to_owned(); + assert!(validate(&[named("task read first")], &blank).is_err()); + } + + #[test] + fn a_registry_declaring_no_vocabulary_is_not_held_to_the_grammar() { + // The opt-in exemption, and the direction that keeps it honest: a + // consumer with no lists cannot satisfy membership, so refusing them + // would be a demand with no fix available. `[[pattern]]`'s preset + // exemption is the landed precedent. + validate(&[entry("V-LEGACY-NAME")], &Vocabulary::default()) + .expect("a consumer that has not adopted the grammar still loads"); } #[test] fn a_duplicate_token_is_refused() { - assert!(validate(&[entry("V-ONE"), entry("V-ONE")]).is_err()); + assert!(validate(&[entry("V-ONE"), entry("V-ONE")], &Vocabulary::default()).is_err()); } #[test] fn a_paragraph_gloss_is_refused() { let mut bad = entry("V-ONE"); bad.gloss = "x".repeat(GLOSS_MAX + 1); - assert!(validate(&[bad]).is_err()); + assert!(validate(&[bad], &Vocabulary::default()).is_err()); let mut wrapped = entry("V-ONE"); wrapped.gloss = "one\ntwo".to_owned(); - assert!(validate(&[wrapped]).is_err()); + assert!(validate(&[wrapped], &Vocabulary::default()).is_err()); } #[test] fn a_verdict_with_no_route_is_refused() { let mut bad = entry("V-ONE"); bad.routes.clear(); - assert!(validate(&[bad]).is_err()); + assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] @@ -1221,7 +1543,7 @@ mod tests { target: String::new(), precondition: Some("you can state why".to_owned()), }]; - assert!(validate(&[bad]).is_err()); + assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] @@ -1233,21 +1555,21 @@ mod tests { target: String::new(), precondition: None, }); - assert!(validate(&[bad]).is_err()); + assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] fn a_command_route_carrying_a_precondition_is_refused() { let mut bad = entry("V-ONE"); bad.routes[0].precondition = Some("something".to_owned()); - assert!(validate(&[bad]).is_err()); + assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] fn a_successor_naming_nothing_is_refused() { let mut bad = entry("V-OLD"); bad.successor = Some("V-GONE".to_owned()); - assert!(validate(&[bad]).is_err()); + assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] @@ -1256,7 +1578,7 @@ mod tests { first.successor = Some("V-B".to_owned()); let mut second = entry("V-B"); second.successor = Some("V-A".to_owned()); - assert!(validate(&[first, second]).is_err()); + assert!(validate(&[first, second], &Vocabulary::default()).is_err()); } #[test] @@ -1265,7 +1587,7 @@ mod tests { old.successor = Some("V-NEW".to_owned()); let new = entry("V-NEW"); let table = vec![old, new]; - validate(&table).expect("a terminating chain loads"); + validate(&table, &Vocabulary::default()).expect("a terminating chain loads"); let (resolved, retired) = resolve(&table, "V-OLD").expect("the token resolves"); assert_eq!(resolved.id, "V-NEW"); assert!(retired, "the token the reader asked for was retired"); @@ -1312,9 +1634,15 @@ mod tests { | Native::SpawningRuleOnReadVerb | Native::StopConditionUnmet => native.id(), }; - assert!( - named.starts_with(VERDICT_PREFIX), - "{named} is not a verdict token" + // The prefix is gone (CLOUD-1284), so what makes this a token is the + // ARITY: exactly three words. Asserting that here rather than a + // prefix keeps the native half held to the same shape the consumer + // half is validated against, which is the property the prefix used + // to stand in for. + assert_eq!( + named.split(' ').count(), + SLOTS, + "{named} is not a three-word verdict name" ); } assert_eq!( @@ -1332,7 +1660,8 @@ mod tests { #[test] fn the_vendored_table_validates() { let table = vendored(); - validate(&table).expect("the table this binary ships is well formed"); + validate(&table, &Vocabulary::default()) + .expect("the table this binary ships is well formed"); for native in Native::ALL { assert!( table.iter().any(|entry| entry.id == native.id()), diff --git a/crates/batten/tests/it/admission.rs b/crates/batten/tests/it/admission.rs index 43f4f834f..97c120961 100644 --- a/crates/batten/tests/it/admission.rs +++ b/crates/batten/tests/it/admission.rs @@ -49,7 +49,7 @@ use batten::admission::{self, Binding, Record, Refused, Situation, State}; const AUTHORITY: &str = r#"version = 1 [[verdict]] -id = "V-PROSE-ONLY-DIFF" +id = "diff ship early" gloss = "a branch whose whole diff is comment lines buys a CI matrix that confirms nothing" class = """ Every changed line is a comment and no test moved, so a full required matrix @@ -57,12 +57,12 @@ would confirm nothing that could differ. Ride the next change these files carry. """ [[verdict.route]] -id = "R-BATCH-IT" +id = "task run first" kind = "command" target = "let the next change to these files carry it" [[verdict.route]] -id = "R-OVERRIDE-PROSE-ONLY" +id = "path admit first" kind = "override" precondition = "the prose IS the deliverable and cannot wait for the next change" "#; @@ -101,7 +101,7 @@ fn answers(reason: &str) -> BTreeMap { ("lost".to_owned(), reason.to_owned()), ( "rejected-route".to_owned(), - "R-BATCH-IT: no next change is coming".to_owned(), + "task run first: no next change is coming".to_owned(), ), ]) } @@ -117,7 +117,7 @@ fn answers(reason: &str) -> BTreeMap { fn binding(subject: &str, head: &str, epoch: &str, reason: &str) -> Binding { Binding { rule: "prose-only".to_owned(), - verdict: "V-PROSE-ONLY-DIFF".to_owned(), + verdict: "diff ship early".to_owned(), subject: subject.to_owned(), head: head.to_owned(), epoch: epoch.to_owned(), @@ -131,7 +131,7 @@ fn binding(subject: &str, head: &str, epoch: &str, reason: &str) -> Binding { fn situation<'a>(subject: &'a str, head: &'a str, epoch: &'a str) -> Situation<'a> { Situation { rule: "prose-only", - verdict: "V-PROSE-ONLY-DIFF", + verdict: "diff ship early", subject, head, epoch, @@ -328,7 +328,7 @@ fn a_cycle_cannot_be_constructed_without_breaking_an_address() { let situation = Situation { rule: "prose-only", - verdict: "V-PROSE-ONLY-DIFF", + verdict: "diff ship early", subject: "a.rs", head: "head1", epoch: "epoch1", @@ -395,7 +395,7 @@ fn two_concurrent_consumes_resolve_to_exactly_one_winner() { &issued, &Situation { rule: "prose-only", - verdict: "V-PROSE-ONLY-DIFF", + verdict: "diff ship early", subject: "a.rs", head: "head1", epoch: "epoch1", @@ -462,7 +462,7 @@ fn an_unanswered_question_yields_no_admission_and_prints_what_to_answer() { "--rule", "prose-only", "--verdict", - "V-PROSE-ONLY-DIFF", + "diff ship early", "--subject", "a.rs", ], @@ -489,7 +489,7 @@ fn a_class_declaring_no_override_route_cannot_be_overridden() { // The right default, and it composes with `verdict::validate`'s refusal of a // class whose ONLY route is an override: a class either offers a real way out // and may additionally be overridden, or it offers a real way out and may - // not. `V-SPAWN-ON-READ-VERB` is the second kind. + // not. `spawn run refused` is the second kind. let root = fixture("no-override-route"); let output = common::run_with_stdin( &root, @@ -499,7 +499,7 @@ fn a_class_declaring_no_override_route_cannot_be_overridden() { "--rule", "check", "--verdict", - "V-SPAWN-ON-READ-VERB", + "spawn run refused", "--subject", "a.rs", ], @@ -562,13 +562,13 @@ fn a_correctly_answered_override_completes_end_to_end() { "--rule", "prose-only", "--verdict", - "V-PROSE-ONLY-DIFF", + "diff ship early", "--subject", "a.rs,b.rs", ], "precondition=the prose IS the deliverable — this branch is the release notes\n\ lost=the notes miss the release window and ship describing the previous version\n\ - rejected-route=R-BATCH-IT assumes a next change to these files, and there is none queued\n", + rejected-route=task run first assumes a next change to these files, and there is none queued\n", ); let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); assert_eq!( @@ -587,7 +587,7 @@ fn a_correctly_answered_override_completes_end_to_end() { assert_eq!(record.state, State::Issued); assert!(record.recomputes(), "and it verifies against its own key"); assert_eq!(record.binding.subject, "a.rs,b.rs"); - assert_eq!(record.binding.verdict, "V-PROSE-ONLY-DIFF"); + assert_eq!(record.binding.verdict, "diff ship early"); assert_eq!( record.binding.answers.len(), 3, @@ -609,7 +609,7 @@ fn an_admission_for_one_class_is_not_presentable_against_another() { .expect("the admission issues"); let elsewhere = Situation { - verdict: "V-SHELL-RULE-EDITED", + verdict: "shell edit refused", ..situation("a.rs", "HEAD1", "E1") }; assert_eq!( @@ -635,7 +635,7 @@ fn issued_through_the_verb(root: &Path, subject: &str) -> String { "--rule", "prose-only", "--verdict", - "V-PROSE-ONLY-DIFF", + "diff ship early", "--subject", subject, ], @@ -646,7 +646,7 @@ fn issued_through_the_verb(root: &Path, subject: &str) -> String { // about the SPEND rather than about the request. "precondition=the prose IS the deliverable — this branch is the release notes\n\ lost=the notes miss the release window and ship describing the previous version\n\ - rejected-route=R-BATCH-IT assumes a next change to these files, and there is none queued\n", + rejected-route=task run first assumes a next change to these files, and there is none queued\n", ); assert_eq!(output.status.code(), Some(0), "the request succeeds"); String::from_utf8_lossy(&output.stdout).trim().to_owned() @@ -670,7 +670,7 @@ fn the_verb_spends_a_legitimate_admission_and_reports_it() { "--rule", "prose-only", "--verdict", - "V-PROSE-ONLY-DIFF", + "diff ship early", "--subject", "a.rs", ], @@ -681,7 +681,7 @@ fn the_verb_spends_a_legitimate_admission_and_reports_it() { // parses one line working across this change. let first = stdout.lines().next().unwrap_or_default(); assert!( - first.contains("V-PROSE-ONLY-DIFF") && first.contains("spent"), + first.contains("diff ship early") && first.contains("spent"), "the class and the outcome, on line one: {stdout:?}" ); // THIS ASSERTION IS INVERTED, AND IT IS THE CHANGE RATHER THAN COLLATERAL @@ -734,7 +734,7 @@ fn the_verb_refuses_a_replay_with_the_policy_code() { "--rule", "prose-only", "--verdict", - "V-PROSE-ONLY-DIFF", + "diff ship early", "--subject", "a.rs", ]; @@ -775,7 +775,7 @@ fn the_verb_refuses_an_admission_presented_for_another_subject() { "--rule", "prose-only", "--verdict", - "V-PROSE-ONLY-DIFF", + "diff ship early", "--subject", "b.rs", ], diff --git a/crates/batten/tests/it/authority_replay.rs b/crates/batten/tests/it/authority_replay.rs index 292cf48d8..627e239dd 100644 --- a/crates/batten/tests/it/authority_replay.rs +++ b/crates/batten/tests/it/authority_replay.rs @@ -380,7 +380,7 @@ fn the_compiled_authority_answers_exactly_what_the_program_answered() { /// — a `chore(lint)` row refused for landing the commit it exists to land. /// /// It is not repaired here because `mise-tasks/ready-lint.sh` is a governed shell -/// rule: `V-SHELL-RULE-EDITED` declares one route, `R-PORT-AND-RETIRE`, with no +/// rule: `shell edit refused` declares one route, `rule read first`, with no /// override and no `bypass_env`. Retiring it reaches `graph-check.sh`, and through /// it `released.sh` and `board-sweep.sh` — four programs and 214 `@test` cases, /// which is CLOUD-1194's campaign rather than a line in this file. CLOUD-1221 diff --git a/crates/batten/tests/it/bypass_scrub.rs b/crates/batten/tests/it/bypass_scrub.rs index a46830102..ad600d276 100644 --- a/crates/batten/tests/it/bypass_scrub.rs +++ b/crates/batten/tests/it/bypass_scrub.rs @@ -121,7 +121,7 @@ fn the_hatch_is_load_bearing() { // // This case used to observe the hatch through a protected-path refusal, // which was the obvious choice while the hatch reached every row. It no - // longer reaches `V-PROTECTED-MUTATION`: that class declares an override + // longer reaches `path write refused`: that class declares an override // route and the boundary honours a spent admission for it, so the variable // stopped being its way out. Observing the hatch through the one gate it // deliberately cannot open would assert the opposite of the contract. diff --git a/crates/batten/tests/it/ci_parity.rs b/crates/batten/tests/it/ci_parity.rs index c7cff524a..96d4b4842 100644 --- a/crates/batten/tests/it/ci_parity.rs +++ b/crates/batten/tests/it/ci_parity.rs @@ -34,7 +34,7 @@ //! this row's, as `foreign-cargo-is-the-declared-spelling`. It reads //! `test:cargo`'s body out of the manifest rather than out of `mise tasks info`, //! which no policy module can spawn for — and the two are the same bytes only -//! while that task carries no template. `V-TASK-CARGO-UNREADABLE` is the arm +//! while that task carries no template. `task read unread` is the arm //! that surfaces the day they stop being. //! # RETIREMENT LEDGER, PER PATH — what `shell-retirement` reads @@ -157,7 +157,7 @@ // changed: "the concurrency property judges every workflow, not only the pull_request ones" crates/batten/tests/it/ci_hygiene.rs it judges every workflow whose runs answer about ONE SUBJECT — pull_request, issue_comment, workflow_run and schedule — and no longer a push-only workflow, whose runs are each keyed to a different commit and are therefore two subjects rather than two answers. Every measured instance of the original defect is inside the narrowed set; what it gives up is a preset that refuses an ordinary minimal repository, which this tree's own shipped-config canary in tests/prebuilt-lint.bats is what surfaced // changed: "a required check whose workflow cannot see ready_for_review is refused" crates/batten/tests/it/ci_hygiene.rs the preset scopes it to a workflow that DRAFT-GATES rather than to one producing a required check: a roster is a consumer fact and cannot live in a vendored preset (rule 1). Same condition read from the workflow itself, since a job that skips on a draft is one whose verdict can only arrive on the ready event // changed: "a workflow producing no required check may omit ready_for_review" crates/batten/tests/it/ci_hygiene.rs the exemption is now 'does not draft-gate' rather than 'produces no required check', for the same rule-1 reason; a workflow whose jobs run on drafts has no skipped run to supersede -// changed: "finding no pull_request workflow at all is a failure, not a pass" crates/batten/tests/it/ci_parity.rs the engine distinguishes could-not-look from not-applicable through `input.tree.missing`, so an unreadable workflow raises V-CI-WORKFLOW-UNREAD while a tree that genuinely runs no such workflow is not-applicable. The shell had one channel for both and had to refuse the empty case to avoid a vacuous pass +// changed: "finding no pull_request workflow at all is a failure, not a pass" crates/batten/tests/it/ci_parity.rs the engine distinguishes could-not-look from not-applicable through `input.tree.missing`, so an unreadable workflow raises workflow read unread while a tree that genuinely runs no such workflow is not-applicable. The shell had one channel for both and had to refuse the empty case to avoid a vacuous pass // changed: "a missing release config is a failure, not a pass" crates/batten/tests/it/ci_parity.rs a consumer with no release automation is not-applicable rather than refused; the row's `sources` declares the file, so a declared-but-unparseable one raises the could-not-look verdict instead // changed: "an empty workflow directory is refused rather than silently green" crates/batten/tests/it/ci_parity.rs the anti-vacuity term moved from the gate's own counter to the rule guards: each rule stands down on a tree carrying no workflow, and the compiled-binary tier's `this_repository_is_clean_today` is what proves the rules are not vacuous over the real tree // changed: "a missing renovate config is a failure, not a pass" crates/batten/tests/it/ci_parity.rs same as the release config: absent is not-applicable and unparseable is loud, which is the distinction the shell could not draw @@ -477,7 +477,7 @@ fn a_foreign_leg_running_a_different_cargo_is_refused() { ); let found = verdicts_raised(&root); assert!( - found.iter().any(|v| v == "V-FOREIGN-CARGO-SPELLING-DRIFT"), + found.iter().any(|v| v == "cargo spelling other"), "a foreign leg running a cargo the task does not declare should be refused: {found:?}" ); } @@ -497,7 +497,7 @@ fn a_tree_with_no_foreign_cargo_leg_is_refused() { ); let found = verdicts_raised(&root); assert!( - found.iter().any(|v| v == "V-FOREIGN-CARGO-ABSENT"), + found.iter().any(|v| v == "cargo reach absent"), "a tree with no foreign cargo leg should be refused, not passed: {found:?}" ); } @@ -519,11 +519,11 @@ fn a_no_run_build_is_exempt_and_does_not_satisfy_the_term() { ); let found = verdicts_raised(&root); assert!( - found.iter().any(|v| v == "V-FOREIGN-CARGO-ABSENT"), + found.iter().any(|v| v == "cargo reach absent"), "a --no-run leg is not a subject and must not satisfy the term: {found:?}" ); assert!( - !found.iter().any(|v| v == "V-FOREIGN-CARGO-SPELLING-DRIFT"), + !found.iter().any(|v| v == "cargo spelling other"), "a --no-run leg is exempt from the comparison itself: {found:?}" ); } @@ -544,7 +544,7 @@ fn a_task_yielding_no_cargo_invocation_is_refused() { ); let found = verdicts_raised(&root); assert!( - found.iter().any(|v| v == "V-TASK-CARGO-UNREADABLE"), + found.iter().any(|v| v == "task read unread"), "a task yielding no cargo invocation should be refused, not passed: {found:?}" ); } diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 79c75381e..b5cba0f54 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -1844,7 +1844,7 @@ fn a_deny_with_no_consumer_remedy_falls_back_to_the_declared_class() { "names the gate, got: {stderr}" ); assert!( - stderr.contains("V-PROTECTED-MUTATION ("), + stderr.contains("path write refused ("), "the hot path leads with the token and its gloss, got: {stderr}" ); assert!( @@ -4924,7 +4924,7 @@ const CENSUS_POSITIONALS: &[(&str, &[&str])] = &[ // reason the vendored half exists at all. A consumer token here would make // this census depend on the fixture's authority carrying a row, and the // fixture's authority is `batten init`'s output. - ("policy explain", &["V-PROTECTED-MUTATION"]), + ("policy explain", &["path write refused"]), // The key the fixture's seeded RESPONSE capture carries. `capture find` is // the first verb whose clean run needs a capture of a kind `exec` cannot // make: a `Stream::Response`, which only the post-tool event writes. diff --git a/crates/batten/tests/it/commit_admission.rs b/crates/batten/tests/it/commit_admission.rs index a4ed5d53f..121ab1165 100644 --- a/crates/batten/tests/it/commit_admission.rs +++ b/crates/batten/tests/it/commit_admission.rs @@ -3,7 +3,7 @@ //! //! # What this tier is for //! -//! `V-PROTECTED-MUTATION`'s override route made a protected write admissible: the +//! `path write refused`'s override route made a protected write admissible: the //! guarded party articulates, an admission is issued and spent, the write goes //! through. That much landed and it produced nothing anyone could read. The record //! lives under the OS data directory, which in this repository is a container the @@ -93,7 +93,7 @@ fn articulate(dir: &Path, subject: &str) -> String { "--rule", "protected-mutation", "--verdict", - "V-PROTECTED-MUTATION", + "path write refused", "--subject", subject, ], @@ -112,7 +112,7 @@ fn articulate(dir: &Path, subject: &str) -> String { "--rule", "protected-mutation", "--verdict", - "V-PROTECTED-MUTATION", + "path write refused", "--subject", subject, ], diff --git a/crates/batten/tests/it/connector_not_granted.rs b/crates/batten/tests/it/connector_not_granted.rs index ce37d8fee..b639847e7 100644 --- a/crates/batten/tests/it/connector_not_granted.rs +++ b/crates/batten/tests/it/connector_not_granted.rs @@ -60,12 +60,12 @@ module = "connector-not-granted.rego" severity = "deny" [[verdict]] -id = "V-RAW-CONNECTOR-GRANTED" +id = "connector grant loose" gloss = "a reduced tool is also granted raw, so the reduction decides nothing" class = "A fixture copy of the shipped class; the registry's own row is in batten.toml." [[verdict.route]] -id = "R-DROP-THE-RAW-GRANT" +id = "task run first" kind = "document" target = "connector-not-granted.rego" "#; diff --git a/crates/batten/tests/it/harness_grant.rs b/crates/batten/tests/it/harness_grant.rs index cce0e96dd..33716c58b 100644 --- a/crates/batten/tests/it/harness_grant.rs +++ b/crates/batten/tests/it/harness_grant.rs @@ -62,22 +62,22 @@ module = "harness-grant.rego" severity = "deny" [[verdict]] -id = "V-HARNESS-GRANT-ABSENT" +id = "grant declare absent" gloss = "the committed settings no longer grant this repository's own binary" class = "A fixture copy of the shipped class; the registry's own row is in batten.toml." [[verdict.route]] -id = "R-RESTORE-THE-GRANT" +id = "task run first" kind = "document" target = "harness-grant.rego" [[verdict]] -id = "V-HARNESS-GRANT-DEFAULTS-DROPPED" +id = "default carry dropped" gloss = "the grant is there and the built-in classifier rules were discarded with it" class = "A fixture copy of the shipped class; the registry's own row is in batten.toml." [[verdict.route]] -id = "R-RESTORE-THE-DEFAULTS" +id = "task run first" kind = "document" target = "harness-grant.rego" "#; diff --git a/crates/batten/tests/it/hook_profile.rs b/crates/batten/tests/it/hook_profile.rs index a007943e1..6ee040fe7 100644 --- a/crates/batten/tests/it/hook_profile.rs +++ b/crates/batten/tests/it/hook_profile.rs @@ -86,32 +86,32 @@ version = "{DECLARED_VERSION}" input = "hk.pkl" [[verdict]] -id = "V-PROFILED-STEP-NOT-IN-CHECK" +id = "step declare missing" gloss = "a step declaring the slow profile is not selected by the `check` hook" class = "A fixture class, mirroring the committed row." [[verdict.route]] -id = "R-RESTORE-THE-PROFILED-STEP" +id = "gate read first" kind = "document" target = "hk.pkl" [[verdict]] -id = "V-SLOW-TIER-EMPTY" +id = "tier list empty" gloss = "something planned this tree and no step declares the slow profile" class = "A fixture class, mirroring the committed row." [[verdict.route]] -id = "R-RESTORE-THE-SLOW-TIER" +id = "gate read first" kind = "document" target = "hk.pkl" [[verdict]] -id = "V-HOOK-MISSING-PROFILE-FLAG" +id = "hook declare missing" gloss = "the git hook runs hk without the profile flag" class = "A fixture class, mirroring the committed row." [[verdict.route]] -id = "R-RESTORE-THE-PROFILE-FLAG" +id = "source read first" kind = "document" target = ".claude/hooks/git-hook.sh" "# diff --git a/crates/batten/tests/it/mediated_admission.rs b/crates/batten/tests/it/mediated_admission.rs index ce84ca9d7..cec2fc78d 100644 --- a/crates/batten/tests/it/mediated_admission.rs +++ b/crates/batten/tests/it/mediated_admission.rs @@ -1,7 +1,7 @@ //! A mediated refusal is admissible by a spent admission, and only by one. //! //! The tier that proves the ENGINE honours what the route advertises. Without it -//! `V-PROTECTED-MUTATION`'s `R-ARTICULATE-THE-WRITE` is a promise made in a +//! `path write refused`'s `R-ARTICULATE-THE-WRITE` is a promise made in a //! refusal message: `batten override request` would answer, mint a real record, //! and the write would still be refused — the exact defect `verdict.rs`'s header //! exists to kill, one layer along. @@ -40,13 +40,13 @@ const ORDINARY: &str = "notes.md"; /// that spelled them itself would keep passing after a rename that broke every /// consumer. const RULE: &str = "protected-mutation"; -const CLASS: &str = "V-PROTECTED-MUTATION"; +const CLASS: &str = "path write refused"; /// A fixture whose committed authority protects itself. /// /// `protected` naming `batten.toml` is this repository's own row, and it is the /// case that matters: the file a registration has to edit is the file the gate -/// refuses, which is why `R-USE-THE-OWNING-SURFACE` cannot reach it. +/// refuses, which is why `config read first` cannot reach it. fn fixture(name: &str) -> PathBuf { Fixture::new(name) // THE `[[verb]]` ROW IS LOAD-BEARING AND DOES NOT MATCH THIS CALL, which @@ -103,7 +103,7 @@ fn request(dir: &Path, 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\ - rejected-route=R-USE-THE-OWNING-SURFACE names batten.toml, which is the subject\n" + rejected-route=config read first names batten.toml, which is the subject\n" ); let output = run_with_stdin( dir, diff --git a/crates/batten/tests/it/memories.rs b/crates/batten/tests/it/memories.rs index a38b713d5..3500a047e 100644 --- a/crates/batten/tests/it/memories.rs +++ b/crates/batten/tests/it/memories.rs @@ -100,7 +100,7 @@ severity = "deny" # not merely fail; it fails with a config error that looks nothing like the # predicate being wrong, which is how it read the first time. [[verdict]] -id = "V-MEMORY-ROOT-MISSING" +id = "memory resolve missing" gloss = "the memory graph has no root" class = "fixture" @@ -110,7 +110,7 @@ kind = "document" target = "policy/memories.rego" [[verdict]] -id = "V-MEMORY-NAME-SHADOWED" +id = "memory name duplicate" gloss = "a memory name strips to another name" class = "fixture" @@ -120,7 +120,7 @@ kind = "document" target = "policy/memories.rego" [[verdict]] -id = "V-MEMORY-NAME-UNREFERENCABLE" +id = "memory name unseen" gloss = "a memory name carries a character no reference can spell" class = "fixture" @@ -130,7 +130,7 @@ kind = "document" target = "policy/memories.rego" [[verdict]] -id = "V-MEM-REF-STALE" +id = "memory point stale" gloss = "a mem: reference names a memory this tree does not carry" class = "fixture" @@ -140,7 +140,7 @@ kind = "document" target = "policy/memories.rego" [[verdict]] -id = "V-MEMORY-SOURCE-UNREAD" +id = "memory read unread" gloss = "a declared referrer could not be read" class = "fixture" diff --git a/crates/batten/tests/it/mise_pin_agreement.rs b/crates/batten/tests/it/mise_pin_agreement.rs index cbf807301..0132a9748 100644 --- a/crates/batten/tests/it/mise_pin_agreement.rs +++ b/crates/batten/tests/it/mise_pin_agreement.rs @@ -83,50 +83,50 @@ severity = "deny" reason = "mise.toml owns the pin and .mcp.json's copy is a reference to it." [[verdict]] -id = "V-MCP-PIN-DISAGREES" +id = "pin declare other" gloss = "a tool version .mcp.json names is not the version mise.toml pins" class = """ The second place a pin is written cannot drift from the first. """ [[verdict.route]] -id = "R-REPEAT-THE-AUTHORITATIVE-PIN" +id = "config read first" kind = "document" target = ".mcp.json" [[verdict]] -id = "V-MCP-PIN-UNDECLARED" +id = "pin declare missing" gloss = "a tool .mcp.json launches has no plain-string pin in mise.toml at all" class = """ A reference with no authority behind it is not a pin. """ [[verdict.route]] -id = "R-PIN-THE-TOOL" +id = "task read first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-MCP-EXEC-UNSCOPED" +id = "call run loose" gloss = "a `mise exec` launch names no tool before `--`" class = """ A bare exec provisions the whole toolchain and dies with any one of it. """ [[verdict.route]] -id = "R-SCOPE-THE-EXEC" +id = "config read first" kind = "document" target = ".mcp.json" [[verdict]] -id = "V-PIN-AUTHORITY-UNREADABLE" +id = "pin read unread" gloss = "mise.toml could not be read, so no pin reference could be compared" class = """ Could-not-look, kept loud. """ [[verdict.route]] -id = "R-RESTORE-THE-AUTHORITY" +id = "task read first" kind = "document" target = "mise.toml" "#; @@ -399,12 +399,12 @@ fn a_table_valued_pin_reads_as_undeclared() { // different things, so a `2=2` pair in the replay row would assert the migration // preserved the very contract it exists to fix, and it would pass. // -// The successor is the module's `V-PIN-AUTHORITY-UNREADABLE` clause, which is +// The successor is the module's `pin read unread` clause, which is // written and correct and which the engine cannot currently reach: measured // above, `input.tree.missing` is empty for an absent declared path. So this case // diverges twice over — once by contract, once because the channel is unfilled — // and both reasons are on the row. -// changed: "a missing mise.toml cannot be compared against — exit 2" crates/batten/tests/it/mise_pin_agreement.rs the shell's exit 2 is could-not-look and the engine's 2 is the policy verdict (house-style §7), so the code cannot be carried through an identity; and the successor clause `V-PIN-AUTHORITY-UNREADABLE` is unreachable today because `input.tree.missing` is never populated for an absent declared path — measured here, recorded on CLOUD-1049, which owns restoring the case +// changed: "a missing mise.toml cannot be compared against — exit 2" crates/batten/tests/it/mise_pin_agreement.rs the shell's exit 2 is could-not-look and the engine's 2 is the policy verdict (house-style §7), so the code cannot be carried through an identity; and the successor clause `pin read unread` is unreachable today because `input.tree.missing` is never populated for an absent declared path — measured here, recorded on CLOUD-1049, which owns restoring the case // // THE SAME CHANNEL, THE OTHER INPUT. An unparseable `.mcp.json` is today // indistinguishable from an absent one and is silent, where the bash exited 2. diff --git a/crates/batten/tests/it/perf_pair.rs b/crates/batten/tests/it/perf_pair.rs index 25687315c..5f6a023a9 100644 --- a/crates/batten/tests/it/perf_pair.rs +++ b/crates/batten/tests/it/perf_pair.rs @@ -30,7 +30,7 @@ //! //! WHY IT WAS MIGRATED AT ALL, which is the campaign working on its author a //! second time (after `semver`). CLOUD-875 is a repair to the SKIP, and making it -//! meant editing an authored shell rule, which `V-SHELL-RULE-EDITED` refuses with +//! meant editing an authored shell rule, which `shell edit refused` refuses with //! no override route. It is also a repair a shell skip could not express: the //! widened set is DERIVED from the loaded config — every path a `policy` row //! registers — rather than written down, and that is exactly what a `grep -cE` diff --git a/crates/batten/tests/it/pinned_programs.rs b/crates/batten/tests/it/pinned_programs.rs index 2f21bbaec..746a128a2 100644 --- a/crates/batten/tests/it/pinned_programs.rs +++ b/crates/batten/tests/it/pinned_programs.rs @@ -197,7 +197,7 @@ fn the_same_program_through_the_pin_is_not_reported() { provided(&repo, &["bats"]); let said = advice(&repo, "mise exec -- bats tests/land.bats"); assert!( - !said.contains("V-PIN-BYPASSED"), + !said.contains("pin reach loose"), "the mediated form is the sanctioned one: {said}" ); } @@ -210,7 +210,7 @@ fn a_program_the_pin_does_not_provide_is_not_reported() { provided(&repo, &["bats"]); let said = advice(&repo, "ls -la tests"); assert!( - !said.contains("V-PIN-BYPASSED"), + !said.contains("pin reach loose"), "an unpinned program is a genuine one-off: {said}" ); } @@ -226,7 +226,7 @@ fn a_checkout_with_no_record_reports_nothing() { "./tests/bats/bin/bats --filter 'a case' tests/land.bats", ); assert!( - !said.contains("V-PIN-BYPASSED"), + !said.contains("pin reach loose"), "a project whose pin could not be read is not one to refuse: {said}" ); } diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 3c9cf6a78..1aeb7093a 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -844,7 +844,7 @@ const CENSUS: &[Verb] = &[ // 4 is about. The token asked for is a literal the caller typed. Verb { path: "policy explain", - args: &["V-PROTECTED-MUTATION"], + args: &["path write refused"], stdin: Stdin::Nothing, disposition: Disposition::Echoes( "the answer IS a `[[verdict]]` row — its gloss, its class definition and its \ @@ -873,7 +873,7 @@ const CENSUS: &[Verb] = &[ "--rule", "prose-only", "--verdict", - "V-PROTECTED-MUTATION", + "path write refused", "--subject", "a.rs", ], @@ -895,7 +895,7 @@ const CENSUS: &[Verb] = &[ "--rule", "prose-only", "--verdict", - "V-PROTECTED-MUTATION", + "path write refused", "--subject", "a.rs", ], diff --git a/crates/batten/tests/it/policy_test_suite.rs b/crates/batten/tests/it/policy_test_suite.rs index a72ae0733..8cae72e1d 100644 --- a/crates/batten/tests/it/policy_test_suite.rs +++ b/crates/batten/tests/it/policy_test_suite.rs @@ -124,7 +124,7 @@ rules contains "no-force-push" violation contains { "rule": "no-force-push", - "verdict": "V-FORCE-PUSH-AT-TRUNK", + "verdict": "trunk push forced", } if { words := split(input.call.command, " ") "--force" in words @@ -159,7 +159,7 @@ rules contains "no-force-push" violation contains { "rule": "no-force-push", - "verdict": "V-FORCE-PUSH-AT-TRUNK", + "verdict": "trunk push forced", } if { contains(input.call.command, "--force") } @@ -501,7 +501,7 @@ rules contains "no-force-push" violation contains { "rule": "no-force-push", - "verdict": "V-FORCE-PUSH-AT-TRUNK", + "verdict": "trunk push forced", } if { some path, _ in input.tree.documents endswith(path, ".forbidden") diff --git a/crates/batten/tests/it/privileged_lane.rs b/crates/batten/tests/it/privileged_lane.rs index c951e3043..4b86eff96 100644 --- a/crates/batten/tests/it/privileged_lane.rs +++ b/crates/batten/tests/it/privileged_lane.rs @@ -62,25 +62,25 @@ fn fixture(name: &str, workflow: &str, body: &str) -> PathBuf { "module = \"policy/privileged-lane.rego\"\n", "severity = \"deny\"\n\n", "[[verdict]]\n", - "id = \"V-PRIVILEGED-LANE-UNTESTED-ORIGIN\"\n", + "id = \"lane guard missing\"\n", "gloss = \"a job an outside author can reach holds contents:write and tests no head origin\"\n", "class = \"\"\"\n", "A trigger an outside author can fire, a token that can write, and no check that \\\n", "the head being built came from this repository.\n", "\"\"\"\n\n", "[[verdict.route]]\n", - "id = \"R-TEST-THE-HEAD-ORIGIN\"\n", + "id = \"workflow read first\"\n", "kind = \"document\"\n", "target = \".github/workflows\"\n\n", "[[verdict]]\n", - "id = \"V-WORKFLOW-UNPARSED\"\n", + "id = \"workflow parse broken\"\n", "gloss = \"a workflow could not be parsed, so its lanes were never judged\"\n", "class = \"\"\"\n", "Could-not-look, and deliberately not spelled the same way as a workflow whose \\\n", "lanes are all safe.\n", "\"\"\"\n\n", "[[verdict.route]]\n", - "id = \"R-FIX-THE-WORKFLOW-SYNTAX\"\n", + "id = \"task run first\"\n", "kind = \"document\"\n", "target = \".github/workflows\"\n", ), diff --git a/crates/batten/tests/it/prose_only.rs b/crates/batten/tests/it/prose_only.rs index da88110e3..a3ebc1aa5 100644 --- a/crates/batten/tests/it/prose_only.rs +++ b/crates/batten/tests/it/prose_only.rs @@ -460,7 +460,7 @@ fn the_finding_carries_a_count_and_never_a_path() { // an append to a log. // // The remedy is no longer prose this gate composes. It is -// `V-PROSE-ONLY-DIFF`'s declared `R-BATCH-IT` route, and `verdict::validate` +// `diff ship early`'s declared `task run first` route, and `verdict::validate` // refuses a class that declares no route at all — so "the refusal names // something to run" stopped being a property of this gate's message and became a // property of the registry. `crates/batten/tests/it/verdict_registry.rs` holds it. diff --git a/crates/batten/tests/it/ready.rs b/crates/batten/tests/it/ready.rs index 23140e663..578058956 100644 --- a/crates/batten/tests/it/ready.rs +++ b/crates/batten/tests/it/ready.rs @@ -43,7 +43,7 @@ //! rediscovered: `mise-tasks/graph-check.sh` resolves this gate BY PATH and //! branches on its exit codes, and both lines have to move for the program to //! die — the path because there is no path any more, the codes because a -//! violation is `2` here where it was `1` there. `V-SHELL-RULE-EDITED` admits an +//! violation is `2` here where it was `1` there. `shell edit refused` admits an //! edit to a caller only where every added line is a truncation of a removed one //! or an exact path substitution at a declared successor (both arms are in //! `policy/shell-retirement.rego`), and a shell sibling repointed at a compiled diff --git a/crates/batten/tests/it/retirement_doctrine.rs b/crates/batten/tests/it/retirement_doctrine.rs index 77badc52f..42a6c8b99 100644 --- a/crates/batten/tests/it/retirement_doctrine.rs +++ b/crates/batten/tests/it/retirement_doctrine.rs @@ -2,7 +2,7 @@ //! only in a rego header and a refusal (CLOUD-1132). //! //! `policy/shell-retirement.rego` admits exactly one disposition for a governed -//! shell gate — retire it whole, or leave it — and `V-SHELL-RULE-EDITED` declares +//! shell gate — retire it whole, or leave it — and `shell edit refused` declares //! one route with no override and no `bypass_env`. That rule was written down in //! two places and a reader reached neither before they had already edited the //! file: the module's own header, and the refusal itself. Two documents @@ -95,7 +95,7 @@ fn the_rules_state_both_shapes_and_that_there_is_no_third() { #[test] fn the_rules_name_the_module_and_the_verdict_that_decide() { let text = rules_text(); - for token in [MODULE, "V-SHELL-RULE-EDITED", "R-PORT-AND-RETIRE"] { + for token in [MODULE, "shell edit refused", "rule read first"] { assert!( text.contains(token), "{RULES} must name `{token}` — a reader who meets the refusal has to be \ @@ -214,7 +214,7 @@ fn the_rules_name_the_three_homes_and_the_module_admits_them() { #[test] fn the_rules_name_the_successor_kind_field_and_the_module_admits_it() { let text = squashed(&rules_text()); - for clause in ["kind:verb", "kind:mechanism", "V-SUCCESSOR-KIND-UNDECLARED"] { + for clause in ["kind:verb", "kind:mechanism", "shell port unnamed"] { assert!( text.contains(&squashed(clause)), "{RULES} must name `{clause}` — an engine-source arm now OWES its kind, \ @@ -225,7 +225,7 @@ fn the_rules_name_the_successor_kind_field_and_the_module_admits_it() { let module = squashed(&fs::read_to_string(at_root(MODULE)).expect("the module is committed")); assert!( - module.contains("V-SUCCESSOR-KIND-UNDECLARED"), + module.contains("shell port unnamed"), "{MODULE} must still raise the token {RULES} tells the author about" ); assert!( diff --git a/crates/batten/tests/it/review_answered.rs b/crates/batten/tests/it/review_answered.rs index 143434ead..a6ddeee38 100644 --- a/crates/batten/tests/it/review_answered.rs +++ b/crates/batten/tests/it/review_answered.rs @@ -501,7 +501,7 @@ fn the_measured_shape_a_head_carrying_unresolved_threads_is_refused_naming_the_c // THE COUNT, as the typed ABI renders it: the token, its gloss, and the // `Subject::Count` beside them. The retired case read `4 blocking` out of a // free string; the number is the same and it is now a decoded subject. - assert!(decision.contains("V-REVIEW-UNANSWERED"), "{decision}"); + assert!(decision.contains("review answer missing"), "{decision}"); assert!( decision.contains("unresolved review threads) 4"), "{decision}" @@ -603,7 +603,7 @@ fn vacuity_zero_threads_and_no_review_reads_as_unreviewed_not_as_all_addressed() record_reviews(&dir, &declared, &reviews(0)); let decision = ready(&dir); denied(&decision); - assert!(decision.contains("V-REVIEW-ABSENT"), "{decision}"); + assert!(decision.contains("review read absent"), "{decision}"); assert!(decision.contains("nobody has reviewed"), "{decision}"); } @@ -854,7 +854,7 @@ fn an_undeclared_class_refuses_with_the_token_and_says_the_registry_is_silent() reviewed(&dir, &declared); let decision = ready(&dir); denied(&decision); - assert!(decision.contains("V-REVIEW-UNANSWERED"), "{decision}"); + assert!(decision.contains("review answer missing"), "{decision}"); assert!( decision.contains("no `[[verdict]]` row declares"), "{decision}" @@ -904,7 +904,7 @@ severity = "deny" /// one case above so the registry-is-silent branch is reachable. const CLASSES: &str = r#" [[verdict]] -id = "V-REVIEW-UNANSWERED" +id = "review answer missing" gloss = "readying would buy a CI matrix on a head carrying unresolved review threads" class = """ Readying is the event that starts CI, and nothing in `land`'s pre-ready sequence \ @@ -912,19 +912,19 @@ asks about review. """ [[verdict.route]] -id = "R-ANSWER-THE-THREADS" +id = "task run first" kind = "command" target = "resolve each thread, then read them again and retry" [[verdict]] -id = "V-REVIEW-ABSENT" +id = "review read absent" gloss = "readying would buy a CI matrix on a head nobody has reviewed" class = """ The read found no review at all, which the thread count cannot say. """ [[verdict.route]] -id = "R-FORCE-A-FIRST-REVIEW" +id = "task run first" kind = "command" target = "@coderabbitai full review" "#; diff --git a/crates/batten/tests/it/rules_builtin_claims.rs b/crates/batten/tests/it/rules_builtin_claims.rs index 4e9b6edcf..ea59c3b20 100644 --- a/crates/batten/tests/it/rules_builtin_claims.rs +++ b/crates/batten/tests/it/rules_builtin_claims.rs @@ -16,8 +16,8 @@ //! //! Non-negotiable rule 2: a rule without a runnable gate is half a change. The //! clause arrived by copy-forward and nothing saw it, which is precisely how it -//! would arrive again — and this file is where `V-SHELL-RULE-ADDED`'s remedy -//! `R-WRITE-A-POLICY-MODULE` sends authors, with CLOUD-843's wave 1 copying its +//! would arrive again — and this file is where `shell add refused`'s remedy +//! `rule read first` sends authors, with CLOUD-843's wave 1 copying its //! template ~80 times. A wrong sentence there is ~80 authors pushed toward //! hand-rolled string work that the `[[pattern]]` registry exists to make //! unwritable. diff --git a/crates/batten/tests/it/rules_drift.rs b/crates/batten/tests/it/rules_drift.rs index 0971488bb..68f44ea16 100644 --- a/crates/batten/tests/it/rules_drift.rs +++ b/crates/batten/tests/it/rules_drift.rs @@ -66,10 +66,10 @@ // refusal would speak in every fixture repository that inherits this config — // the scoping defect CLOUD-1164 records for `tree-clean`. Each guard is now // conditioned on there being a claim that depends on the authority, and raises -// `V-DRIFT-AUTHORITY-UNREADABLE` rather than exiting before any predicate runs. +// `drift read unread` rather than exiting before any predicate runs. // // changed: "an empty rules directory is refused rather than silently green" policy/rules-drift.rego a glob that selects nothing is silent here rather than exit 1: `line_sources` is a glob and a repository with no rules tree is an ordinary consumer, which is exactly what the predecessor already said about an ABSENT MEMORY TREE one case below. The refusal it does keep is the one that has a subject — an authority some prose claims against -// changed: "unreadable wiring is refused rather than reporting every event unwired" policy/rules-drift.rego conditioned on a wiring claim existing: an unreadable `.claude/settings.json` is `V-DRIFT-AUTHORITY-UNREADABLE` when some sentence claims a wiring, and silent when none does +// changed: "unreadable wiring is refused rather than reporting every event unwired" policy/rules-drift.rego conditioned on a wiring claim existing: an unreadable `.claude/settings.json` is `drift read unread` when some sentence claims a wiring, and silent when none does // changed: "unreadable schemas are refused rather than reporting every key unemittable" policy/rules-drift.rego same conditioning, plus a READ-BUT-EMPTY arm the predecessor did not need: this build of regorus has no `walk`, so the recursive descent became one fixed path, and a schema whose shape moved parses fine and yields nothing — invisible to `input.tree.missing`, so `schema_vacuous` covers it // changed: "unreadable policy source is refused rather than reporting every name unqueried" policy/rules-drift.rego same conditioning, on a named fixed rule existing // changed: "the gate is wired into the hk gate, so a drift reddens a commit" policy/rules-drift.rego the assertion moves from the suite to the wiring itself: hk's `rules-drift` step now runs `mise run rules-drift`, which is an inline `batten check --rule rules-drift`, so the step name and the rule id are one object rather than two that a grep held together @@ -137,7 +137,7 @@ id = "fixed-rule-ref" regex = '`data\.batten\.[a-z_]+`' [[pattern]] -id = "policy-rule-const" +id = "module read first" regex = '^const [A-Z_]+_RULE: &str = "[a-z_]+";' [[pattern]] @@ -178,7 +178,7 @@ module = "policy/rules-drift.rego" severity = "deny" [[verdict]] -id = "V-RESTATED-DEFAULT-DRIFTS" +id = "default state other" gloss = "a restated env default disagrees with the mechanism" class = "fixture" @@ -188,7 +188,7 @@ kind = "document" target = "policy/rules-drift.rego" [[verdict]] -id = "V-NAMED-EVENT-UNWIRED" +id = "event wire missing" gloss = "a sentence claims a wiring nothing wires" class = "fixture" @@ -198,7 +198,7 @@ kind = "document" target = "policy/rules-drift.rego" [[verdict]] -id = "V-NAMED-INPUT-KEY-UNEMITTABLE" +id = "input key dead" gloss = "a named policy input key the schema does not carry" class = "fixture" @@ -208,7 +208,7 @@ kind = "document" target = "policy/rules-drift.rego" [[verdict]] -id = "V-NAMED-FIXED-RULE-UNQUERIED" +id = "rule ask missing" gloss = "a named fixed rule the evaluator does not query" class = "fixture" diff --git a/crates/batten/tests/it/run_shape.rs b/crates/batten/tests/it/run_shape.rs index 758af578e..451664a2d 100644 --- a/crates/batten/tests/it/run_shape.rs +++ b/crates/batten/tests/it/run_shape.rs @@ -91,7 +91,7 @@ fn fixture(name: &str) -> PathBuf { "id = \"commit-message-file-flag\"\n", "regex = \"^(-[A-Za-z]*F|--file)$\"\n\n", "[[verdict]]\n", - "id = \"V-COMMIT-WITHOUT-A-MESSAGE-SOURCE\"\n", + "id = \"commit write missing\"\n", "gloss = \"a `git commit` names no message source, so git opens $EDITOR and blocks\"\n", "class = \"\"\"\n", "No `-m`, `-F`, `-C`, `--no-edit`, `--fixup` or `--squash`. Git opens $EDITOR and \\\n", @@ -99,40 +99,40 @@ fn fixture(name: &str) -> PathBuf { "a file and use `git commit -F `, the one form that cannot rebind.\n", "\"\"\"\n\n", "[[verdict.route]]\n", - "id = \"R-COMMIT-FROM-A-FILE\"\n", + "id = \"patch run first\"\n", "kind = \"command\"\n", "target = \"git commit -F \"\n\n", "[[verdict]]\n", - "id = \"V-COMMIT-STDIN-UNBOUND\"\n", + "id = \"commit bind missing\"\n", "gloss = \"a `git commit -F -` has nothing redirected into the element it is written in\"\n", "class = \"\"\"\n", "The heredoc binds to the element that WRITES it, so git reads the harness's \\\n", "/dev/null — after `pre-commit` has already spent the whole gate.\n", "\"\"\"\n\n", "[[verdict.route]]\n", - "id = \"R-COMMIT-FROM-A-FILE-THAT-CANNOT-REBIND\"\n", + "id = \"patch run first\"\n", "kind = \"command\"\n", "target = \"git commit -F \"\n\n", "[[verdict]]\n", - "id = \"V-FOREGROUND-SLEEP\"\n", + "id = \"sleep run blocked\"\n", "gloss = \"a foreground `sleep` spends the session's own turn, and the call is killed at ~2 minutes\"\n", "class = \"\"\"\n", "A wait longer than about two minutes does not run slowly, it FAILS. Background \\\n", "the work and act on its exit notification.\n", "\"\"\"\n\n", "[[verdict.route]]\n", - "id = \"R-WAIT-ON-THE-CONDITION\"\n", + "id = \"task run first\"\n", "kind = \"command\"\n", "target = \"until ; do sleep 1; done\"\n\n", "[[verdict]]\n", - "id = \"V-BACKGROUND-TIMER\"\n", + "id = \"timer run refused\"\n", "gloss = \"a backgrounded `sleep` with no loop around it is a timer, not a wait\"\n", "class = \"\"\"\n", "It exits when the clock says so, never when the thing being waited for happens. \\\n", "The exit notification already fires.\n", "\"\"\"\n\n", "[[verdict.route]]\n", - "id = \"R-WAIT-ON-THE-CONDITION-NOT-THE-CLOCK\"\n", + "id = \"task run first\"\n", "kind = \"command\"\n", "target = \"until ; do sleep 1; done\"\n", ), @@ -400,9 +400,9 @@ fn a_backgrounded_bare_sleep_raises_the_timer_and_not_the_foreground_rule() { true, ); assert!(deny, "{text}"); - assert!(text.contains("V-BACKGROUND-TIMER"), "{text}"); + assert!(text.contains("timer run refused"), "{text}"); assert!( - !text.contains("V-FOREGROUND-SLEEP"), + !text.contains("sleep run blocked"), "the call IS backgrounded, so the foreground rule must not fire: {text}" ); } @@ -556,7 +556,7 @@ fn the_refusal_names_its_predicate_its_class_and_the_route_out() { "the predicate id: {text}" ); assert!( - text.contains("V-COMMIT-WITHOUT-A-MESSAGE-SOURCE"), + text.contains("commit write missing"), "the declared class: {text}" ); assert!( diff --git a/crates/batten/tests/it/runner_verdict.rs b/crates/batten/tests/it/runner_verdict.rs index d8fc18cf9..caeb29c3f 100644 --- a/crates/batten/tests/it/runner_verdict.rs +++ b/crates/batten/tests/it/runner_verdict.rs @@ -20,7 +20,7 @@ //! //! **WHY IT IS NOT IN THAT BATS SUITE**, which is where it belongs on subject. The //! `shell-retirement` row (`severity = "deny"`) refuses an EDITED `tests/**/*.bats` -//! as `V-SHELL-RULE-EDITED` and an ADDED one as `V-SHELL-RULE-ADDED`, and the one +//! as `shell edit refused` and an ADDED one as `shell add refused`, and the one //! admitted edit is a line whose removal names a path the same change deletes. So //! the bats corpus is closed to an addition like this one. That is CLOUD-1088 — //! *"the campaign's own door-tier suites have no landable spelling"* — and this file diff --git a/crates/batten/tests/it/semver_gate.rs b/crates/batten/tests/it/semver_gate.rs index 50c933d2e..ad6b8e020 100644 --- a/crates/batten/tests/it/semver_gate.rs +++ b/crates/batten/tests/it/semver_gate.rs @@ -7,7 +7,7 @@ //! `cargo update` in a scratch crate and so discards `Cargo.lock`, and a yank of //! `bisync` on 2026-08-26 made every commit from v0.0.89 on unresolvable seven //! minutes after the gate last passed in CI. Repairing that meant editing -//! `mise-tasks/semver.sh`, and an edit is `V-SHELL-RULE-EDITED`, which declares +//! `mise-tasks/semver.sh`, and an edit is `shell edit refused`, which declares //! no override route by design. So the repair WAS the migration. //! //! # What this tier can assert and the retired one could not diff --git a/crates/batten/tests/it/shell_write_advisory.rs b/crates/batten/tests/it/shell_write_advisory.rs index 7318cbbcc..76ee7b012 100644 --- a/crates/batten/tests/it/shell_write_advisory.rs +++ b/crates/batten/tests/it/shell_write_advisory.rs @@ -71,7 +71,7 @@ fn reported(payload: &str) -> String { } fn signals(payload: &str) -> bool { - reported(payload).contains("V-SHELL-EDIT-BEFORE-RETIREMENT") + reported(payload).contains("shell edit early") } /// A write to an authored shell gate is told at the write. @@ -289,7 +289,7 @@ fn an_advised_and_allowed_call_still_speaks() { String::from_utf8_lossy(&answer.stderr) ); assert!( - reported.contains("V-SHELL-EDIT-BEFORE-RETIREMENT"), + reported.contains("shell edit early"), "an advisory with nothing refusing it still reaches its reader: {reported}" ); } diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index 06fbce79f..e15090252 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -998,7 +998,7 @@ expression: stdout_of(&output) "short": null, "long": "verdict", "takes_value": true, - "help": "The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF" + "help": "The verdict token that refusal carries, e.g. diff ship early" } ], "subcommands": [] @@ -1034,7 +1034,7 @@ expression: stdout_of(&output) "short": null, "long": "verdict", "takes_value": true, - "help": "The verdict token that refusal carries, e.g. V-PROSE-ONLY-DIFF" + "help": "The verdict token that refusal carries, e.g. diff ship early" } ], "subcommands": [] @@ -1132,7 +1132,7 @@ expression: stdout_of(&output) "short": null, "long": null, "takes_value": true, - "help": "The verdict token to resolve, e.g. V-TASK-UNDEFINED" + "help": "The verdict token to resolve, e.g. task name undefined" } ], "subcommands": [] diff --git a/crates/batten/tests/it/stop_posture.rs b/crates/batten/tests/it/stop_posture.rs index 079c9734b..f0ecfc9e1 100644 --- a/crates/batten/tests/it/stop_posture.rs +++ b/crates/batten/tests/it/stop_posture.rs @@ -142,7 +142,7 @@ id = "hedged-flag-framing" regex = "(?i)worth (noting|flagging|mentioning|naming)|one thing (I would|I['’]?d) (flag|note)|I['’]?d (flag|note) (that|one)|I would (flag|note) that|I should (note|flag)|(it|that)['’]?s worth (noting|flagging|mentioning|naming)|bears (noting|flagging|mentioning|naming)" [[verdict]] -id = "V-HEDGED-FLAG-FRAMING" +id = "prose report duplicate" gloss = "a finding was written as editorial instead of durably" class = """ Chat stores nothing, so a finding's home is an issue or a memory. A sentence \ @@ -358,7 +358,7 @@ fn the_hot_path_drops_the_modules_own_cases_and_policy_test_keeps_them() { &stop_payload("one thing I'd flag is the ordering", false), ); assert!( - stdout_of(&out).contains("V-HEDGED-FLAG-FRAMING"), + stdout_of(&out).contains("prose report duplicate"), "the stripped module still refuses: {}", stdout_of(&out) ); diff --git a/crates/batten/tests/it/target_prune.rs b/crates/batten/tests/it/target_prune.rs index 8631531b8..8c9ef0909 100644 --- a/crates/batten/tests/it/target_prune.rs +++ b/crates/batten/tests/it/target_prune.rs @@ -30,7 +30,7 @@ //! WHY IT WAS MIGRATED AT ALL, and this one is the campaign working on its author //! a third time — after `semver` and `perf-pair`, and less creditably than //! either. CLOUD-1030 is a repair to the FLOOR, and making it meant editing an -//! authored shell rule, which `V-SHELL-RULE-EDITED` refuses with no override +//! authored shell rule, which `shell edit refused` refuses with no override //! route. The row was backlogged instead, on a two-part blocker whose decisive //! half — "the effect cannot move into a read-only engine" — was asserted without //! being checked and is false: `capture prune` has been `Effect::Destructive` in diff --git a/crates/batten/tests/it/task_prose.rs b/crates/batten/tests/it/task_prose.rs index 25e39bbf3..5ba507d32 100644 --- a/crates/batten/tests/it/task_prose.rs +++ b/crates/batten/tests/it/task_prose.rs @@ -93,7 +93,7 @@ fn the_rules_file_names_the_command_fmt_runs() { /// the regression unguarded in both directions; asserting the opposite keeps one /// case on the sentence and moves which way it points. What now stops the /// regression on the CONFIG side — where it actually lives — is -/// `hk-fix-selection`, whose `V-FMT-DESCRIBED-AS-THE-GATE` reads this same clause +/// `hk-fix-selection`, whose `task state wrong` reads this same clause /// and `fix-selection-complete`, which holds hk's own selection to the gate's /// fixer-bearing steps in both directions. Prose alone was never the mechanism; /// it is the half a reader sees. diff --git a/crates/batten/tests/it/tool_verdict_facts.rs b/crates/batten/tests/it/tool_verdict_facts.rs index 297f95e1d..30fcc661a 100644 --- a/crates/batten/tests/it/tool_verdict_facts.rs +++ b/crates/batten/tests/it/tool_verdict_facts.rs @@ -64,7 +64,7 @@ //! the environment variable `RENOVATE_CONFIG` — the same name renovate 44 reads //! as INLINE JSON5 config — so the validator was handed a PATH and died parsing //! it as content. Renaming the seam meant editing authored shell frozen by -//! `V-SHELL-RULE-EDITED`, whose sole route is `R-PORT-AND-RETIRE`. This is that +//! `shell edit refused`, whose sole route is `rule read first`. This is that //! route: the path is now an argument in `[tasks.record-verdicts]` and the input //! is `batten.toml`'s `renovate-config` row, so there is no variable left to //! collide. diff --git a/crates/batten/tests/it/verdict_registry.rs b/crates/batten/tests/it/verdict_registry.rs index fd64eaf70..4effc9710 100644 --- a/crates/batten/tests/it/verdict_registry.rs +++ b/crates/batten/tests/it/verdict_registry.rs @@ -202,7 +202,8 @@ fn a_tombstoned_token_that_is_still_raised_is_refused() { fn a_tombstone_resolves_through_its_chain() { let mut table = common::verdicts(&["V-OLD", "V-NEW"]); table[0].successor = Some("V-NEW".to_owned()); - verdict::validate(&table).expect("a terminating chain is well formed"); + verdict::validate(&table, &batten::verdict::Vocabulary::default()) + .expect("a terminating chain is well formed"); let (resolved, retired) = verdict::resolve(&table, "V-OLD").expect("the token resolves"); assert_eq!(resolved.id, "V-NEW"); assert!(retired); @@ -223,7 +224,8 @@ fn a_tombstone_resolves_through_its_chain() { fn a_row_naming_only_a_withdrawal_loads_and_reports_retired() { let mut table = common::verdicts(&["V-GONE"]); table[0].withdrawn = Some("the thing it refused is no longer refused by anything".to_owned()); - verdict::validate(&table).expect("a withdrawal is a well-formed retirement"); + verdict::validate(&table, &batten::verdict::Vocabulary::default()) + .expect("a withdrawal is a well-formed retirement"); assert!( table[0].retired(), "a withdrawn class is as retired as a replaced one" @@ -257,7 +259,8 @@ fn an_empty_withdrawal_reason_is_refused() { for blank in ["", " ", "\n"] { let mut table = common::verdicts(&["V-GONE"]); table[0].withdrawn = Some(blank.to_owned()); - let err = verdict::validate(&table).expect_err("an empty withdrawal explains nothing"); + let err = verdict::validate(&table, &batten::verdict::Vocabulary::default()) + .expect_err("an empty withdrawal explains nothing"); let text = format!("{err}"); assert!(text.contains("V-GONE"), "the refusal names the id: {text}"); assert!( @@ -274,7 +277,8 @@ fn a_row_naming_both_arms_is_refused() { let mut table = common::verdicts(&["V-OLD", "V-NEW"]); table[0].successor = Some("V-NEW".to_owned()); table[0].withdrawn = Some("and also nobody refuses it".to_owned()); - let err = verdict::validate(&table).expect_err("a row cannot be both replaced and withdrawn"); + let err = verdict::validate(&table, &batten::verdict::Vocabulary::default()) + .expect_err("a row cannot be both replaced and withdrawn"); let text = format!("{err}"); assert!(text.contains("V-OLD"), "the refusal names the id: {text}"); assert!(text.contains("successor"), "{text}"); @@ -287,13 +291,15 @@ fn a_row_naming_both_arms_is_refused() { fn the_withdrawal_arm_weakens_neither_successor_refusal() { let mut dangling = common::verdicts(&["V-OLD"]); dangling[0].successor = Some("V-NEVER-DECLARED".to_owned()); - let err = verdict::validate(&dangling).expect_err("a successor nothing declares is refused"); + let err = verdict::validate(&dangling, &batten::verdict::Vocabulary::default()) + .expect_err("a successor nothing declares is refused"); assert!(format!("{err}").contains("V-NEVER-DECLARED")); let mut cycle = common::verdicts(&["V-A", "V-B"]); cycle[0].successor = Some("V-B".to_owned()); cycle[1].successor = Some("V-A".to_owned()); - let err = verdict::validate(&cycle).expect_err("a chain that cycles terminates nowhere"); + let err = verdict::validate(&cycle, &batten::verdict::Vocabulary::default()) + .expect_err("a chain that cycles terminates nowhere"); assert!(format!("{err}").contains("cycles")); } @@ -304,7 +310,7 @@ fn the_withdrawal_arm_weakens_neither_successor_refusal() { fn a_withdrawal_is_not_read_as_a_successor() { let mut table = common::verdicts(&["V-GONE"]); table[0].withdrawn = Some("V-SOMETHING-THAT-IS-NOT-A-TOKEN".to_owned()); - verdict::validate(&table) + verdict::validate(&table, &batten::verdict::Vocabulary::default()) .expect("a withdrawal reason is prose, never a token the registry must declare"); } @@ -317,10 +323,10 @@ fn a_withdrawal_is_not_read_as_a_successor() { #[test] fn a_consumer_row_colliding_with_a_vendored_class_is_refused() { let mut table = declared(); - table.extend(common::verdicts(&["V-EMPTY-COMMIT"])); + table.extend(common::verdicts(&["commit ship empty"])); let err = load("collision", CONFORMING, &table) .expect_err("a class with two definitions is refused rather than resolved"); - assert!(format!("{err}").contains("V-EMPTY-COMMIT")); + assert!(format!("{err}").contains("commit ship empty")); } /// **A preset loads against a registry the consumer never wrote.** Holding it to @@ -356,7 +362,7 @@ fn a_vendored_preset_loads_with_no_consumer_rows_at_all() { panic!("the preset answered could-not-look"); }; assert_eq!(denials.len(), 1); - assert_eq!(denials[0].verdict, "V-EMPTY-COMMIT"); + assert_eq!(denials[0].verdict, "commit ship empty"); } // --------------------------------------------------------------------------- diff --git a/crates/batten/tests/it/verdict_vocabulary.rs b/crates/batten/tests/it/verdict_vocabulary.rs index 6b44447b6..9f1da527a 100644 --- a/crates/batten/tests/it/verdict_vocabulary.rs +++ b/crates/batten/tests/it/verdict_vocabulary.rs @@ -9,16 +9,14 @@ //! the commit, and the table is a fixed committed artifact, so its token counts //! are a property of the commit rather than of the world. -/// The measured dictionary: every word the three-word grammar may draw on. +/// Every word `batten.toml`'s `[vocabulary]` declares, held to one token each. /// -/// **This is half of CLOUD-1284 and says so.** The row's other five arms decide -/// arity, membership, uniqueness, orphans and glosses over a `[vocabulary]` -/// table in `batten.toml`, and that table is not declared yet — the conversion -/// is all-or-nothing (`policy::check_registry_is_exhausted` refuses a -/// declared-but-unraised token and `check_verdicts_are_declared` refuses a -/// raised-but-undeclared one, so a half-renamed registry does not load). What -/// lands here first is the measurement those arms depend on, because the -/// vocabulary cannot be curated without it. +/// **Why a mirrored list rather than a read of the table.** The other five arms +/// are load-time and read the config; this one cannot be, because the tokenizer +/// is a dev-dependency the binary does not link. A test that parsed the config +/// would still be measuring the same bytes, so the list is duplicated and +/// `a_declared_word_is_missing_from_this_list` holds the two in agreement — the +/// duplication is visible and gated rather than implicit and drifting. /// /// Sifted from 250 candidates: **237 are one token, 13 are not**, and the 13 are /// a class rather than a scatter — `unparsed`, `unwired`, `untested`, `ungated`, @@ -27,9 +25,6 @@ /// `unnamed`, `unused`, `unmet` and `unseen` survive at 1. That is the issue's /// own worked example (`shell edit unretired` is 5 tokens, not 3) reproduced as /// a gate rather than an anecdote. -/// -/// When the table lands this list is replaced by a read of the declared words, -/// and the assertion is unchanged. const CANDIDATES: &[&str] = &[ "absent", "adapter", @@ -44,8 +39,6 @@ const CANDIDATES: &[&str] = &[ "bound", "branch", "broken", - "build", - "cache", "call", "cargo", "carry", @@ -59,7 +52,6 @@ const CANDIDATES: &[&str] = &[ "dead", "declare", "default", - "denied", "deny", "diff", "dirty", @@ -71,23 +63,21 @@ const CANDIDATES: &[&str] = &[ "empty", "event", "file", - "finish", - "forced", + "first", "forge", "gate", "grade", "grant", "guard", - "handler", "held", "hook", "input", - "install", "issue", "job", "judge", "key", "lane", + "last", "late", "layer", "lease", @@ -97,7 +87,6 @@ const CANDIDATES: &[&str] = &[ "manifest", "measure", "memory", - "merge", "mint", "missing", "module", @@ -116,14 +105,12 @@ const CANDIDATES: &[&str] = &[ "port", "program", "prose", - "push", "reach", "read", "red", "refused", "release", "remedy", - "render", "report", "require", "resolve", @@ -133,7 +120,6 @@ const CANDIDATES: &[&str] = &[ "rule", "run", "same", - "scanner", "select", "shell", "ship", @@ -143,7 +129,6 @@ const CANDIDATES: &[&str] = &[ "spawn", "spelling", "stale", - "start", "state", "step", "suite", @@ -155,13 +140,11 @@ const CANDIDATES: &[&str] = &[ "tier", "timer", "tool", - "trunk", "turn", "twice", "unclear", "undefined", "unknown", - "unmet", "unnamed", "unread", "unsafe", @@ -208,3 +191,48 @@ fn every_candidate_word_is_one_token_under_the_declared_pin() { multi ); } + +/// The list above and the committed table are the same set, in both directions. +/// +/// Without this the measurement is over a list nobody declares: a word added to +/// `[vocabulary]` and not here would be unmeasured, and a word here and not in +/// the table would be measured and unused. Both directions are asserted because +/// only one of them is the obvious one. +#[test] +fn the_measured_list_and_the_declared_table_are_the_same_set() { + let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let text = + std::fs::read_to_string(root.join("batten.toml")).expect("the authority is readable"); + let config: toml::Value = toml::from_str(&text).expect("the authority parses"); + let vocabulary = config + .get("vocabulary") + .expect("the authority declares a vocabulary"); + + let mut declared: Vec = Vec::new(); + for slot in ["subject", "action", "condition"] { + let rows = vocabulary + .get(slot) + .and_then(toml::Value::as_array) + .unwrap_or_else(|| panic!("`[[vocabulary.{slot}]]` is declared")); + for row in rows { + let word = row + .get("word") + .and_then(toml::Value::as_str) + .expect("every vocabulary row carries a word"); + declared.push(word.to_owned()); + } + } + declared.sort(); + declared.dedup(); + + let measured: std::collections::BTreeSet<&str> = CANDIDATES.iter().copied().collect(); + let declared_set: std::collections::BTreeSet<&str> = + declared.iter().map(String::as_str).collect(); + + let unmeasured: Vec<&&str> = declared_set.difference(&measured).collect(); + let undeclared: Vec<&&str> = measured.difference(&declared_set).collect(); + assert!( + unmeasured.is_empty() && undeclared.is_empty(), + "declared-but-unmeasured: {unmeasured:?}; measured-but-undeclared: {undeclared:?}" + ); +} diff --git a/crates/batten/tests/policy_modules.rs b/crates/batten/tests/policy_modules.rs index a43ce1906..cde79b873 100644 --- a/crates/batten/tests/policy_modules.rs +++ b/crates/batten/tests/policy_modules.rs @@ -1001,7 +1001,7 @@ import rego.v1 rules contains "no-force-push" -violation contains {"rule": "no-force-push", "verdict": "V-FORCE-PUSH-AT-TRUNK"} if { +violation contains {"rule": "no-force-push", "verdict": "trunk push forced"} if { input.call.operation == "write" } "#; @@ -1026,7 +1026,7 @@ violation contains {"rule": "no-force-push", "verdict": "V-FORCE-PUSH-AT-TRUNK"} }; assert_eq!( violations, - vec![attributed("no-force-push", "V-FORCE-PUSH-AT-TRUNK")], + vec![attributed("no-force-push", "trunk push forced")], "the package prefix is `batten` and the RULE NAMES are what is fixed; \ pinning the whole path leaves this module silently unreachable" ); @@ -1067,7 +1067,7 @@ import data.batten.shared rules contains "no-protected-write" -violation contains {"rule": "no-protected-write", "verdict": "V-PROTECTED-MUTATION"} if { +violation contains {"rule": "no-protected-write", "verdict": "path write refused"} if { shared.is_protected(input.call.path) } "#; @@ -1091,7 +1091,7 @@ violation contains {"rule": "no-protected-write", "verdict": "V-PROTECTED-MUTATI }; assert_eq!( violations, - vec![attributed("no-protected-write", "V-PROTECTED-MUTATION")], + vec![attributed("no-protected-write", "path write refused")], "module B called module A's helper; under per-module isolation this \ evaluates to `could not find function shared.is_protected`" ); diff --git a/mise.toml b/mise.toml index ff0024379..a11a7a458 100644 --- a/mise.toml +++ b/mise.toml @@ -206,8 +206,8 @@ node = "24.20.0" # `mise-tasks/renovate-config-validator.sh:32` used that same name for the path # seam its suite set — so the validator was handed `/tmp/.../renovate.json5` as # CONTENT and died at `invalid character 't' at 1:2`. Repairing it meant editing -# authored shell frozen by `V-SHELL-RULE-EDITED`, whose sole route is -# `R-PORT-AND-RETIRE`, which is why the bump owed a retirement rather than a +# authored shell frozen by `shell edit refused`, whose sole route is +# `rule read first`, which is why the bump owed a retirement rather than a # rename. That retirement has now landed: the path is an argument in # `[tasks.record-verdicts]` and the input is `batten.toml`'s `renovate-config` # row, so there is no environment variable left to collide. @@ -552,7 +552,18 @@ shell = "bash -c" # there it fails `no_pending_snapshot_is_left_in_the_tree` inside this very task, # so accepting a changed snapshot could never succeed. Measured on the first # machine-surface change after this task landed. -run = "find crates/batten/tests/snapshots -name '*.snap.new' -delete && INSTA_UPDATE=always cargo test --test snapshots" +# REPOINTED BY THE TARGET CONSOLIDATION (CLOUD-1210), which this task did not +# follow and which left it unable to run at all: the corpus moved to +# `tests/it/snapshots` and `--test snapshots` names a target that no longer +# exists, so `find` failed on a missing directory and the accept half never +# reached `cargo`. Found by the first machine-surface change after that landed — +# the same way the note above it was found. +# +# THE FILTER IS WHAT KEEPS THE BOUND THE NOTE ABOVE ARGUES FOR. With one target, +# `--test it` would run the whole suite under `cargo test`'s threads-in-one- +# process model, which is exactly what `document_read_count` cannot survive. A +# module filter selects the same cases the old per-file target did. +run = "find crates/batten/tests/it/snapshots -name '*.snap.new' -delete && INSTA_UPDATE=always cargo test --test it snapshots::" [tasks.fix] description = "Everything `fmt` does, plus clippy's autofixes and the derived artifacts — the one command that empties a messy tree" @@ -1127,7 +1138,7 @@ description = "Gate: the API delta on this branch is compatible with the bump re # discarding `Cargo.lock` — so on 2026-08-26 a yank of `bisync` made every commit # from v0.0.89 on unresolvable, seven minutes after this gate passed in CI. The # repair was a fallback that builds the baseline from its committed lock, and -# adding it meant EDITING an authored shell rule, which `V-SHELL-RULE-EDITED` +# adding it meant EDITING an authored shell rule, which `shell edit refused` # refuses with no override route. Maintenance of a shell-tier rule is completed # by migrating it; that is the whole claim, and this is the first time it was # tested against a repair somebody actually needed. @@ -1153,7 +1164,7 @@ description = "Gate: the issue you are about to pull is actually unclaimed (read # does not, so the two could never agree for any body not already ending in one — # the rule refused every claim in every clone, and the only way past it was the # bypass it reserves for a human's visible decision. Making that repair meant -# EDITING an authored shell rule, which `V-SHELL-RULE-EDITED` refuses with no +# EDITING an authored shell rule, which `shell edit refused` refuses with no # override route; and the repair is also the thing the shell could not express # safely, because reader and writer had two spellings of one hash. The port shares # `git::blob_id` with the minting side, so a second spelling is unwritable rather @@ -1184,7 +1195,7 @@ description = "Measure this branch and its merge base back to back on one machin # config, and the skip could see neither that config nor any path a `policy` row # registers. Measured, a policy row alone moved `wired` 5.8ms -> 9.3ms while the # gate reported "nothing measured". Making that repair meant EDITING an authored -# shell rule, which `V-SHELL-RULE-EDITED` refuses with no override route — and the +# shell rule, which `shell edit refused` refuses with no override route — and the # repair is also the thing a shell skip could not express, because the widened set # is DERIVED from the loaded config rather than written down. # @@ -1240,7 +1251,7 @@ description = "Reclaim superseded build artifacts, and refuse below a measured d # THE TASK NAME SURVIVES THE MIGRATION, for `perf-pair`'s reason above and with # the same edge: `verify`'s body and `mise-tasks/land.sh` both invoke # `mise run target-prune` by name, and `land.sh` is an authored shell rule -# `V-SHELL-RULE-EDITED` refuses to see edited. Keeping the name is what holds +# `shell edit refused` refuses to see edited. Keeping the name is what holds # this retirement to the one program being retired. # # `-y` RATHER THAN A PROMPT, and it is house-style §5's binding on a destructive @@ -1377,7 +1388,7 @@ description = "Effect: run each declared third-party validator OUTSIDE the engin # # AN INLINE TASK RATHER THAN A `mise-tasks/` PROGRAM, and it is forced: # `governed_at_head` selects any `mise-tasks/` path carrying a shebang or a -# `#MISE description=`, so a new program there is `V-SHELL-RULE-ADDED` — refused +# `#MISE description=`, so a new program there is `shell add refused` — refused # at `deny`, in the same change that is retiring four of them. # # THE REDUCTION IS THIS REPOSITORY'S, WHICH IS WHY IT LIVES HERE. `status clean` @@ -1529,7 +1540,7 @@ description = "Measure tree-surface acquisition cost as declared-document count # # THE MEASUREMENT IS `batten perf acquire`, AND IT USED TO BE PYTHON (CLOUD-1229). # This comment used to argue that `bench/acquisition/sweep.py` was forced: an -# authored shell rule cannot be ADDED (`V-SHELL-RULE-ADDED`, one `document` route, +# authored shell rule cannot be ADDED (`shell add refused`, one `document` route, # no override, no `bypass_env`), so the measurement went to the one language the # retirement campaign does not watch. That reading was wrong in a way this line # then propagated — a second author read it, followed the precedent for the @@ -2373,7 +2384,7 @@ fi # `mise-tasks/` programs and by the Rust tier; `hooks-wiring-check.sh:219` # declares the same seam under its own name for its `doctor hooks -J` call and # only 2 of its 36 cases ever set it. Exported here so every already-honouring -# program is reached with ZERO governed-file edits — `V-SHELL-RULE-EDITED` +# program is reached with ZERO governed-file edits — `shell edit refused` # refuses touching any of them, which is the whole reason this change lives in # the ungoverned task and nowhere else. BATTEN_BIN="$PWD/target/debug/batten" @@ -2731,7 +2742,7 @@ fi # reported as a full volume, which is the misattribution the row is about, one # layer out. It cannot be written HERE: `tests/verify.bats`'s "a volume that # cannot be recovered is a STOP, not a lap" pins exit 1 to that exact phrase, and -# `V-SHELL-RULE-EDITED` refuses an edit to a bats suite with no override route +# `shell edit refused` refuses an edit to a bats suite with no override route # and no `bypass_env`. So CLOUD-1153 is a row whose §1 is written in the wrong # shape — it needs the classification where a gate can already see it, not a # fourth authority in this body — and it is left on the board rather than diff --git a/policy/ancestry-decides-nothing.rego b/policy/ancestry-decides-nothing.rego index c3408a508..55bec4560 100644 --- a/policy/ancestry-decides-nothing.rego +++ b/policy/ancestry-decides-nothing.rego @@ -60,7 +60,7 @@ violation contains { "rule": "ancestry-decides-nothing", # The site first, then the token that gave it away: the fix is at the line, # and the token is what a reader searches for once there. - "verdict": "V-ANCESTRY-DECIDES-MERGEDNESS", + "verdict": "patch judge wrong", "subjects": [{"path": path, "line": site.line}, {"artifact": token}], } if { some path, sites in input.tree.invocations diff --git a/policy/bats-invocation.rego b/policy/bats-invocation.rego index 631ada7b3..17d787e44 100644 --- a/policy/bats-invocation.rego +++ b/policy/bats-invocation.rego @@ -15,9 +15,9 @@ # stops being watched, and a reader who sees one finding should not have to guess # which: # -# V-BATS-NOT-PARALLEL the run is serial, or is about to be -# V-BATS-RUN-UNCOUNTED the run proves less than it appears to -# V-BATS-COST-UNMEASURED nothing compares what the run cost against a record +# suite run late the run is serial, or is about to be +# suite count missing the run proves less than it appears to +# suite measure missing nothing compares what the run cost against a record # # The third is CLOUD-386's third measurement (2026-08-28) and is the new half: # `test:bats` was 1435.7s at 2 workers on CI against a 1249.1s recorded serial @@ -136,7 +136,7 @@ cost_markers := { violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-NOT-PARALLEL", + "verdict": "suite run late", "subjects": [{"path": "mise.toml"}, {"artifact": marker}], } if { governed @@ -146,7 +146,7 @@ violation contains { violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-NOT-PARALLEL", + "verdict": "suite run late", "subjects": [{"path": "mise.toml"}, {"artifact": spelling}], } if { governed @@ -167,7 +167,7 @@ nproc_mentions := [line | violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-NOT-PARALLEL", + "verdict": "suite run late", "subjects": [{"path": "mise.toml"}, {"count": count(nproc_mentions)}], } if { governed @@ -179,7 +179,7 @@ violation contains { # pinned nowhere here. violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-NOT-PARALLEL", + "verdict": "suite run late", "subjects": [{"path": "mise.toml"}, {"artifact": "aqua:shenwei356/rush"}], } if { governed @@ -213,7 +213,7 @@ ci_install_args := [args | violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-NOT-PARALLEL", + "verdict": "suite run late", "subjects": [ {"path": ".github/workflows/ci.yml"}, {"artifact": "aqua:shenwei356/rush"}, @@ -230,7 +230,7 @@ violation contains { violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-RUN-UNCOUNTED", + "verdict": "suite count missing", "subjects": [{"path": "mise.toml"}, {"artifact": marker}], } if { governed @@ -240,7 +240,7 @@ violation contains { violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-RUN-UNCOUNTED", + "verdict": "suite count missing", "subjects": [{"path": "mise.toml"}, {"artifact": spelling}], } if { governed @@ -252,7 +252,7 @@ violation contains { violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-COST-UNMEASURED", + "verdict": "suite measure missing", "subjects": [{"path": "mise.toml"}, {"artifact": marker}], } if { governed @@ -280,7 +280,7 @@ measured_date(line) := date if { violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-COST-UNMEASURED", + "verdict": "suite measure missing", "subjects": [ {"path": "mise.toml"}, {"artifact": "# sweep: measured= cores="}, @@ -303,7 +303,7 @@ violation contains { # number nobody measured. violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-COST-UNMEASURED", + "verdict": "suite measure missing", "subjects": [{"path": ".github/workflows/ci.yml", "line": index + 1}], } if { governed @@ -322,7 +322,7 @@ violation contains { # failure yet, so this clause is right and the channel is not yet filled.) violation contains { "rule": "bats-invocation", - "verdict": "V-BATS-SOURCE-UNREAD", + "verdict": "bats parse unread", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -387,37 +387,37 @@ test_a_dropped_jobs_flag_is_refused if { found := violation with input as sound_input(replace(sound_body, `--jobs "$workers"`, "")) count(found) > 0 some finding in found - finding.verdict == "V-BATS-NOT-PARALLEL" + finding.verdict == "suite run late" } test_a_count_of_one_is_refused_even_though_bats_refuses_it_too if { found := violation with input as sound_input(concat("", [sound_body, " --jobs 1"])) some finding in found - finding.verdict == "V-BATS-NOT-PARALLEL" + finding.verdict == "suite run late" } test_a_count_capped_below_the_machine_is_refused if { found := violation with input as sound_input(concat("", [sound_body, " workers=$(($(nproc) / 2))"])) some finding in found - finding.verdict == "V-BATS-NOT-PARALLEL" + finding.verdict == "suite run late" } test_a_run_that_counts_nothing_is_refused if { found := violation with input as sound_input(replace(sound_body, `[ "$ran" != "$expected" ]`, "")) some finding in found - finding.verdict == "V-BATS-RUN-UNCOUNTED" + finding.verdict == "suite count missing" } test_a_discarded_report_is_refused if { found := violation with input as sound_input(concat("", [sound_body, " report=$(mktemp -d)"])) some finding in found - finding.verdict == "V-BATS-RUN-UNCOUNTED" + finding.verdict == "suite count missing" } test_a_run_that_measures_no_cost_is_refused if { found := violation with input as sound_input(replace(sound_body, `[ "$elapsed" -ge "$recorded" ]`, "")) some finding in found - finding.verdict == "V-BATS-COST-UNMEASURED" + finding.verdict == "suite measure missing" } # THE PREDICATE THAT PRODUCED THIS ROW: a sweep table that does not say what @@ -432,7 +432,7 @@ test_a_sweep_without_its_hardware_is_refused if { "missing": [], }} some finding in found - finding.verdict == "V-BATS-COST-UNMEASURED" + finding.verdict == "suite measure missing" } # AND A BUDGET OLDER THAN THE SWEEP: the pole moved and the workflow that runs it @@ -450,7 +450,7 @@ test_a_budget_older_than_the_sweep_is_refused if { "missing": [], }} some finding in found - finding.verdict == "V-BATS-COST-UNMEASURED" + finding.verdict == "suite measure missing" } # A GRANDFATHERED ROW IS NOT STALE, it is undeclared, and it is CLOUD-352's. @@ -490,5 +490,5 @@ test_an_unreadable_manifest_is_loud if { }} count(found) == 1 some finding in found - finding.verdict == "V-BATS-SOURCE-UNREAD" + finding.verdict == "bats parse unread" } diff --git a/policy/ci-parity.rego b/policy/ci-parity.rego index d542a703b..6be108b3a 100644 --- a/policy/ci-parity.rego +++ b/policy/ci-parity.rego @@ -152,7 +152,7 @@ ci_task_used contains [path, task] if { violation contains { "rule": "ci-task-parity", - "verdict": "V-CI-TASK-NOT-IN-VERIFY", + "verdict": "task run missing", "subjects": [{"path": path}, {"artifact": task}], } if { governed @@ -184,7 +184,7 @@ roster_name_has_a_job(name) if name in job_display_names violation contains { "rule": "required-roster-matches-jobs", - "verdict": "V-REQUIRED-CHECK-NAMES-NO-JOB", + "verdict": "check name unknown", "subjects": [{"path": "mise.toml"}, {"artifact": name}], } if { governed @@ -194,7 +194,7 @@ violation contains { violation contains { "rule": "required-roster-matches-jobs", - "verdict": "V-JOB-NOT-IN-REQUIRED-ROSTER", + "verdict": "job list missing", "subjects": [{"path": "mise.toml"}, {"artifact": name}], } if { governed @@ -214,7 +214,7 @@ release_config := input.tree.documents["release-plz.toml"] violation contains { "rule": "release-pr-opens-as-a-draft", - "verdict": "V-RELEASE-PR-NOT-DRAFT", + "verdict": "release open early", "subjects": [{"path": "release-plz.toml"}], } if { governed @@ -236,7 +236,7 @@ dependabot_absent if not ".github/dependabot.yml" in input.tree.tracked violation contains { "rule": "one-bot-serves-every-ecosystem", - "verdict": "V-DEPENDABOT-RETURNED", + "verdict": "config carry duplicate", "subjects": [{"path": ".github/dependabot.yml"}], } if { governed @@ -260,7 +260,7 @@ renovate_key_ok("vulnerabilityAlerts") if is_object(renovate.vulnerabilityAlerts violation contains { "rule": "one-bot-serves-every-ecosystem", - "verdict": "V-RENOVATE-BOUND-MISSING", + "verdict": "bound declare missing", "subjects": [{"path": "renovate.json5"}, {"artifact": key}], } if { governed @@ -284,7 +284,7 @@ commit_type_is_scoped if { violation contains { "rule": "one-bot-serves-every-ecosystem", - "verdict": "V-RENOVATE-COMMIT-TYPE-UNSCOPED", + "verdict": "commit name unnamed", "subjects": [{"path": "renovate.json5"}], } if { governed @@ -301,7 +301,7 @@ maintained_ecosystems := ["cargo", "github-actions", "mise"] violation contains { "rule": "one-bot-serves-every-ecosystem", - "verdict": "V-ECOSYSTEM-UNSERVED", + "verdict": "manifest cover missing", "subjects": [{"path": "renovate.json5"}, {"artifact": eco}], } if { governed @@ -328,7 +328,7 @@ fanin_workflow := manifest.env.CI_FANIN_WORKFLOW violation contains { "rule": "fan-in-is-wired", - "verdict": "V-FANIN-NOT-REQUIRED", + "verdict": "job require missing", "subjects": [{"path": "mise.toml"}, {"artifact": fanin_check}], } if { governed @@ -337,7 +337,7 @@ violation contains { violation contains { "rule": "fan-in-is-wired", - "verdict": "V-FANIN-WORKFLOW-DECLARES-NO-JOB", + "verdict": "workflow declare empty", "subjects": [{"path": fanin_workflow}, {"artifact": fanin_check}], } if { governed @@ -360,7 +360,7 @@ abandon_reads_declaration if { violation contains { "rule": "fan-in-is-wired", - "verdict": "V-ABANDON-RESTATES-THE-FANIN", + "verdict": "job declare duplicate", "subjects": [{"path": "mise-tasks/abandon-matrix.sh"}], } if { governed @@ -378,7 +378,7 @@ lander_calls_abandon if { violation contains { "rule": "fan-in-is-wired", - "verdict": "V-ABANDON-NEVER-CALLED", + "verdict": "job reach dead", "subjects": [{"path": "mise-tasks/land.sh"}], } if { governed @@ -409,7 +409,7 @@ starts_with_the_lease(path, name) if { violation contains { "rule": "lease-authorises-before-spending", - "verdict": "V-LEASE-PRECONDITION-ABSENT", + "verdict": "lease guard absent", "subjects": [{"path": path}, {"artifact": name}], } if { governed @@ -443,7 +443,7 @@ lease_tolerant(path) := count([line | violation contains { "rule": "lease-authorises-before-spending", - "verdict": "V-LEASE-PRECONDITION-FATAL", + "verdict": "lease guard unsafe", "subjects": [{"path": path}], } if { governed @@ -475,7 +475,7 @@ decides_through_checks_green(path) if { violation contains { "rule": "check-status-decided-in-one-place", - "verdict": "V-CHECK-STATUS-REROLLED", + "verdict": "check grade twice", "subjects": [{"path": path}], } if { governed @@ -518,7 +518,7 @@ watched(prefix) if { violation contains { "rule": "every-bot-branch-has-a-watcher", - "verdict": "V-BOT-PREFIX-UNWATCHED", + "verdict": "branch watch missing", "subjects": [{"path": config}, {"artifact": bot_prefix(config)}], } if { governed @@ -535,7 +535,7 @@ violation contains { # green over a file it never read. violation contains { "rule": "ci-task-parity", - "verdict": "V-CI-WORKFLOW-UNREAD", + "verdict": "workflow read unread", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -604,7 +604,7 @@ foreign_cargo contains [path, number, cmd] if { violation contains { "rule": "foreign-cargo-is-the-declared-spelling", - "verdict": "V-FOREIGN-CARGO-SPELLING-DRIFT", + "verdict": "cargo spelling other", "subjects": [{"path": path, "line": number}, {"artifact": cmd}], } if { governed @@ -620,7 +620,7 @@ violation contains { # that carries a copy of this config and none of its subjects. violation contains { "rule": "foreign-cargo-is-the-declared-spelling", - "verdict": "V-FOREIGN-CARGO-ABSENT", + "verdict": "cargo reach absent", "subjects": [{"count": 0}], } if { governed @@ -631,7 +631,7 @@ violation contains { violation contains { "rule": "foreign-cargo-is-the-declared-spelling", - "verdict": "V-TASK-CARGO-UNREADABLE", + "verdict": "task read unread", "subjects": [{"artifact": "test:cargo"}], } if { governed @@ -724,7 +724,7 @@ test_a_foreign_leg_running_a_different_cargo_is_refused if { drifted := object.union(sound_input.tree.lines, {".github/workflows/rust.yml": [" - run: mise exec -- cargo nextest run --workspace --all-features"]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": drifted})} some f in found - f.verdict == "V-FOREIGN-CARGO-SPELLING-DRIFT" + f.verdict == "cargo spelling other" } # THE ANTI-VACUITY TERM. Every clause above judges a foreign leg; none of them @@ -737,7 +737,7 @@ test_a_tree_with_no_foreign_cargo_leg_is_refused if { blank := object.union(object.remove(sound_input.tree, ["lines"]), {"lines": {}}) found := violation with input as {"tree": blank} some f in found - f.verdict == "V-FOREIGN-CARGO-ABSENT" + f.verdict == "cargo reach absent" } # A `--no-run` build compiles and executes nothing, so it covers nothing and @@ -748,7 +748,7 @@ test_a_no_run_build_is_exempt_and_does_not_satisfy_the_term if { only_no_run := object.union(sound_input.tree.lines, {".github/workflows/rust.yml": [" - run: mise exec -- cargo nextest run --no-run --workspace"]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": only_no_run})} some f in found - f.verdict == "V-FOREIGN-CARGO-ABSENT" + f.verdict == "cargo reach absent" } # A manifest whose `test:cargo` carries no readable cargo line is could-not-look, @@ -757,7 +757,7 @@ test_a_task_yielding_no_cargo_line_is_refused if { blind := object.union(sound_manifest.tasks, {"test:cargo": {"run": "./mise-tasks/step-receipt.sh check test:cargo"}}) found := violation with input as swap("mise.toml", object.union(sound_manifest, {"tasks": blind})) some f in found - f.verdict == "V-TASK-CARGO-UNREADABLE" + f.verdict == "task read unread" } test_a_sound_tree_is_clean if { @@ -771,7 +771,7 @@ test_a_ci_task_verify_does_not_run_is_refused if { } found := violation with input as swap(".github/workflows/ci.yml", wf) some f in found - f.verdict == "V-CI-TASK-NOT-IN-VERIFY" + f.verdict == "task run missing" some s in f.subjects s.artifact == "smoke" } @@ -786,7 +786,7 @@ test_a_windows_job_may_run_a_task_verify_does_not if { } found := violation with input as swap(".github/workflows/ci.yml", wf) every f in found { - f.verdict != "V-CI-TASK-NOT-IN-VERIFY" + f.verdict != "task run missing" } } @@ -800,7 +800,7 @@ test_an_unclassified_runner_is_still_judged if { } found := violation with input as swap(".github/workflows/ci.yml", wf) some f in found - f.verdict == "V-CI-TASK-NOT-IN-VERIFY" + f.verdict == "task run missing" } # A TASK NAMED OUTSIDE A `run:` SCALAR IS NOT SPEND, which is the reading the @@ -825,7 +825,7 @@ test_a_task_named_outside_a_run_step_is_not_read_as_spend if { } found := violation with input as swap(".github/workflows/ci.yml", wf) every f in found { - f.verdict != "V-CI-TASK-NOT-IN-VERIFY" + f.verdict != "task run missing" } } @@ -833,7 +833,7 @@ test_a_job_missing_from_the_roster_is_refused if { wf := object.union(sound_workflow, {"jobs": {"extra": {"name": "extra", "runs-on": "ubuntu-latest", "steps": [{"run": "mise run lint"}]}}}) found := violation with input as swap(".github/workflows/ci.yml", wf) some f in found - f.verdict == "V-JOB-NOT-IN-REQUIRED-ROSTER" + f.verdict == "job list missing" some s in f.subjects s.artifact == "extra" } @@ -842,7 +842,7 @@ test_a_roster_name_matching_no_job_is_refused if { m := object.union(sound_manifest, {"env": object.union(sound_manifest.env, {"CI_REQUIRED_CHECKS": "ci,final,ghost"})}) found := violation with input as swap("mise.toml", m) some f in found - f.verdict == "V-REQUIRED-CHECK-NAMES-NO-JOB" + f.verdict == "check name unknown" some s in f.subjects s.artifact == "ghost" } @@ -853,28 +853,28 @@ test_a_matrix_leg_matches_on_its_base_name if { m := object.union(sound_manifest, {"env": object.union(sound_manifest.env, {"CI_REQUIRED_CHECKS": "ci (ubuntu-latest),final"})}) found := violation with input as swap("mise.toml", m) every f in found { - f.verdict != "V-REQUIRED-CHECK-NAMES-NO-JOB" + f.verdict != "check name unknown" } } test_a_release_config_that_does_not_open_a_draft_is_refused if { found := violation with input as swap("release-plz.toml", {"pr": {"pr_draft": false}}) some f in found - f.verdict == "V-RELEASE-PR-NOT-DRAFT" + f.verdict == "release open early" } test_a_returned_dependabot_config_is_refused if { tree := object.union(sound_input.tree, {"tracked": ["mise.toml", ".github/dependabot.yml"]}) found := violation with input as {"tree": tree} some f in found - f.verdict == "V-DEPENDABOT-RETURNED" + f.verdict == "config carry duplicate" } test_each_renovate_bound_missing_is_refused if { some key in ["draftPR", "rebaseWhen", "prConcurrentLimit", "minimumReleaseAge", "vulnerabilityAlerts"] found := violation with input as swap("renovate.json5", object.remove(sound_renovate, [key])) some f in found - f.verdict == "V-RENOVATE-BOUND-MISSING" + f.verdict == "bound declare missing" some s in f.subjects s.artifact == key } @@ -885,27 +885,27 @@ test_each_renovate_bound_missing_is_refused if { test_reverting_rebase_when_to_never_is_refused if { found := violation with input as swap("renovate.json5", object.union(sound_renovate, {"rebaseWhen": "never"})) some f in found - f.verdict == "V-RENOVATE-BOUND-MISSING" + f.verdict == "bound declare missing" } # ZERO IS UNLIMITED, so the bound and its own negation differ by one character. test_a_zero_concurrent_limit_is_not_a_bound if { found := violation with input as swap("renovate.json5", object.union(sound_renovate, {"prConcurrentLimit": 0})) some f in found - f.verdict == "V-RENOVATE-BOUND-MISSING" + f.verdict == "bound declare missing" } test_a_top_level_commit_type_does_not_satisfy_the_scoped_one if { stripped := object.remove(sound_renovate, ["packageRules"]) found := violation with input as swap("renovate.json5", object.union(stripped, {"semanticCommitType": "ci"})) some f in found - f.verdict == "V-RENOVATE-COMMIT-TYPE-UNSCOPED" + f.verdict == "commit name unnamed" } test_an_unserved_ecosystem_is_refused_and_named if { found := violation with input as swap("renovate.json5", object.union(sound_renovate, {"enabledManagers": ["cargo", "github-actions"]})) some f in found - f.verdict == "V-ECOSYSTEM-UNSERVED" + f.verdict == "manifest cover missing" some s in f.subjects s.artifact == "mise" } @@ -914,21 +914,21 @@ test_a_fanin_outside_the_roster_is_refused if { m := object.union(sound_manifest, {"env": object.union(sound_manifest.env, {"CI_FANIN_CHECK": "nowhere"})}) found := violation with input as swap("mise.toml", m) some f in found - f.verdict == "V-FANIN-NOT-REQUIRED" + f.verdict == "job require missing" } test_a_fanin_workflow_declaring_no_such_job_is_refused if { m := object.union(sound_manifest, {"env": object.union(sound_manifest.env, {"CI_FANIN_WORKFLOW": ".github/workflows/other.yml"})}) found := violation with input as swap("mise.toml", m) some f in found - f.verdict == "V-FANIN-WORKFLOW-DECLARES-NO-JOB" + f.verdict == "workflow declare empty" } test_an_abandon_that_restates_the_path_is_refused if { lines := object.union(sound_input.tree.lines, {"mise-tasks/abandon-matrix.sh": ["run=.github/workflows/ci.yml"]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} some f in found - f.verdict == "V-ABANDON-RESTATES-THE-FANIN" + f.verdict == "job declare duplicate" } # THE ANTI-VACUITY TERM. Every other fan-in clause makes the abandon SAFE; none @@ -937,7 +937,7 @@ test_a_lander_that_never_abandons_is_refused if { lines := object.union(sound_input.tree.lines, {"mise-tasks/land.sh": ["mise run ci-wait"]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} some f in found - f.verdict == "V-ABANDON-NEVER-CALLED" + f.verdict == "job reach dead" } test_a_job_that_starts_without_asking_the_lease_is_refused if { @@ -948,7 +948,7 @@ test_a_job_that_starts_without_asking_the_lease_is_refused if { }}}) found := violation with input as swap(".github/workflows/ci.yml", wf) some f in found - f.verdict == "V-LEASE-PRECONDITION-ABSENT" + f.verdict == "lease guard absent" some sub in f.subjects sub.artifact == "ci" } @@ -963,7 +963,7 @@ test_a_lease_step_that_is_not_first_is_refused if { }}}) found := violation with input as swap(".github/workflows/ci.yml", wf) some f in found - f.verdict == "V-LEASE-PRECONDITION-ABSENT" + f.verdict == "lease guard absent" } # A FAN-IN IS EXEMPT, and for a reason rather than by name: it cannot start @@ -972,7 +972,7 @@ test_a_lease_step_that_is_not_first_is_refused if { test_a_job_that_waits_on_another_is_not_asked_for_the_lease if { found := violation with input as sound_input every f in found { - f.verdict != "V-LEASE-PRECONDITION-ABSENT" + f.verdict != "lease guard absent" } } @@ -982,21 +982,21 @@ test_a_precondition_invoked_without_the_tolerant_suffix_is_refused if { lines := object.union(sound_input.tree.lines, {".github/workflows/ci.yml": [" bash -c \"$body\""]}) found := violation with input as {"tree": object.union(sound_input.tree, {"lines": lines})} some f in found - f.verdict == "V-LEASE-PRECONDITION-FATAL" + f.verdict == "lease guard unsafe" } test_a_workflow_reading_check_runs_without_the_one_predicate_is_refused if { wf := object.union(sound_lander, {"jobs": {"land": {"steps": [{"run": "gh api /check-runs | jq ."}]}}}) found := violation with input as swap(".github/workflows/land.yml", wf) some f in found - f.verdict == "V-CHECK-STATUS-REROLLED" + f.verdict == "check grade twice" } test_a_workflow_deciding_through_the_one_predicate_passes if { wf := object.union(sound_lander, {"jobs": {"land": {"steps": [{"run": "gh api /check-runs && mise run checks-green"}]}}}) found := violation with input as swap(".github/workflows/land.yml", wf) every f in found { - f.verdict != "V-CHECK-STATUS-REROLLED" + f.verdict != "check grade twice" } } @@ -1004,7 +1004,7 @@ test_a_workflow_deciding_through_the_one_predicate_passes if { test_a_workflow_that_reads_no_check_status_is_not_asked if { found := violation with input as sound_input every f in found { - f.verdict != "V-CHECK-STATUS-REROLLED" + f.verdict != "check grade twice" } } @@ -1012,7 +1012,7 @@ test_a_bot_prefix_with_no_watcher_is_refused if { wf := object.union(sound_lander, {"on": {"workflow_run": {"branches": ["release-plz-**"]}}}) found := violation with input as swap(".github/workflows/land.yml", wf) some f in found - f.verdict == "V-BOT-PREFIX-UNWATCHED" + f.verdict == "branch watch missing" some sub in f.subjects sub.artifact == "renovate/" } @@ -1025,7 +1025,7 @@ test_an_overridden_prefix_is_read_from_its_own_config if { }) found := violation with input as {"tree": object.union(sound_input.tree, {"documents": docs})} every f in found { - f.verdict != "V-BOT-PREFIX-UNWATCHED" + f.verdict != "branch watch missing" } } @@ -1044,7 +1044,7 @@ test_a_lane_with_no_config_is_not_asked_for_a_watcher if { tree := object.union(object.remove(sound_input.tree, ["documents"]), {"documents": docs}) found := violation with input as {"tree": tree} every f in found { - f.verdict != "V-BOT-PREFIX-UNWATCHED" + f.verdict != "branch watch missing" } } @@ -1063,5 +1063,5 @@ test_an_unreadable_workflow_is_loud if { "missing": [".github/workflows/ci.yml"], }} some f in found - f.verdict == "V-CI-WORKFLOW-UNREAD" + f.verdict == "workflow read unread" } diff --git a/policy/ci-suite-lane.rego b/policy/ci-suite-lane.rego index 19906bff6..fb4691b68 100644 --- a/policy/ci-suite-lane.rego +++ b/policy/ci-suite-lane.rego @@ -111,7 +111,7 @@ runs_task(name) if { violation contains { "rule": "ci-suite-lane", - "verdict": "V-HK-SKIP-UNCOVERED", + "verdict": "gate skip unseen", "subjects": [{"path": workflow_path}, {"artifact": name}], } if { governed @@ -127,7 +127,7 @@ violation contains { # green over a file it never read. violation contains { "rule": "ci-suite-lane", - "verdict": "V-CI-WORKFLOW-UNREAD", + "verdict": "workflow read unread", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -164,7 +164,7 @@ test_a_skipped_step_no_job_runs_is_refused if { found := violation with input as sound_input({"ci": paired_jobs.ci}) count(found) == 1 some finding in found - finding.verdict == "V-HK-SKIP-UNCOVERED" + finding.verdict == "gate skip unseen" some subject in finding.subjects subject.artifact == "test:bats" } @@ -193,7 +193,7 @@ test_a_job_level_carve_out_is_read_too if { found := violation with input as sound_input(jobs) count(found) == 1 some finding in found - finding.verdict == "V-HK-SKIP-UNCOVERED" + finding.verdict == "gate skip unseen" } # ANTI-VACUITY'S OTHER HALF: a workflow that carves nothing out has nothing to @@ -217,5 +217,5 @@ test_an_unreadable_workflow_is_loud if { }} count(found) == 1 some finding in found - finding.verdict == "V-CI-WORKFLOW-UNREAD" + finding.verdict == "workflow read unread" } diff --git a/policy/claim-before-code.rego b/policy/claim-before-code.rego index bd64f92b3..dfe67557b 100644 --- a/policy/claim-before-code.rego +++ b/policy/claim-before-code.rego @@ -80,7 +80,7 @@ refused contains id if { violation contains { "rule": "claim-before-code", - "verdict": "V-CLAIM-BEFORE-CODE", + "verdict": "claim mint absent", "subjects": [{"count": count(refused)}], } if { count(refused) > 0 @@ -104,7 +104,7 @@ test_a_filed_row_is_clean if { test_an_unfiled_row_is_refused if { some v in violation with input as reduced(false) - v.verdict == "V-CLAIM-BEFORE-CODE" + v.verdict == "claim mint absent" } # NOTHING WAS CAPTURED about this key is not a verdict. The id is absent from the diff --git a/policy/command-task-defined.rego b/policy/command-task-defined.rego index c6a64fd10..d0303896f 100644 --- a/policy/command-task-defined.rego +++ b/policy/command-task-defined.rego @@ -120,7 +120,7 @@ mise_task(command) := task if { violation contains { "rule": "command-task-defined", # The row first, then the task it names: the fix is on the row. - "verdict": "V-TASK-UNDEFINED", + "verdict": "task name undefined", "subjects": [{"artifact": row.id}, {"artifact": row.task}], } if { # ONLY WHERE A TASK SOURCE WAS FOUND. Without this guard the rule reproduces @@ -142,7 +142,7 @@ violation contains { # resolves. violation contains { "rule": "command-task-defined", - "verdict": "V-AUTHORITY-UNPARSED", + "verdict": "config parse broken", "subjects": [{"path": path}], } if { # THE AUTHORITY ONLY. `mise.toml` is declared as a source and lands in diff --git a/policy/connector-not-granted.rego b/policy/connector-not-granted.rego index 4e3c1c460..1783c57cb 100644 --- a/policy/connector-not-granted.rego +++ b/policy/connector-not-granted.rego @@ -73,7 +73,7 @@ granted contains entry if { # refuses, and rule 4's subject vocabulary has a `count` for exactly this. violation contains { "rule": "connector-not-granted", - "verdict": "V-RAW-CONNECTOR-GRANTED", + "verdict": "connector grant loose", "subjects": [{"path": ".claude/settings.json"}, {"count": count(granted)}], } if { allows @@ -96,7 +96,7 @@ test_a_tree_granting_nothing_raw_is_clean if { test_a_named_raw_tool_is_refused if { some v in violation with input as settings(["mcp__Linear__get_issue"]) - v.verdict == "V-RAW-CONNECTOR-GRANTED" + v.verdict == "connector grant loose" } # THE ANTI-VACUITY MIRROR. Without it the case above is satisfied by a predicate @@ -104,12 +104,12 @@ test_a_named_raw_tool_is_refused if { # wider — would ship past the gate that exists to refuse it. test_a_globbed_server_grant_is_refused if { some v in violation with input as settings(["mcp__Linear__*"]) - v.verdict == "V-RAW-CONNECTOR-GRANTED" + v.verdict == "connector grant loose" } test_a_bare_server_grant_is_refused if { some v in violation with input as settings(["mcp__Linear"]) - v.verdict == "V-RAW-CONNECTOR-GRANTED" + v.verdict == "connector grant loose" } # The finding is ONE per file however many entries offend, carrying a count. A @@ -133,4 +133,4 @@ test_no_settings_file_answers_nothing if { count(violation) == 0 with input as {"tree": {"documents": {}}} } -#MUTANT-EXEMPT CLOUD-1260|no `tests/connector-not-granted.bats` exists and none may be added: `mutant` resolves a gate's suite as `tests/$gate.bats`, and `V-SHELL-RULE-ADDED` refuses adding one, so there is no named case a mutation could turn red. The load-time tier is this file's own `test_` rules and the engine tier is `crates/batten/tests/connector_not_granted.rs`, neither of which is what the mutation runner drives +#MUTANT-EXEMPT CLOUD-1260|no `tests/connector-not-granted.bats` exists and none may be added: `mutant` resolves a gate's suite as `tests/$gate.bats`, and `shell add refused` refuses adding one, so there is no named case a mutation could turn red. The load-time tier is this file's own `test_` rules and the engine tier is `crates/batten/tests/connector_not_granted.rs`, neither of which is what the mutation runner drives diff --git a/policy/denials-outlive-the-turn.rego b/policy/denials-outlive-the-turn.rego index 85db7e026..b738ab5da 100644 --- a/policy/denials-outlive-the-turn.rego +++ b/policy/denials-outlive-the-turn.rego @@ -48,7 +48,7 @@ rules contains "denials-outlive-the-turn" # exists to catch — a finding produced and then left behind. violation contains { "rule": "denials-outlive-the-turn", - "verdict": "V-DENIALS-OUTLIVE-THE-TURN", + "verdict": "turn deny held", "subjects": [{"count": input.facts.extracted.denials}], } if { # GUARDED, and the guard is the whole could-not-look arm: `null` is the @@ -73,7 +73,7 @@ stopped(extracted, repeat) := { test_a_repeat_stop_with_refusals_is_refused if { some v in violation with input as stopped({"denials": 2}, true) - v.verdict == "V-DENIALS-OUTLIVE-THE-TURN" + v.verdict == "turn deny held" } # A FIRST stop is work concluding, not a finding left behind. @@ -113,7 +113,7 @@ test_a_compound_command_reaches_the_same_verdict if { "call": {"command": "cd /tmp && mise run land", "stop-repeat": true}, "facts": {"extracted": {"denials": 2}}, } - v.verdict == "V-DENIALS-OUTLIVE-THE-TURN" + v.verdict == "turn deny held" } #MUTANT-SUITE crates/batten/tests/it/extracted_facts.rs diff --git a/policy/filed-here.rego b/policy/filed-here.rego index 3382bb2e1..1325f7e1a 100644 --- a/policy/filed-here.rego +++ b/policy/filed-here.rego @@ -155,7 +155,7 @@ closes contains key if { # `ready` passes and so does `-`; only the tracker's own `unready` refuses. violation contains { "rule": "filed-unrefined", - "verdict": "V-FILED-UNREFINED", + "verdict": "issue file unclear", "subjects": [{"artifact": id}], } if { some id @@ -228,7 +228,7 @@ cites_only(id) if { # than a count they have to go and reconstruct. violation contains { "rule": "filed-over-own-diff", - "verdict": "V-FILED-OVER-OWN-DIFF", + "verdict": "issue file same", "subjects": [{"path": path}, {"artifact": id}], } if { some id, hits in overlapping @@ -251,7 +251,7 @@ with_diff(record, closes_record, changed_paths, base) := {"tree": { test_an_unready_row_is_refused if { some v in violation with input as board(["issue CLOUD-1 2026-01-01T00:00:00Z unready - - -"]) - v.verdict == "V-FILED-UNREFINED" + v.verdict == "issue file unclear" } test_a_ready_row_is_silent if { @@ -293,7 +293,7 @@ test_a_row_naming_this_branch_s_own_diff_is_refused if { ["src/a.rs"], "2026-01-01T00:00:00Z", ) - v.verdict == "V-FILED-OVER-OWN-DIFF" + v.verdict == "issue file same" # THE ORDER IS THE STATEMENT: the tracked path leads, because that is what a # reader should open, and `first_pointer` takes the first path-bearing subject @@ -369,7 +369,7 @@ test_an_unresolvable_base_date_still_judges_the_row if { ["src/a.rs"], null, ) - v.verdict == "V-FILED-OVER-OWN-DIFF" + v.verdict == "issue file same" } # BOTH REFUSALS AT ONCE, because neither subsumes the other. @@ -380,5 +380,5 @@ test_a_row_can_earn_both_refusals if { ["src/a.rs"], "2026-01-01T00:00:00Z", ) - verdicts == {"V-FILED-UNREFINED", "V-FILED-OVER-OWN-DIFF"} + verdicts == {"issue file unclear", "issue file same"} } diff --git a/policy/forge-verdict-required.rego b/policy/forge-verdict-required.rego index fa5cbcabb..1122c76c2 100644 --- a/policy/forge-verdict-required.rego +++ b/policy/forge-verdict-required.rego @@ -71,7 +71,7 @@ passed(checks) if { violation contains { "rule": "forge-verdict-required", - "verdict": "V-FORGE-VERDICT-NOT-GREEN", + "verdict": "forge check red", "subjects": [{"count": count(refused)}], } if { count(refused) > 0 @@ -93,14 +93,14 @@ test_a_green_record_is_clean if { test_a_failed_record_is_refused if { some v in violation with input as recorded({"final": "failure"}) - v.verdict == "V-FORGE-VERDICT-NOT-GREEN" + v.verdict == "forge check red" } # A judged commit whose fan-in never reported is not green, and reading it as # green is exactly the false pass CLOUD-900 records. test_a_record_missing_the_fan_in_is_refused if { some v in violation with input as recorded({"lint": "success"}) - v.verdict == "V-FORGE-VERDICT-NOT-GREEN" + v.verdict == "forge check red" } # NOTHING HAS JUDGED THIS COMMIT is not a verdict. The sha is absent from the diff --git a/policy/harness-grant.rego b/policy/harness-grant.rego index 9f27b3aa2..a3a9d3ea4 100644 --- a/policy/harness-grant.rego +++ b/policy/harness-grant.rego @@ -71,7 +71,7 @@ keeps_the_defaults if { # actually decides. violation contains { "rule": "harness-grant", - "verdict": "V-HARNESS-GRANT-ABSENT", + "verdict": "grant declare absent", "subjects": [{"path": ".claude/settings.json"}], } if { grants @@ -81,7 +81,7 @@ violation contains { # The grant is present and every built-in safety rule was discarded with it. violation contains { "rule": "harness-grant", - "verdict": "V-HARNESS-GRANT-DEFAULTS-DROPPED", + "verdict": "default carry dropped", "subjects": [{"path": ".claude/settings.json"}], } if { grants @@ -105,7 +105,7 @@ test_the_landed_shape_is_clean if { test_a_dropped_grant_is_refused if { some v in violation with input as settings(["$defaults"]) - v.verdict == "V-HARNESS-GRANT-ABSENT" + v.verdict == "grant declare absent" } # The anti-vacuity mirror. Without it the clean case above is satisfied by a @@ -113,7 +113,7 @@ test_a_dropped_grant_is_refused if { # as coverage having never been walked. test_a_dropped_sentinel_is_refused if { some v in violation with input as settings(["Allow every `batten` subcommand."]) - v.verdict == "V-HARNESS-GRANT-DEFAULTS-DROPPED" + v.verdict == "default carry dropped" } test_both_missing_raises_both if { diff --git a/policy/harness-wiring.rego b/policy/harness-wiring.rego index bb7fac398..bc0cac1f2 100644 --- a/policy/harness-wiring.rego +++ b/policy/harness-wiring.rego @@ -63,7 +63,7 @@ registrations contains command if { # rather than the spelling of the one that is there. violation contains { "rule": "harness-wiring", - "verdict": "V-HARNESS-WIRING-SECOND-DECIDER", + "verdict": "hook wire duplicate", "subjects": [{"count": count(strays)}], } if { count(strays) > 0 @@ -95,7 +95,7 @@ test_a_second_decider_is_refused if { "batten hook --harness claude-code", "mise run some-other-guard", }) - v.verdict == "V-HARNESS-WIRING-SECOND-DECIDER" + v.verdict == "hook wire duplicate" } test_a_pinned_wrapper_around_the_mediator_is_not_a_second_decider if { diff --git a/policy/hk-fix-selection.rego b/policy/hk-fix-selection.rego index 4a5ed410f..9dfe7fbfd 100644 --- a/policy/hk-fix-selection.rego +++ b/policy/hk-fix-selection.rego @@ -20,8 +20,8 @@ # the prose stop agreeing, and a reader who sees one finding should not have to # guess which side moved: # -# V-FMT-DESCRIBED-AS-THE-GATE the prose stopped saying formatters-only -# V-FIXER-TASK-UNROUTED a fixer task exists that `mise run fmt` cannot reach +# task state wrong the prose stopped saying formatters-only +# task select missing a fixer task exists that `mise run fmt` cannot reach # # The second is the half that would otherwise be invisible, and it is the second # defect this row was written on. `deno-fmt`'s step carried a `check` and no @@ -98,7 +98,7 @@ fmt_description := input.tree.documents["mise.toml"].tasks.fmt.description violation contains { "rule": "hk-fix-selection", - "verdict": "V-FMT-DESCRIBED-AS-THE-GATE", + "verdict": "task state wrong", "subjects": [{"path": "mise.toml"}, {"artifact": "Run every fixer over the tree"}], } if { governed @@ -108,7 +108,7 @@ violation contains { violation contains { "rule": "hk-fix-selection", - "verdict": "V-FMT-DESCRIBED-AS-THE-GATE", + "verdict": "task state wrong", "subjects": [ {"path": ".claude/rules/toolchain.md"}, {"artifact": "`fmt` remains the formatters-only subset"}, @@ -139,7 +139,7 @@ fixer_tasks := {name | violation contains { "rule": "hk-fix-selection", - "verdict": "V-FIXER-TASK-UNROUTED", + "verdict": "task select missing", "subjects": [{"path": "hk.pkl"}, {"artifact": task}], } if { governed @@ -156,7 +156,7 @@ violation contains { # yet, so this clause is right and the channel is not yet filled.) violation contains { "rule": "hk-fix-selection", - "verdict": "V-HK-SOURCE-UNREAD", + "verdict": "gate parse unread", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -218,7 +218,7 @@ test_a_task_description_that_names_the_gate_is_refused if { "missing": [], }} some finding in found - finding.verdict == "V-FMT-DESCRIBED-AS-THE-GATE" + finding.verdict == "task state wrong" } test_a_rules_file_that_dropped_the_clause_is_refused if { @@ -232,7 +232,7 @@ test_a_rules_file_that_dropped_the_clause_is_refused if { "missing": [], }} some finding in found - finding.verdict == "V-FMT-DESCRIBED-AS-THE-GATE" + finding.verdict == "task state wrong" } # THE SECOND DEFECT THIS ROW WAS WRITTEN ON, in the shape it actually had: a @@ -245,7 +245,7 @@ test_a_fixer_task_no_step_routes_is_refused if { ] found := violation with input as sound_input(unrouted) some finding in found - finding.verdict == "V-FIXER-TASK-UNROUTED" + finding.verdict == "task select missing" some subject in finding.subjects subject.artifact == "fmt:deno" } @@ -270,5 +270,5 @@ test_an_unreadable_config_is_loud if { }} count(found) == 1 some finding in found - finding.verdict == "V-HK-SOURCE-UNREAD" + finding.verdict == "gate parse unread" } diff --git a/policy/hook-profile.rego b/policy/hook-profile.rego index 0685d8405..924eb7d32 100644 --- a/policy/hook-profile.rego +++ b/policy/hook-profile.rego @@ -68,7 +68,7 @@ stray contains name if { violation contains { "rule": "hook-profile", - "verdict": "V-PROFILED-STEP-NOT-IN-CHECK", + "verdict": "step declare missing", "subjects": [{"count": count(stray)}], } if { count(stray) > 0 @@ -82,7 +82,7 @@ violation contains { # never binds `plan` at all, and that is could-not-look rather than a finding. violation contains { "rule": "hook-profile", - "verdict": "V-SLOW-TIER-EMPTY", + "verdict": "tier list empty", "subjects": [{"artifact": "hk-plan"}], } if { is_object(plan) @@ -108,14 +108,14 @@ flagged contains line if { violation contains { "rule": "hook-profile", - "verdict": "V-HOOK-MISSING-PROFILE-FLAG", + "verdict": "hook declare missing", "subjects": [{"path": hook}], } if { count(invocations) > 0 count(flagged) == 0 } -#MUTANT-EXEMPT CLOUD-931|no `tests/hook-profile.bats` exists and none may be added: `mutant` resolves a gate's suite as `tests/$gate.bats`, and `V-SHELL-RULE-ADDED` refuses adding one, so there is no named case a mutation could turn red. The load-time tier is this file's own `test_` rules and the engine tier is `crates/batten/tests/hook_profile.rs`, neither of which is what the mutation runner drives. The mutation this row WOULD declare is on `status != included` — the load-bearing conjunct, since a slow step the `check` hook does not select is the false green the whole rule exists for — and `a_slow_step_missing_from_check_is_refused` plus its anti-vacuity mirror `a_wired_split_is_clean` are what stand in for it. CLOUD-1267 owns closing this for the Rego layer as a whole +#MUTANT-EXEMPT CLOUD-931|no `tests/hook-profile.bats` exists and none may be added: `mutant` resolves a gate's suite as `tests/$gate.bats`, and `shell add refused` refuses adding one, so there is no named case a mutation could turn red. The load-time tier is this file's own `test_` rules and the engine tier is `crates/batten/tests/hook_profile.rs`, neither of which is what the mutation runner drives. The mutation this row WOULD declare is on `status != included` — the load-bearing conjunct, since a slow step the `check` hook does not select is the false green the whole rule exists for — and `a_slow_step_missing_from_check_is_refused` plus its anti-vacuity mirror `a_wired_split_is_clean` are what stand in for it. CLOUD-1267 owns closing this for the Rego layer as a whole # --- the load-time tier ------------------------------------------------------ # @@ -136,13 +136,13 @@ test_every_slow_step_selected_by_check_is_clean if { test_a_slow_step_missing_from_check_is_refused if { some v in violation with input as planned({"test": "included", "batten-check": "skipped"}) - v.verdict == "V-PROFILED-STEP-NOT-IN-CHECK" + v.verdict == "step declare missing" } # An evaporated tier is a FINDING, not a clean read — the anti-vacuity arm. test_an_empty_plan_is_refused if { some v in violation with input as planned({}) - v.verdict == "V-SLOW-TIER-EMPTY" + v.verdict == "tier list empty" } # COULD-NOT-LOOK. Nothing has planned this tree, which is not the same as a tier @@ -163,7 +163,7 @@ test_a_hook_that_stopped_passing_the_flag_is_refused if { "tool-verdict": {"hk-plan": {"test": "included"}}, "lines": {".claude/hooks/git-hook.sh": ["hk run pre-commit"]}, }} - v.verdict == "V-HOOK-MISSING-PROFILE-FLAG" + v.verdict == "hook declare missing" } # The measured case: the flag is gone from the COMMAND and still present in the @@ -173,5 +173,5 @@ test_the_flag_in_a_comment_alone_does_not_satisfy_it if { "tool-verdict": {"hk-plan": {"test": "included"}}, "lines": {".claude/hooks/git-hook.sh": ["# we pass --profile '!slow' here", "hk run pre-commit"]}, }} - v.verdict == "V-HOOK-MISSING-PROFILE-FLAG" + v.verdict == "hook declare missing" } diff --git a/policy/lock-entry-complete.rego b/policy/lock-entry-complete.rego new file mode 100644 index 000000000..1d22462f1 --- /dev/null +++ b/policy/lock-entry-complete.rego @@ -0,0 +1,115 @@ +# METADATA +# description: | +# The successor shape for `lock-complete`, and the demonstration CLOUD-1203 +# unit A owes: a tree-scoped module deciding over the git INDEX rather than the +# working tree. +# +# THE INDEX IS THE WHOLE POINT. `lock-complete` is the pure "committed bytes +# only, no network, no write" gate — it judges THE COMMIT, not the developer's +# working copy — so a successor reading `input.tree.documents` would answer a +# different question and pass over a staged-but-unsaved edit. That is a silent +# wrong answer, not a missing feature, and `input.tree.staged` is the only key +# in the model that can avoid it: `Fact::Tracked` walks the checkout and says +# so in its own doc, and `Fact::GitStatus` carries paths and a count. +# +# THE PREDICATE IS THE ONE `lock-complete`'s OWN COMMENT CITES as its +# motivation: a platform entry carrying a checksum and no url. Such an entry is +# the partial shape a regenerate-and-diff gate structurally cannot catch, +# because `mise lock` never removes or repairs an existing entry — so a stably +# wrong lockfile passes forever, and one did. +# +# WHAT THIS DELIBERATELY IS NOT is the currency question. Whether upstream has +# moved since this commit is a property of the WORLD and belongs on a schedule; +# this is a property of the COMMIT and belongs in a gate. `lock-complete`'s own +# split records that lesson, and reading the index rather than the network is +# what keeps this half on the right side of it. +# +# THE BRACKETS ARE NOT STYLE: the schema file carries a hyphen, so the dotted +# form is a parse error reported as `invalid schema reference`. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.lock_entry_complete + +import rego.v1 + +rules contains "lock-entry-complete" + +# Every platform entry in the STAGED lockfile. +# +# `[[tools."x"]]` is an array of tables and `[tools."x"."platforms.y"]` addresses +# the last element of it, so the parsed shape is a list per tool whose entries +# carry `version`, `backend` and one key per platform. The `startswith` is what +# separates the platform sub-tables from those two scalars. +platforms contains platform if { + some entries in input.tree.staged["mise.lock"].tools + some entry in entries + some key, platform in entry + startswith(key, "platforms.") +} + +# A checksum with nothing to fetch. +# +# The pair is the point: an entry with neither is simply unlocked, and an entry +# with both is complete. One without the other is the partial shape that reads as +# locked and cannot be used. +violation contains { + "rule": "lock-entry-complete", + "verdict": "lock write partial", + "subjects": [{"path": "mise.lock"}, {"count": count(partial)}], +} if { + count(partial) > 0 +} + +partial contains platform if { + some platform in platforms + platform.checksum + not platform.url +} + +# --- the load-time tier ------------------------------------------------------ +# +# These pin the PREDICATE. They cannot pin that the ENGINE reads the INDEX rather +# than the checkout — a `with input as` case fabricates the very shape the engine +# may be unable to produce, and here it would fabricate the very distinction the +# family exists for. `crates/batten/tests/staged_facts.rs` is that tier, and its +# `the_index_answers_not_the_worktree` case is the one that discriminates. + +lock(entry) := {"tree": {"staged": {"mise.lock": {"tools": {"aqua:example/tool": [entry]}}}}} + +complete := { + "version": "1.0.0", + "backend": "aqua:example/tool", + "platforms.linux-x64": {"checksum": "sha256:abc", "url": "https://example.invalid/tool.tar.gz"}, +} + +test_a_complete_entry_is_clean if { + count(violation) == 0 with input as lock(complete) +} + +test_a_checksum_with_no_url_is_refused if { + some v in violation with input as lock({ + "version": "1.0.0", + "backend": "aqua:example/tool", + "platforms.linux-x64": {"checksum": "sha256:abc"}, + }) + v.verdict == "lock write partial" +} + +test_an_unlocked_entry_is_not_a_partial_one if { + count(violation) == 0 with input as lock({ + "version": "1.0.0", + "backend": "aqua:example/tool", + "platforms.linux-x64": {}, + }) +} + +# The two scalars beside the platform tables must not be read as platforms, or a +# tool whose `backend` happens to be a map would be judged as one. +test_version_and_backend_are_not_platforms if { + count(violation) == 0 with input as lock({"version": "1.0.0", "backend": "aqua:example/tool"}) +} + +#MUTANT-SUITE crates/batten/tests/it/staged_facts.rs +#MUTANT-OWNER CLOUD-845|the tier this module names drives `input.tree.staged` and never installs the module, so no case in it can turn red under a mutation of the predicate +#MUTANT partial-entry-unread|s@^\tcount(partial) > 0$@\tfalse@|the_index_answers_not_the_worktree diff --git a/policy/memories.rego b/policy/memories.rego index 72eb7e696..8bc3459a5 100644 --- a/policy/memories.rego +++ b/policy/memories.rego @@ -37,7 +37,7 @@ # `path:line` — never the reference's surrounding prose, and never a line of a # memory. The predecessor emitted the same shape for the same reason. # -#MUTANT-EXEMPT CLOUD-1267|no `tests/memories.bats` exists and none may be added: `mutant` resolves a gate's suite as `tests/$gate.bats`, and `V-SHELL-RULE-ADDED` refuses adding one, so there is no named case a mutation could turn red. The load-time tier is this file's own `test_` rules and the engine tier is `crates/batten/tests/memories.rs`, neither of which is what the mutation runner drives +#MUTANT-EXEMPT CLOUD-1267|no `tests/memories.bats` exists and none may be added: `mutant` resolves a gate's suite as `tests/$gate.bats`, and `shell add refused` refuses adding one, so there is no named case a mutation could turn red. The load-time tier is this file's own `test_` rules and the engine tier is `crates/batten/tests/memories.rs`, neither of which is what the mutation runner drives # METADATA # description: | @@ -92,7 +92,7 @@ name_of(path) := trim_suffix(trim_prefix(path, memories_dir), ".md") # a repository with a broken memory graph. violation contains { "rule": "memory-graph", - "verdict": "V-MEMORY-ROOT-MISSING", + "verdict": "memory resolve missing", "subjects": [{"path": root_memory}], } if { count(memory_files) > 0 @@ -106,7 +106,7 @@ violation contains { # first foreign character. violation contains { "rule": "memory-graph", - "verdict": "V-MEMORY-NAME-SHADOWED", + "verdict": "memory name duplicate", "subjects": [{"path": path}], } if { some path in memory_files @@ -115,7 +115,7 @@ violation contains { violation contains { "rule": "memory-graph", - "verdict": "V-MEMORY-NAME-UNREFERENCABLE", + "verdict": "memory name unseen", "subjects": [{"path": path}], } if { some path in memory_files @@ -141,7 +141,7 @@ referrer(path) if { violation contains { "rule": "memory-graph", - "verdict": "V-MEM-REF-STALE", + "verdict": "memory point stale", "subjects": [{"path": path, "line": number}], } if { some path, lines in input.tree.lines @@ -162,7 +162,7 @@ violation contains { # an unreadable file is loud, and an absent map key is silent. violation contains { "rule": "memory-graph", - "verdict": "V-MEMORY-SOURCE-UNREAD", + "verdict": "memory read unread", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -191,7 +191,7 @@ test_a_coherent_graph_is_clean if { test_a_missing_root_is_reported if { found := violation with input as graph([".serena/memories/other.md"], {}) some finding in found - finding.verdict == "V-MEMORY-ROOT-MISSING" + finding.verdict == "memory resolve missing" } # THE BOUND ARM A NEEDS. A repository with no memories at all is not a repository @@ -206,7 +206,7 @@ test_a_shadowed_name_is_reported if { {}, ) some finding in found - finding.verdict == "V-MEMORY-NAME-SHADOWED" + finding.verdict == "memory name duplicate" } test_an_unreferencable_name_is_reported if { @@ -215,7 +215,7 @@ test_an_unreferencable_name_is_reported if { {}, ) some finding in found - finding.verdict == "V-MEMORY-NAME-UNREFERENCABLE" + finding.verdict == "memory name unseen" } # The predicate that produced the row: a reference with no memory behind it, @@ -226,7 +226,7 @@ test_a_dangling_reference_is_reported_with_a_pointer if { {"AGENTS.md": ["intro", "see mem:gone-away for detail"]}, ) some finding in found - finding.verdict == "V-MEM-REF-STALE" + finding.verdict == "memory point stale" finding.subjects[0].path == "AGENTS.md" finding.subjects[0].line == 2 } @@ -263,7 +263,7 @@ test_an_unreadable_referrer_is_loud if { "missing": ["AGENTS.md"], }} some finding in found - finding.verdict == "V-MEMORY-SOURCE-UNREAD" + finding.verdict == "memory read unread" } test_an_unreadable_non_referrer_is_not_this_rules_business if { @@ -273,7 +273,7 @@ test_an_unreadable_non_referrer_is_not_this_rules_business if { "lines": {}, "missing": ["mise.toml"], }} - f.verdict == "V-MEMORY-SOURCE-UNREAD" + f.verdict == "memory read unread" } count(found) == 0 } diff --git a/policy/mise-pin-agreement.rego b/policy/mise-pin-agreement.rego index bf598ebe2..390bda6ca 100644 --- a/policy/mise-pin-agreement.rego +++ b/policy/mise-pin-agreement.rego @@ -135,7 +135,7 @@ reference contains {"server": server, "ref": arg, "tool": tool, "want": want} if violation contains { "rule": "mise-pin-agreement", - "verdict": "V-MCP-PIN-DISAGREES", + "verdict": "pin declare other", "subjects": [{"path": ".mcp.json"}, {"artifact": entry.server}, {"artifact": entry.ref}], } if { some entry in reference @@ -150,7 +150,7 @@ violation contains { violation contains { "rule": "mise-pin-agreement", - "verdict": "V-MCP-PIN-UNDECLARED", + "verdict": "pin declare missing", "subjects": [{"path": ".mcp.json"}, {"artifact": entry.server}, {"artifact": entry.ref}], } if { some entry in reference @@ -171,7 +171,7 @@ violation contains { violation contains { "rule": "mise-pin-agreement", - "verdict": "V-MCP-EXEC-UNSCOPED", + "verdict": "call run loose", "subjects": [{"path": ".mcp.json"}, {"artifact": server}], } if { some server, body in servers @@ -213,7 +213,7 @@ terminator(args) := count(args) if { violation contains { "rule": "mise-pin-agreement", - "verdict": "V-PIN-AUTHORITY-UNREADABLE", + "verdict": "pin read unread", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -247,7 +247,7 @@ test_a_version_the_authority_pins_differently_is_refused if { with data.batten.patterns as {"mise-tool-reference": `^[a-z0-9]+:.+@.+$`} count(found) == 1 some finding in found - finding.verdict == "V-MCP-PIN-DISAGREES" + finding.verdict == "pin declare other" } test_a_tool_the_authority_does_not_carry_is_refused if { @@ -261,7 +261,7 @@ test_a_tool_the_authority_does_not_carry_is_refused if { with data.batten.patterns as {"mise-tool-reference": `^[a-z0-9]+:.+@.+$`} count(found) == 1 some finding in found - finding.verdict == "V-MCP-PIN-UNDECLARED" + finding.verdict == "pin declare missing" } # THE REGRESSION. A bare exec names no version to compare, and must not pass. @@ -276,7 +276,7 @@ test_a_bare_exec_is_refused_even_though_it_names_no_version if { with data.batten.patterns as {"mise-tool-reference": `^[a-z0-9]+:.+@.+$`} count(found) == 1 some finding in found - finding.verdict == "V-MCP-EXEC-UNSCOPED" + finding.verdict == "call run loose" } test_a_server_not_launched_through_mise_is_left_alone if { @@ -306,7 +306,7 @@ test_a_shimmed_bare_exec_is_still_refused if { with data.batten.patterns as {"mise-tool-reference": `^[a-z0-9]+:.+@.+$`} count(found) == 1 some finding in found - finding.verdict == "V-MCP-EXEC-UNSCOPED" + finding.verdict == "call run loose" } # A shimmed launch that IS scoped passes, and its pin is still read. @@ -332,7 +332,7 @@ test_an_absent_authority_is_loud if { }} with data.batten.patterns as {"mise-tool-reference": `^[a-z0-9]+:.+@.+$`} some finding in found - finding.verdict == "V-PIN-AUTHORITY-UNREADABLE" + finding.verdict == "pin read unread" } # ANTI-VACUITY for the clause above: with no manifest there is nothing that would @@ -358,7 +358,7 @@ test_a_table_valued_pin_reads_as_undeclared if { with data.batten.patterns as {"mise-tool-reference": `^[a-z0-9]+:.+@.+$`} count(found) == 1 some finding in found - finding.verdict == "V-MCP-PIN-UNDECLARED" + finding.verdict == "pin declare missing" } # A tool name carrying an `@` keeps its head whole — the last `@` is the split. diff --git a/policy/module-layering.rego b/policy/module-layering.rego index b96ad9031..756a0ac7c 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -321,7 +321,7 @@ module_of(path) := name if { # that produced them stays on the engine's side. violation contains { "rule": "module-layering", - "verdict": "V-LAYERING-EDGE-FORBIDDEN", + "verdict": "layer reach refused", "subjects": [{"path": path, "line": edge.line}, {"artifact": edge.to}], } if { some path, edges in input.tree.uses @@ -338,7 +338,7 @@ violation contains { # close, so it is a finding rather than silence. violation contains { "rule": "module-layering", - "verdict": "V-LAYER-UNPLACED", + "verdict": "module place missing", "subjects": [{"path": path}], } if { some path, _ in input.tree.uses @@ -351,7 +351,7 @@ violation contains { # can be switched off by deletion without anything going red. violation contains { "rule": "module-layering", - "verdict": "V-LAYER-TABLE-DECIDES-NOTHING", + "verdict": "layer table dead", } if { count(forbidden) == 0 } @@ -507,7 +507,7 @@ test_an_unplaced_module_is_refused_rather_than_allowed if { # pins is the class it raises, and a token is what makes that assertion # exact — a `contains` over a message passed for any rewording that kept # three words, and failed for any that did not. - v.verdict == "V-LAYER-UNPLACED" + v.verdict == "module place missing" } # A selector that matched nothing reaches this module as an empty set and it says diff --git a/policy/opa-compliance.rego b/policy/opa-compliance.rego index c8b956a88..c82f5f11a 100644 --- a/policy/opa-compliance.rego +++ b/policy/opa-compliance.rego @@ -59,7 +59,7 @@ rules contains "opa-tracks-regorus-compliance" # read — a vacuous pass, indistinguishable from a real one. violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-COMPLIANCE-SOURCE-UNPARSED", + "verdict": "source parse broken", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -69,7 +69,7 @@ violation contains { # The checker and the evaluator naming different OPA release lines. violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-CHECKER-AHEAD-OF-EVALUATOR", + "verdict": "version pin ahead", "subjects": [{"artifact": pin}, {"artifact": declared}], } if { pin := opa_pin @@ -82,7 +82,7 @@ violation contains { # and that is the point. violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-COMPLIANCE-CLAIM-STALE", + "verdict": "claim state stale", "subjects": [{"artifact": recorded_for}, {"artifact": regorus_pin}], } if { recorded_for := compliance_for @@ -145,7 +145,7 @@ in_this_workspace if input.tree.documents["Cargo.toml"] # name the caller's parse failure as four separate findings. violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "verdict": "claim declare absent", "subjects": [{"artifact": "opa"}], } if { in_this_workspace @@ -155,7 +155,7 @@ violation contains { violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "verdict": "claim declare absent", "subjects": [{"artifact": "REGORUS_OPA_COMPLIANCE"}], } if { in_this_workspace @@ -165,7 +165,7 @@ violation contains { violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "verdict": "claim declare absent", "subjects": [{"artifact": "REGORUS_OPA_COMPLIANCE_FOR"}], } if { in_this_workspace @@ -175,7 +175,7 @@ violation contains { violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-COMPLIANCE-DECLARATION-ABSENT", + "verdict": "claim declare absent", "subjects": [{"artifact": "regorus"}], } if { in_this_workspace @@ -188,7 +188,7 @@ violation contains { # one level in. `"1"` against a declared `1.2.0` was measured passing. violation contains { "rule": "opa-tracks-regorus-compliance", - "verdict": "V-VERSION-UNREADABLE", + "verdict": "version read unread", "subjects": [{"artifact": entry.key}, {"artifact": entry.owner}], } if { in_this_workspace diff --git a/policy/privileged-lane.rego b/policy/privileged-lane.rego index 7e73e1f98..365182bbd 100644 --- a/policy/privileged-lane.rego +++ b/policy/privileged-lane.rego @@ -69,7 +69,7 @@ rules contains "privileged-lane-tests-origin" # indistinguishable from a real one. violation contains { "rule": "privileged-lane-tests-origin", - "verdict": "V-WORKFLOW-UNPARSED", + "verdict": "workflow parse broken", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -79,7 +79,7 @@ violation contains { # The finding itself: a subject job that never mentions the head's origin. violation contains { "rule": "privileged-lane-tests-origin", - "verdict": "V-PRIVILEGED-LANE-UNTESTED-ORIGIN", + "verdict": "lane guard missing", "subjects": [{"path": path}, {"artifact": job}], } if { some path, doc in input.tree.documents diff --git a/policy/prose-only.rego b/policy/prose-only.rego index d736b20c8..048e6ea24 100644 --- a/policy/prose-only.rego +++ b/policy/prose-only.rego @@ -100,7 +100,7 @@ touches_a_test if { # rewrite that ships with its own test. violation contains { "rule": "prose-only", - "verdict": "V-PROSE-ONLY-DIFF", + "verdict": "diff ship early", "subjects": [{"count": count(changed)}], } if { count(changed) > 0 @@ -120,7 +120,7 @@ test_a_comment_only_branch_with_no_test_change_is_refused if { "deleted": [], "code-changed": [], }) - v.verdict == "V-PROSE-ONLY-DIFF" + v.verdict == "diff ship early" } # THE CONJUNCT THAT MAKES DOC WORK POSSIBLE. Same diff plus a test, and the gate @@ -173,7 +173,7 @@ test_deleting_a_pure_prose_file_is_prose_only if { "deleted": ["NOTES.md"], "code-changed": [], }) - v.verdict == "V-PROSE-ONLY-DIFF" + v.verdict == "diff ship early" } # COULD-NOT-LOOK SAYS NOTHING. An unresolvable base is `null`, and reading it as diff --git a/policy/release-tag-shape.rego b/policy/release-tag-shape.rego index 4b70f5989..99aaa3c53 100644 --- a/policy/release-tag-shape.rego +++ b/policy/release-tag-shape.rego @@ -48,7 +48,7 @@ shipped contains tag if { violation contains { "rule": "release-tag-shape", - "verdict": "V-RELEASE-TAG-SHAPE", + "verdict": "tag mint wrong", "subjects": [{"count": count(malformed)}], } if { count(malformed) > 0 @@ -78,12 +78,12 @@ test_a_conventional_tag_is_clean if { test_a_tag_missing_its_prefix_is_refused if { some v in violation with input as tags({"0.0.134"}) - v.verdict == "V-RELEASE-TAG-SHAPE" + v.verdict == "tag mint wrong" } test_a_tag_with_a_trailing_label_is_refused if { some v in violation with input as tags({"v0.0.134-rc1"}) - v.verdict == "V-RELEASE-TAG-SHAPE" + v.verdict == "tag mint wrong" } test_no_tags_is_clean if { diff --git a/policy/remedy-authorship.rego b/policy/remedy-authorship.rego index 4ce6598f9..ff5e3546b 100644 --- a/policy/remedy-authorship.rego +++ b/policy/remedy-authorship.rego @@ -94,7 +94,7 @@ rules contains "remedy-has-one-author" violation contains { "rule": "remedy-reaches-the-reader", - "verdict": "V-REMEDY-DROPPED-BY-THE-FILTER", + "verdict": "remedy select dropped", "subjects": [{"path": path, "line": i + 1}], } if { some path, block in stderr_block @@ -191,7 +191,7 @@ emits_a_literal(line) if { violation contains { "rule": "remedy-has-one-author", - "verdict": "V-REMEDY-HAS-TWO-AUTHORS", + "verdict": "remedy own duplicate", "subjects": [{"artifact": name}, {"artifact": var}], } if { some name, body in task_bodies diff --git a/policy/review-answered.rego b/policy/review-answered.rego index d5c3c227b..270089529 100644 --- a/policy/review-answered.rego +++ b/policy/review-answered.rego @@ -92,7 +92,7 @@ rules contains "review-absent" #MUTANT ready-unread|s@^\treadying$@\tfalse@|the_measured_shape_a_head_carrying_unresolved_threads_is_refused_naming_the_count violation contains { "rule": "review-unanswered", - "verdict": "V-REVIEW-UNANSWERED", + "verdict": "review answer missing", "subjects": [{"count": record.rows}], } if { readying @@ -111,7 +111,7 @@ violation contains { # empty subject list reads as a refusal nobody could locate. violation contains { "rule": "review-absent", - "verdict": "V-REVIEW-ABSENT", + "verdict": "review read absent", "subjects": [{"count": record.rows}], } if { readying diff --git a/policy/rules-drift.rego b/policy/rules-drift.rego index 0208bfc8c..be1178f1f 100644 --- a/policy/rules-drift.rego +++ b/policy/rules-drift.rego @@ -98,7 +98,7 @@ observed(name) if { violation contains { "rule": "restated-default-drifts", - "verdict": "V-RESTATED-DEFAULT-DRIFTS", + "verdict": "default state other", "subjects": [{"path": claim.path, "line": claim.line}], } if { some claim in restated @@ -202,7 +202,7 @@ event_pointer(claim, name) := line if { violation contains { "rule": "named-event-unwired", - "verdict": "V-NAMED-EVENT-UNWIRED", + "verdict": "event wire missing", "subjects": [{"path": claim.path, "line": event_pointer(claim, name)}], } if { some claim in wiring_claims @@ -298,7 +298,7 @@ named_keys contains {"path": path, "line": index + 1, "surface": surface, "key": violation contains { "rule": "named-input-key-unemittable", - "verdict": "V-NAMED-INPUT-KEY-UNEMITTABLE", + "verdict": "input key dead", "subjects": [{"path": named.path, "line": named.line}], } if { some named in named_keys @@ -320,7 +320,7 @@ violation contains { # name the evaluator does not query as a rule, and saying so is the honest report. queried_rules contains name if { some _, text in input.tree.lines["crates/batten/src/policy.rs"] - some found in regex.find_n(data.batten.patterns["policy-rule-const"], text, -1) + some found in regex.find_n(data.batten.patterns["module read first"], text, -1) name := split(found, "\"")[1] } @@ -333,7 +333,7 @@ named_rules contains {"path": path, "line": index + 1, "name": name} if { violation contains { "rule": "named-fixed-rule-unqueried", - "verdict": "V-NAMED-FIXED-RULE-UNQUERIED", + "verdict": "rule ask missing", "subjects": [{"path": named.path, "line": named.line}], } if { some named in named_rules @@ -481,7 +481,7 @@ authority_needed contains "crates/batten/src/policy.rs" if { # key simply not being in `documents` is then the honest signal. violation contains { "rule": "drift-authority-unreadable", - "verdict": "V-DRIFT-AUTHORITY-UNREADABLE", + "verdict": "drift read unread", "subjects": [{"path": path}], } if { some path in authority_needed @@ -495,7 +495,7 @@ violation contains { # path above buys, paid for here rather than left implicit. violation contains { "rule": "drift-authority-unreadable", - "verdict": "V-DRIFT-AUTHORITY-UNREADABLE", + "verdict": "drift read unread", "subjects": [{"path": schema_path[named.surface]}], } if { some named in named_keys @@ -784,5 +784,5 @@ fixture_patterns := { "shell-default": "\\$\\{[A-Z][A-Z0-9_]*:-[^}]*\\}", "policy-input-key": "`input\\.(tree|call)\\.[a-z][a-z0-9_-]*", "fixed-rule-ref": "`data\\.batten\\.[a-z_]+`", - "policy-rule-const": "^const [A-Z_]+_RULE: &str = \"[a-z_]+\";", + "module read first": "^const [A-Z_]+_RULE: &str = \"[a-z_]+\";", } diff --git a/policy/run-shape.rego b/policy/run-shape.rego index d23f766da..f98828f05 100644 --- a/policy/run-shape.rego +++ b/policy/run-shape.rego @@ -84,7 +84,7 @@ rules contains "background-timer" violation contains { "rule": "commit-names-no-message-source", - "verdict": "V-COMMIT-WITHOUT-A-MESSAGE-SOURCE", + "verdict": "commit write missing", } if { # THE CHEAP TERM FIRST, and it is load-bearing rather than tidy. Everything # below — the heredoc scan, both quote passes, the list and pipe splits — is @@ -100,7 +100,7 @@ violation contains { violation contains { "rule": "unsatisfiable-commit", - "verdict": "V-COMMIT-STDIN-UNBOUND", + "verdict": "commit bind missing", } if { some segment in input.call.segments @@ -116,7 +116,7 @@ violation contains { violation contains { "rule": "foreground-sleep", - "verdict": "V-FOREGROUND-SLEEP", + "verdict": "sleep run blocked", } if { sleeps @@ -130,7 +130,7 @@ violation contains { violation contains { "rule": "background-timer", - "verdict": "V-BACKGROUND-TIMER", + "verdict": "timer run refused", } if { sleeps input.call["run-in-background"] == true diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index c4562e662..9e4884e1d 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -183,7 +183,7 @@ governed_when_deleted(path) if is_bats(path) violation contains { "rule": "shell-rule-retired", - "verdict": "V-SHELL-RULE-ADDED", + "verdict": "shell add refused", "subjects": [{"path": path}], } if { some path in delta.added @@ -196,7 +196,7 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "verdict": "V-SHELL-RULE-EDITED", + "verdict": "shell edit refused", "subjects": [{"path": path}], } if { some path in delta.edited @@ -211,7 +211,7 @@ violation contains { # instance: `hooks-wiring-check.sh` carries a `DECLARED` table naming every # by-path hook registration, and its own `wiring-declaration-stale` refuses a row # whose subject no longer exists — so retiring `stop-guard.sh` forces a one-line -# deletion in a governed file, which this arm then refused. `V-SHELL-RULE-EDITED` +# deletion in a governed file, which this arm then refused. `shell edit refused` # declares no override route and no `bypass_env`, so the campaign was structurally # unable to complete a retirement it had itself mandated. # @@ -640,7 +640,7 @@ mentions_retired(_, line, gone) if { # case further on. Retiring a program requires editing the siblings that CALL it, # and a caller does not merely drop a line: it names the successor instead. The # truncation clause admits only shortening, so every such retirement was refused -# with no landable spelling — `V-SHELL-RULE-EDITED` declares no override route and +# with no landable spelling — `shell edit refused` declares no override route and # no `bypass_env`. # # THIS CLAUSE ALONE DOES NOT REACH `mise-tasks/graph-check.sh`, AND SAYING IT DID @@ -741,7 +741,7 @@ truncates_a_retired_reference(line, removed) if { violation contains { "rule": "shell-rule-retired", - "verdict": "V-RETIREMENT-UNMAPPED", + "verdict": "shell retire missing", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -751,7 +751,7 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "verdict": "V-RETIREMENT-AMBIGUOUS", + "verdict": "shell retire unclear", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -775,7 +775,7 @@ violation contains { # be waived is, and the arm that carries the coverage is not. violation contains { "rule": "shell-rule-retired", - "verdict": "V-SUCCESSOR-NO-SURFACE", + "verdict": "shell port missing", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -788,7 +788,7 @@ violation contains { violation contains { "rule": "shell-rule-retired", - "verdict": "V-SUCCESSOR-NO-TEST", + "verdict": "test port missing", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -814,7 +814,7 @@ violation contains { # would be a refusal that teaches nothing. violation contains { "rule": "shell-rule-retired", - "verdict": "V-SUCCESSOR-KIND-UNDECLARED", + "verdict": "shell port unnamed", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -835,7 +835,7 @@ violation contains { # to refuse. violation contains { "rule": "shell-rule-retired", - "verdict": "V-WITHDRAWAL-SUBJECT-ALIVE", + "verdict": "shell retire never", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -866,7 +866,7 @@ violation contains { # suite that this delta does not retire always does. violation contains { "rule": "shell-rule-retired", - "verdict": "V-RETIREMENT-SUBJECT-ALIVE", + "verdict": "program retire never", "subjects": [{"path": path}, {"path": subject}], } if { some path in delta.deleted @@ -893,7 +893,7 @@ violation contains { # not as a subject — and reading it as one refuses a conforming retirement. # Nothing positional separates the two, because the subject field is optional. # * `// withdrawn:` is subjects-and-reason with no successors at all, and -# `V-WITHDRAWAL-SUBJECT-ALIVE` above already decides subject survival for it +# `shell retire never` above already decides subject survival for it # through `withdrawn_subjects`. Reading it here too would be a second # authority over one question, answering from a weaker reading. # @@ -994,7 +994,7 @@ violation contains { # claim against. An arm with neither is a file deleted with a marker on it. violation contains { "rule": "shell-rule-retired", - "verdict": "V-WITHDRAWAL-UNEXPLAINED", + "verdict": "shell retire empty", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -1330,7 +1330,7 @@ test_dropping_a_line_naming_a_live_path_is_still_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # AND AN ADDED LINE IS NOT CLEANUP. Without this conjunct a change could delete a @@ -1349,7 +1349,7 @@ test_an_edit_that_also_adds_a_line_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # THE MEASURED SHAPE the truncation clause exists for. `hooks-wiring-check.sh` @@ -1388,7 +1388,7 @@ test_a_truncation_dropping_a_live_reference_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # THE REPOINTING, and the three cases that keep it from becoming a licence @@ -1424,7 +1424,7 @@ test_a_repointing_that_also_changes_the_line_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # ANTI-VACUITY (b): the target must be a successor the LEDGER declares, never one @@ -1443,7 +1443,7 @@ test_a_repointing_at_an_undeclared_target_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # ANTI-VACUITY (c): the retired path must be one THIS delta deleted, so a @@ -1461,7 +1461,7 @@ test_a_repointing_away_from_a_live_path_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # COULD NOT LOOK REFUSES. A base side this could not read leaves the removed set @@ -1479,7 +1479,7 @@ test_an_edit_with_no_readable_base_side_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } test_deleted_and_fully_mapped_passes if { @@ -1580,7 +1580,7 @@ test_a_kind_field_is_not_a_policy_surface if { # # The positive case: the subject dies in the same delta, the row carries a reason, # and NO successor is named. Under the three-arm module this exact input raised -# both `V-SUCCESSOR-NO-SURFACE` and `V-SUCCESSOR-NO-TEST`, which is the refusal the +# both `shell port missing` and `test port missing`, which is the refusal the # arm exists to remove. test_a_withdrawal_whose_subject_died_is_admitted if { count(violation) == 0 with input as {"tree": { @@ -1684,7 +1684,7 @@ test_a_carried_row_naming_a_live_subject_is_refused if { }, "lines": {"crates/batten/tests/old_gate.rs": ["// carried: tests/old-gate.bats mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs"]}, }} - v.verdict == "V-RETIREMENT-SUBJECT-ALIVE" + v.verdict == "program retire never" } # THE OTHER DIRECTION, and without it the arm above would be a ban on naming a @@ -1708,7 +1708,7 @@ test_a_row_whose_named_subject_died_is_admitted if { # after its two successors, so a sentence mentioning the governed program this # migration deliberately left standing must not read as a claim about it. Without # the marker bound in `named_and_alive` this input raises -# `V-RETIREMENT-SUBJECT-ALIVE` over a word in an explanation — a false refusal in +# `program retire never` over a word in an explanation — a false refusal in # the gate every retirement in CLOUD-843's campaign has to pass. test_a_changed_reason_naming_a_governed_path_is_not_a_subject if { count(violation) == 0 with input as {"tree": { @@ -1722,7 +1722,7 @@ test_a_changed_reason_naming_a_governed_path_is_not_a_subject if { } # AND THE WITHDRAWAL KEEPS ITS OWN ARM rather than gaining a second reading. Its -# reason is prose on the same footing, and `V-WITHDRAWAL-SUBJECT-ALIVE` already +# reason is prose on the same footing, and `shell retire never` already # decides whether its subject survived — from `withdrawn_subjects`, which requires # a named path to be in THIS delta's deleted set. test_a_withdrawal_reason_naming_a_governed_path_is_not_a_subject if { @@ -1912,7 +1912,7 @@ test_a_repointing_at_an_undeclared_invocation_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs runs:mise+run+old-gate"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # THE LOAD-BEARING NEGATIVE. Without the "span is a reference to a deleted path" @@ -1932,7 +1932,7 @@ test_replacing_a_span_that_is_not_a_retired_reference_is_refused if { "crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs runs:mise+run+old-gate"], }, }} - v.verdict == "V-SHELL-RULE-EDITED" + v.verdict == "shell edit refused" } # THE FIELD IS ADDITIVE: it satisfies neither successor obligation, so it cannot @@ -1944,9 +1944,9 @@ test_an_invocation_field_is_not_a_successor if { "lines": {"crates/batten/tests/old_gate.rs": ["// carried: mise-tasks/old-gate.sh runs:mise+run+old-gate"]}, }} - # `V-SUCCESSOR-NO-SURFACE`, never `V-RETIREMENT-UNMAPPED`: the arm EXISTS and + # `shell port missing`, never `shell retire missing`: the arm EXISTS and # is mapped, so what it fails is the successor obligation rather than the # mapping one. Naming the wrong verdict here was this case's own first defect, # and it would have passed over a module refusing for a different reason. - v.verdict == "V-SUCCESSOR-NO-SURFACE" + v.verdict == "shell port missing" } diff --git a/policy/shell-write-advisory.rego b/policy/shell-write-advisory.rego index 0fc0a4c7a..db925d701 100644 --- a/policy/shell-write-advisory.rego +++ b/policy/shell-write-advisory.rego @@ -120,7 +120,7 @@ is_bats(path) if { # disposition the tree gate admits. violation contains { "rule": "shell-write-at-the-edit", - "verdict": "V-SHELL-EDIT-BEFORE-RETIREMENT", + "verdict": "shell edit early", "subjects": [{"path": path}], } if { input.call.operation == "write" @@ -142,7 +142,7 @@ test_a_write_to_an_authored_shell_gate_is_flagged if { "operation": "write", "writes": "mise-tasks/ready-lint.sh", }} - v.verdict == "V-SHELL-EDIT-BEFORE-RETIREMENT" + v.verdict == "shell edit early" } test_a_write_to_a_bats_suite_is_flagged if { @@ -150,7 +150,7 @@ test_a_write_to_a_bats_suite_is_flagged if { "operation": "write", "writes": "tests/land.bats", }} - v.verdict == "V-SHELL-EDIT-BEFORE-RETIREMENT" + v.verdict == "shell edit early" } # The wider set the bound above names, asserted so the over-approximation is a @@ -160,7 +160,7 @@ test_a_nested_mise_tasks_path_is_flagged_though_the_edit_gate_would_not if { "operation": "write", "writes": "mise-tasks/lib/helper", }} - v.verdict == "V-SHELL-EDIT-BEFORE-RETIREMENT" + v.verdict == "shell edit early" } test_an_ungoverned_write_is_silent if { diff --git a/policy/spawn-adapters.rego b/policy/spawn-adapters.rego index c2d6b07be..5efa45f6b 100644 --- a/policy/spawn-adapters.rego +++ b/policy/spawn-adapters.rego @@ -136,7 +136,7 @@ module_of(path) := name if { # all, so there is nothing here for this module to leak even by mistake. violation contains { "rule": "spawn-adapters", - "verdict": "V-SPAWN-UNPLACED", + "verdict": "spawn place missing", "subjects": [{"path": site.path, "line": site.line}, {"artifact": module_of(site.path)}], } if { some site in input.tree.symbols.sites @@ -163,7 +163,7 @@ no_census if not input.tree.symbols.sites violation contains { "rule": "spawn-adapters", - "verdict": "V-SYMBOL-CENSUS-ABSENT", + "verdict": "symbol count absent", } if { no_census } @@ -172,7 +172,7 @@ violation contains { # cannot refuse is off. violation contains { "rule": "spawn-adapters", - "verdict": "V-ADAPTER-TABLE-EMPTY", + "verdict": "adapter table empty", } if { count(adapters) == 0 } diff --git a/policy/stop-posture.rego b/policy/stop-posture.rego index f0ae540b3..32f44d5af 100644 --- a/policy/stop-posture.rego +++ b/policy/stop-posture.rego @@ -115,7 +115,7 @@ hits := count(regex.find_n(data.batten.patterns["hedged-flag-framing"], scrubbed # mirror, and a mirror is cleared by restating it, which is the double-write. violation contains { "rule": "stop-posture", - "verdict": "V-HEDGED-FLAG-FRAMING", + "verdict": "prose report duplicate", "subjects": [{"count": hits}], } if { hits > 0 @@ -128,21 +128,21 @@ ending(text) := {"call": {"final-message": text}} test_a_hedged_flag_is_named if { some v in violation with input as ending("One thing I would flag is the exit code.") - v.verdict == "V-HEDGED-FLAG-FRAMING" + v.verdict == "prose report duplicate" } test_the_witnessed_miss_fires if { # `worth naming` — the sentence that reached chat and nothing else, and became # CLOUD-380 only because a human asked. some v in violation with input as ending("One open thread worth naming: the census never interrogated host settings.") - v.verdict == "V-HEDGED-FLAG-FRAMING" + v.verdict == "prose report duplicate" } test_both_openers_share_one_verb_set if { # CLOUD-387's asymmetry: `mentioning` was a flagging verb under one opener and # unknown under the other, so the pair is asserted rather than one of them. some v in violation with input as ending("It bears mentioning that this is worth mentioning.") - v.verdict == "V-HEDGED-FLAG-FRAMING" + v.verdict == "prose report duplicate" } # THE SCRUB, and each case is a span the shell's first version leaked through. diff --git a/policy/suite-subject-retirable.rego b/policy/suite-subject-retirable.rego index 9de9b12e1..d7133e950 100644 --- a/policy/suite-subject-retirable.rego +++ b/policy/suite-subject-retirable.rego @@ -30,7 +30,7 @@ # # * RE-SUBJECT THEM — rewriting a `# subject:` line means editing a governed # `tests/**/*.bats`. `governed_at_head` selects every bats suite -# (`shell-retirement.rego:135`), so each is `V-SHELL-RULE-EDITED`, which +# (`shell-retirement.rego:135`), so each is `shell edit refused`, which # declares one route and no `bypass_env`. The one admitted edit requires every # REMOVED line to name a path the same delta deletes, and a re-subjected header # names paths that are staying. Refused. @@ -115,7 +115,7 @@ suites := {path | some path, _ in input.tree.lines; is_bats(path)} # The `# subject:` header, split on whitespace. # # A partial rule keyed by path rather than a function, so a suite carrying no -# header simply has no entry — which is what `V-SUITE-SUBJECT-UNDECLARED` below +# header simply has no entry — which is what `suite declare missing` below # catches, rather than letting it fall through as a suite with nothing wrong. declared[path] := parts if { some path in suites @@ -213,7 +213,7 @@ exempt := { violation contains { "rule": "suite-subject-retirable", - "verdict": "V-SUITE-SUBJECT-IMMORTAL", + "verdict": "suite retire never", "subjects": [{"path": path}, {"path": subject}], } if { some path, subjects in declared @@ -231,7 +231,7 @@ violation contains { # `SubjectFacts::died` would have nothing to decide over. violation contains { "rule": "suite-subject-retirable", - "verdict": "V-SUITE-SUBJECT-UNDECLARED", + "verdict": "suite declare missing", "subjects": [{"path": path}], } if { some path in suites @@ -261,7 +261,7 @@ violation contains { # retired suite leaves a spent row until someone reads the table. violation contains { "rule": "suite-subject-retirable", - "verdict": "V-SUITE-EXEMPTION-STALE", + "verdict": "suite admit stale", "subjects": [{"path": path}], } if { some path, _ in exempt @@ -278,7 +278,7 @@ violation contains { # is the class `.claude/rules/policy-modules.md` records for this channel. violation contains { "rule": "suite-subject-retirable", - "verdict": "V-SUITE-SOURCE-UNREAD", + "verdict": "suite parse unread", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -305,7 +305,7 @@ test_an_immortal_subject_is_reported if { found := violation with input as tree({"tests/x.bats": ["# subject: mise.toml"]}, []) count(found) == 1 some finding in found - finding.verdict == "V-SUITE-SUBJECT-IMMORTAL" + finding.verdict == "suite retire never" } # A suite subjecting one retirable path AND one immortal one is reported — the @@ -313,7 +313,7 @@ test_an_immortal_subject_is_reported if { test_one_immortal_subject_among_several_is_reported if { found := violation with input as tree({"tests/x.bats": ["# subject: mise-tasks/x.sh hk.pkl"]}, []) some finding in found - finding.verdict == "V-SUITE-SUBJECT-IMMORTAL" + finding.verdict == "suite retire never" } # A declared exemption silences arm A and nothing else. @@ -327,7 +327,7 @@ test_a_suite_declaring_no_subject_is_reported if { found := violation with input as tree({"tests/x.bats": ["@test 'a' { true; }"]}, []) count(found) == 1 some finding in found - finding.verdict == "V-SUITE-SUBJECT-UNDECLARED" + finding.verdict == "suite declare missing" } # AN EXEMPTION WHOSE SUITE IS SIMPLY NOT IN THIS TREE IS NOT A FINDING, which is @@ -341,21 +341,21 @@ test_a_tree_that_is_not_this_corpus_is_not_judged_against_the_table if { test_an_exemption_for_a_now_retirable_suite_is_reported if { found := violation with input as tree({"tests/verify.bats": ["# subject: mise-tasks/verify.sh"]}, []) some finding in found - finding.verdict == "V-SUITE-EXEMPTION-STALE" + finding.verdict == "suite admit stale" } # COULD NOT LOOK stays loud, and is spelled differently from both answers. test_an_unreadable_suite_is_loud if { found := violation with input as tree({}, ["tests/x.bats"]) some finding in found - finding.verdict == "V-SUITE-SOURCE-UNREAD" + finding.verdict == "suite parse unread" } # A non-suite path in `missing` is not this rule's business. test_an_unreadable_non_suite_is_not_this_rules_business if { found := {f | some f in violation with input as tree({}, ["mise.toml"]) - f.verdict == "V-SUITE-SOURCE-UNREAD" + f.verdict == "suite parse unread" } count(found) == 0 } diff --git a/policy/task-substitution.rego b/policy/task-substitution.rego index 831feedae..cd3240f08 100644 --- a/policy/task-substitution.rego +++ b/policy/task-substitution.rego @@ -104,7 +104,7 @@ runs_a_task(segment) if { violation contains { "rule": "task-substitution", - "verdict": "V-TASK-SUBSTITUTION", + "verdict": "task run loose", "subjects": [{"artifact": task}], } if { some task in substituted @@ -131,7 +131,7 @@ lint := {"lint": ["cargo", "clippy", "--all-targets"]} test_a_bare_tool_call_is_refused if { some v in violation with input as call("cargo clippy", lint) - v.verdict == "V-TASK-SUBSTITUTION" + v.verdict == "task run loose" } # THE REFUSAL CARRIES THE TASK NAME, which is the whole of its value (CLOUD-437). @@ -172,7 +172,7 @@ compound(command, first, second, tasks) := { # two strings. test_a_tool_call_in_a_later_segment_is_refused if { some v in violation with input as compound("cd /tmp && cargo clippy", "cd /tmp", "cargo clippy", lint) - v.verdict == "V-TASK-SUBSTITUTION" + v.verdict == "task run loose" } # THE CASE THAT DISCRIMINATES THE RELATION, and the defect that produced diff --git a/policy/test-targets.rego b/policy/test-targets.rego index 6066e52aa..4b775850e 100644 --- a/policy/test-targets.rego +++ b/policy/test-targets.rego @@ -36,7 +36,7 @@ #MUTANT depth-may-invert|s@count(segments) == 4@count(segments) == 5@|a module inside the group is not a target, and a new top-level file is #MUTANT extension-may-widen|s@endswith(path, ".rs")@true@|a fixture file under tests/ is not a target # -#MUTANT-EXEMPT CLOUD-1210|no `tests/test-targets.bats` exists and none may: `.claude/rules/toolchain.md`'s two-shapes rule and `V-SHELL-RULE-ADDED` refuse adding an authored bats suite, and `mutant` resolves a gate's suite as `tests/$gate.bats`, so there is no named case a mutation could turn red. The second tier is `crates/batten/tests/it/test_targets.rs`, which drives the compiled engine over a real fixture repository with a real base ref — and is what caught the inverted depth test the first `#MUTANT` row above records +#MUTANT-EXEMPT CLOUD-1210|no `tests/test-targets.bats` exists and none may: `.claude/rules/toolchain.md`'s two-shapes rule and `shell add refused` refuse adding an authored bats suite, and `mutant` resolves a gate's suite as `tests/$gate.bats`, so there is no named case a mutation could turn red. The second tier is `crates/batten/tests/it/test_targets.rs`, which drives the compiled engine over a real fixture repository with a real base ref — and is what caught the inverted depth test the first `#MUTANT` row above records package batten import rego.v1 @@ -77,7 +77,7 @@ added_target contains path if { violation contains { "rule": "test-target-added", - "verdict": "V-TEST-TARGET-ADDED", + "verdict": "test add refused", "subjects": [{"path": path}], } if { some path in added_target diff --git a/policy/validator-verdict-clean.rego b/policy/validator-verdict-clean.rego index 0a8e7ee5f..16a24bc9e 100644 --- a/policy/validator-verdict-clean.rego +++ b/policy/validator-verdict-clean.rego @@ -99,7 +99,7 @@ findings(verdict) := {key | violation contains { "rule": "validator-verdict-clean", - "verdict": "V-VALIDATOR-VERDICT-UNCLEAN", + "verdict": "tool judge dirty", "subjects": [{"count": count(refused)}], } if { count(refused) > 0 @@ -123,14 +123,14 @@ test_a_clean_record_is_clean if { test_a_record_carrying_a_finding_is_refused if { some v in violation with input as recorded({"status": "clean", "unresolved-key": "hk.pkl:12"}) - v.verdict == "V-VALIDATOR-VERDICT-UNCLEAN" + v.verdict == "tool judge dirty" } # A validator that reported an error and listed nothing is still an error, and # reading it as clean is the false pass this family exists to close. test_a_non_clean_status_alone_is_refused if { some v in violation with input as recorded({"status": "error"}) - v.verdict == "V-VALIDATOR-VERDICT-UNCLEAN" + v.verdict == "tool judge dirty" } # NOTHING HAS VALIDATED THESE BYTES is not a verdict. The id is absent from the diff --git a/policy/verdict-routes-resolve.rego b/policy/verdict-routes-resolve.rego index fd0a99eb9..7bb083d60 100644 --- a/policy/verdict-routes-resolve.rego +++ b/policy/verdict-routes-resolve.rego @@ -129,7 +129,7 @@ mise_task(command) := task if { violation contains { "rule": "verdict-routes-resolve", - "verdict": "V-ROUTE-TASK-UNDEFINED", + "verdict": "route name undefined", "subjects": [{"artifact": entry.verdict}, {"artifact": entry.route}, {"artifact": entry.task}], } if { # Could-not-look guard, `command-task-defined`'s: with no task namespace @@ -148,7 +148,7 @@ violation contains { # spelled the same way as a registry whose every route resolves. violation contains { "rule": "verdict-routes-resolve", - "verdict": "V-AUTHORITY-UNPARSED", + "verdict": "config parse broken", "subjects": [{"path": path}], } if { some path in input.tree.missing diff --git a/policy/weakens-declared.rego b/policy/weakens-declared.rego index e221c6057..27dc6ce96 100644 --- a/policy/weakens-declared.rego +++ b/policy/weakens-declared.rego @@ -52,7 +52,7 @@ weakens contains value if { violation contains { "rule": "weakens-declared", - "verdict": "V-WEAKENS-DECLARES-NOTHING", + "verdict": "commit declare empty", "subjects": [{"count": count(empty)}], } if { count(empty) > 0 @@ -83,12 +83,12 @@ test_a_declared_key_is_clean if { test_a_trailer_naming_nothing_is_refused if { some v in violation with input as range(["Weakens:"]) - v.verdict == "V-WEAKENS-DECLARES-NOTHING" + v.verdict == "commit declare empty" } test_whitespace_is_not_a_declaration if { some v in violation with input as range(["Weakens: "]) - v.verdict == "V-WEAKENS-DECLARES-NOTHING" + v.verdict == "commit declare empty" } test_an_unrelated_trailer_is_ignored if { diff --git a/policy/workspace-dep-referenced.rego b/policy/workspace-dep-referenced.rego index bc6091025..1e329d515 100644 --- a/policy/workspace-dep-referenced.rego +++ b/policy/workspace-dep-referenced.rego @@ -84,7 +84,7 @@ referenced contains key if { # so the message is where the pointer lives. violation contains { "rule": "workspace-dep-referenced", - "verdict": "V-WORKSPACE-DEP-ORPHANED", + "verdict": "workspace declare unused", "subjects": [{"artifact": key}], } if { some key in declared @@ -95,7 +95,7 @@ violation contains { # in `missing`, and without this the walk above simply does not see it. violation contains { "rule": "workspace-dep-referenced", - "verdict": "V-MANIFEST-UNPARSED", + "verdict": "manifest parse broken", "subjects": [{"path": path}], } if { some path in input.tree.missing @@ -108,7 +108,7 @@ violation contains { # input rather than assumed away. violation contains { "rule": "workspace-dep-referenced", - "verdict": "V-WORKSPACE-TABLE-ABSENT", + "verdict": "workspace table absent", } if { count(declared) == 0 } diff --git a/renovate.json5 b/renovate.json5 index 2c576af63..25f717c97 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -326,7 +326,7 @@ // // THIS IS NOT THE FIX AND MUST NOT BE MISTAKEN FOR ONE. Renaming the seam // means editing the task AND its suite, both frozen by - // `V-SHELL-RULE-EDITED` whose sole route is `R-PORT-AND-RETIRE`. CLOUD-1262 + // `shell edit refused` whose sole route is `rule read first`. CLOUD-1262 // tracks that retirement. This row buys quiet, not currency: the tool whose // whole job is dependency currency stays three majors stale until the port // lands, and leaving this row here while forgetting CLOUD-1262 is exactly diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 4b0b8b8e6..7b533557c 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -341,6 +341,10 @@ "format": "uint32", "minimum": 0 }, + "vocabulary": { + "description": "The three positional word lists every class and route name is drawn from\n(CLOUD-1284), and the tokenizer pin they were measured under.\n\nA **dictionary rather than a per-class essay**: a name spends three words\nand each word's meaning is declared once, so the marginal class costs no\nnew prose. `verdict::validate` holds every name to it, which is what makes\nthe naming convention a gate rather than a habit.", + "$ref": "#/$defs/Vocabulary" + }, "waiver": { "description": "The designed escape hatch (CLOUD-208): per-rule waivers, each carrying a\nrequired justification and a required expiry.\n\nA waiver suppresses findings of the rule it names, and **lapses on its own\ndate** — which is what makes the suppression set stop growing\nmonotonically without anyone having to look at it. Not a severity: the\nfilter runs over findings before the verdict, and [`crate::severity`]'s\nthree axes are untouched. The type and the predicate are\n[`crate::waiver`].", "type": "array", @@ -989,7 +993,7 @@ "type": "string" }, "id": { - "description": "The token, e.g. `V-TASK-UNDEFINED`.", + "description": "The token, e.g. `task name undefined`.", "type": "string" }, "route": { @@ -2386,7 +2390,7 @@ "type": "object", "properties": { "id": { - "description": "The id a rendered refusal names, e.g. `R-DEFINE-THE-TASK`.\n\nStable and referenceable: an agent told `R-DEFINE-THE-TASK` twice has\nbeen told the same thing twice, which a paraphrase cannot establish.", + "description": "The id a rendered refusal names, e.g. `task read first`.\n\nStable and referenceable: an agent told `task read first` twice has\nbeen told the same thing twice, which a paraphrase cannot establish.", "type": "string" }, "kind": { @@ -3666,6 +3670,74 @@ "program" ] }, + "Vocabulary": { + "description": "The three positional lists a name is drawn from, and the pin they were\nmeasured under (CLOUD-1284).\n\n# Why the middle list is `action` and not `verb`\n\nThe grammar is ` ` and the issue writes the\nmiddle slot as \"verb\". The config key cannot be `verb`: `[[verb]]` is already\nthis config's table of mutating **shell** verbs, and two unrelated tables one\nletter apart is the drift a reader pays for every time. The prose keeps the\ngrammatical word; the key states which table it belongs to.\n\n# Why the pin is data and not a constant\n\nThe token counts are model-specific, and `bench/tokens/method.toml` already\nsets this repository's discipline for a constant a published figure depends\non: state it with its source and the date it was read, so a reader checks the\narithmetic against the primary rather than trusting a program. The *ratio*\nargument survives a different tokenizer — common English words are\nsingle-merge in every modern BPE vocabulary — but the exact integer does not,\nso the integer's provenance travels with it.", + "type": "object", + "properties": { + "action": { + "description": "Slot 2: what was done, or what relation is being judged.", + "type": "array", + "items": { + "$ref": "#/$defs/VocabularyWord" + } + }, + "condition": { + "description": "Slot 3: the state that makes it a refusal.", + "type": "array", + "items": { + "$ref": "#/$defs/VocabularyWord" + } + }, + "subject": { + "description": "Slot 1: what the finding is about.", + "type": "array", + "items": { + "$ref": "#/$defs/VocabularyWord" + } + }, + "tokenizer": { + "description": "The encoding every word's token count was measured under.", + "type": [ + "string", + "null" + ] + }, + "tokenizer_retrieved": { + "description": "When it was read.", + "type": [ + "string", + "null" + ] + }, + "tokenizer_source": { + "description": "Where that encoding is published.", + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + }, + "VocabularyWord": { + "description": "One declared vocabulary word: the spelling, and what it means in a name.\n\nThe gloss is what makes dropping the per-class essay from the hot path safe\nrather than merely cheap (CLOUD-1284). A class used to buy a new essay to\nexplain its own free-text name; a word buys one gloss that every name using\nit reuses, so the *marginal* class costs no new prose at all.", + "type": "object", + "properties": { + "gloss": { + "description": "What this word contributes to a name that uses it.", + "type": "string" + }, + "word": { + "description": "The spelling, as it appears in a name. One token under the declared pin.", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "word", + "gloss" + ] + }, "Waiver": { "description": "One declared waiver: which rule, why, until when, and optionally where.", "type": "object", From 6470ef2bdb52ddf0c1fb4865197bf870dea51d5d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 12:11:27 +0000 Subject: [PATCH 03/20] fix(policy): make the stays-bash route clear the verdict that offers it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1088. `shell add refused` declared two routes, and the second -- "declare that it stays bash" -- DID NOT CLEAR THE VERDICT THAT OFFERED IT. Measured 2026-08-28: prepending `# stays-bash: ` to `tests/wiring-reclaim.bats` and re-running `batten check --rule shell-retirement` left the finding byte-identical. The reason was structural rather than a typo. `admits_with = "# stays-bash:"` belongs to `bash-surface-not-growing`, whose glob is `mise-tasks/**`, so it never reaches `tests/**`; and the added arm carried no admission clause at all. So on a bats suite the named remedy was unreachable twice over, and on a `mise-tasks/` path it cleared a DIFFERENT rule while leaving this one standing. It mattered because two landed policies MANDATE what this refused. `.claude/rules/policy-modules.md` requires a door migration's second tier over the compiled binary, and CLOUD-312's per-row obligation says the same for a handler destination -- so a migration was required to add a suite this arm refused, with a route that could not clear it. The added arm now honours the declaration on the ADDED PATH ITSELF, so the route works on every surface the class is raised over. The token stays `bash-surface-not-growing`'s: one spelling for one concept, so an author who learned it once carries it to either surface. `line_sources` GAINS `tests/**/*.bats`, and that is not tidiness. The clause reads `input.tree.lines[path]`, which is exactly that list -- without the glob it would evaluate over a key nothing fills, never hold, and ship as a dead gate inside the fix for a dead route. That is CLOUD-845's class arriving in the repair for its own sibling. THE RATCHET IS NOT WEAKENED, and that bound is why this is the ADDED arm only. `shell edit refused` is untouched -- one route, no override, no `bypass_env` -- because an edit is the move that reads as progress and is not. Five cases hold the line: three load-time (a bats suite admitted, a shell program admitted, and an EDIT carrying the same declaration still refused) and two over the compiled binary. The compiled pair is the one that matters, because only it proves the ENGINE acquires the lines; a `with input as` case fabricates the very shape the engine may be unable to produce. THE SECOND ACCEPTANCE CLAUSE HAS ITS PREMISE REMOVED RATHER THAN LEFT UNDONE. The row also asks for a load-time refusal of a route whose mechanism cannot clear its own verdict. After this change no route in this tree depends on another rule's `admits_with` -- the admission is the arm's own -- so that check would ship with zero subjects, which is the dead gate this repository refuses. Recorded here rather than built. ALSO IN THIS COMMIT, all of it CLOUD-1284 fallout the narrower per-gate tasks could not see and only `test:cargo` did: * `common::tokens_in` still filtered raised tokens on a `V-` prefix, so every fixture-derived registry came back empty and 40 `shell_retirement` cases went red over a module that was fine. Rebound to the ARITY, which keeps the bound its own doc claims: a `test_` rule's fixture input is still excluded rather than declared as dead vocabulary. * 128 fixture tokens across 36 files were still spelled `V-…` in raising position, so the load refused them as undeclared. * `WeakeningKind::VocabularyAbandoned` was in the enum and its census row but missing from `ALL`, and then had no case exercising it -- two separate derived tests reading `trust.rs`'s own source, each catching its own half. * The derived man pages needed regenerating. Refs: CLOUD-1088, CLOUD-1284 Admits: 12b909c4c089a11ecff9dd8bfcd160110544c0aede93725ae06be1a101d22e98 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-head: bc836893d586ab08092349a1a24941cc011d2558 Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: A declared route stays unreachable and two landed policies keep mandating what a third refuses: a door migration is required to add a compiled-binary tier, and this arm refuses adding one, with a remedy that clears a different rule. Admits-answer-precondition: CLOUD-1088's whole subject is this module's `added` arm: it raises `shell add refused` while carrying no admission clause, so the route that class offers cannot clear it. The predicate lives here and nowhere else, so the module is both the surface that owns the fix and the path the gate protects. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which declares the class but not the predicate, so it cannot express the arm. `patch run first` is `git restore`, which puts the defect back. Admits: a3f9361f7cf025ec28f1e69aedff0fd71f1f0fcf8a2551dd51f6b19bbd25bee9 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: bc836893d586ab08092349a1a24941cc011d2558 Admits-epoch: f89c797957500d491a7a75a2131d16f43ff9cd4187c0d6e9bb8179e5f760c90c Admits-author: alec@wenzowski.com Admits-prev: e48c57d974996c843a78471d5986eab38e3107adf53d67a535a93767f62a938a Admits-answer-lost: The fix would ship as a dead gate. The arm would read `input.tree.lines` for a bats path that `line_sources` never acquires, so the declaration would still not clear the verdict and the module would report clean while deciding nothing. Admits-answer-precondition: CLOUD-1088 needs two things only batten.toml can carry: `shell-retirement`'s `line_sources` must reach `tests/**/*.bats`, or the admission clause is dead for exactly the paths the row is about, and `shell add refused`'s class text must stop naming a mechanism that clears a different rule. Both are config; no other surface expresses either. Admits-answer-rejected-route: `config read first` resolves to batten.toml, the file being refused, so the remedy it names is the thing denied. `patch run first` is `git restore`, which restores the unreachable route. Admits: ff78fde0aff5605b167d7b948c1afa9cbfd787a498aab61a5906c132f5ce4195 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: bc836893d586ab08092349a1a24941cc011d2558 Admits-epoch: 111df297017faa5ba1aa29924118fcb88b4c724593f547811c7359504c1532b9 Admits-author: alec@wenzowski.com Admits-prev: a3f9361f7cf025ec28f1e69aedff0fd71f1f0fcf8a2551dd51f6b19bbd25bee9 Admits-answer-lost: The class would keep telling its reader to reach a mechanism that does not reach them, which is the defect CLOUD-1088 exists to close rather than a wording preference. Admits-answer-precondition: `shell add refused`'s class text names `bash-surface-not-growing`'s ratchet as the mechanism that admits a new file, and CLOUD-1088 measured that this is false for every `tests/**` path the class is also raised over. The class prose is config and lives only here, so batten.toml is both the surface that owns the correction and the path the gate protects. Admits-answer-rejected-route: `config read first` resolves to batten.toml, the file being refused. `patch run first` is `git restore`, which puts the false claim back. Admits: 6329c43861dbcb577c8a2670ed901d4bbdd7fc49c9d95f697bf8f6e05c7d7186 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/shell-retirement.rego Admits-head: bc836893d586ab08092349a1a24941cc011d2558 Admits-epoch: 30c658dacad677461143c9255951811cccc1553f78e9d0ef92621e6be7d2896b Admits-author: alec@wenzowski.com Admits-prev: 12b909c4c089a11ecff9dd8bfcd160110544c0aede93725ae06be1a101d22e98 Admits-answer-lost: The fix would ship with no case proving the declaration now clears the verdict, and none proving it still does not admit an EDIT. Without the second the first would pass over a blanket allow, which is the direction that would weaken the ratchet. Admits-answer-precondition: The admission clause added to this module's `added` arm owes its own load-time cases, and a module's `test_` rules live in the module. CLOUD-418 requires the pair be shown able to discriminate, so the cases and the predicate are one artifact and cannot be written anywhere else. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which declares the class but holds no `test_` rule. `patch run first` is `git restore`, which discards the cases. Admits: 4577cb40b9de39a019a72f8e5ea304af64304075e1c44540c6bfa0c4fe0f9bf1 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/verdict-routes-resolve.rego Admits-head: bc836893d586ab08092349a1a24941cc011d2558 Admits-epoch: 9b266c23073f4740b6af63363c75c697adbe1d280ef7054617fc30109667710a Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The module's own load-time tier goes red on fixtures rather than on the predicate, which is the tier reporting about itself instead of about the rule. Admits-answer-precondition: This module's `test_` rules construct fixture registries whose tokens were spelled in the retired `V-` shape. CLOUD-1284 removed that shape, and the harness that derives a fixture registry now recognises a raised class by its arity, so a fixture token left in the old spelling is no longer declared and its own case fails to load. The cases live in the module, so the module is the only surface that can carry the correction. Admits-answer-rejected-route: `config read first` resolves to batten.toml, which declares no `test_` rule. `patch run first` is `git restore`, which puts back tokens the registry no longer declares. --- .../refusal_is_typed_test.rego | 4 +- batten.toml | 25 ++++-- crates/batten/src/hook.rs | 12 +-- crates/batten/src/perf.rs | 6 +- crates/batten/src/policy.rs | 6 +- crates/batten/src/trust.rs | 79 ++++++++++++++++-- crates/batten/src/verdict.rs | 59 +++++++------ .../repos/policy-mediated-call/batten.toml.in | 4 +- .../repos/policy-mediated-call/gate.rego.in | 2 +- crates/batten/tests/it/acquisition_sweep.rs | 5 +- crates/batten/tests/it/admission.rs | 20 ++--- .../batten/tests/it/call_background_flag.rs | 8 +- crates/batten/tests/it/captured_facts.rs | 24 +++--- crates/batten/tests/it/commit_meta_facts.rs | 30 +++---- crates/batten/tests/it/common/mod.rs | 18 +++- crates/batten/tests/it/document_read_count.rs | 2 +- crates/batten/tests/it/external_facts.rs | 22 ++--- crates/batten/tests/it/extracted_facts.rs | 18 ++-- crates/batten/tests/it/forge_facts.rs | 12 +-- crates/batten/tests/it/git_facts.rs | 6 +- crates/batten/tests/it/history_facts.rs | 30 +++---- crates/batten/tests/it/mediated_admission.rs | 2 +- crates/batten/tests/it/memories.rs | 10 +-- crates/batten/tests/it/pointer_only.rs | 6 +- crates/batten/tests/it/policy_severity.rs | 28 +++---- crates/batten/tests/it/policy_test_suite.rs | 14 ++-- crates/batten/tests/it/policy_tree.rs | 32 ++++---- crates/batten/tests/it/rules_drift.rs | 10 +-- crates/batten/tests/it/shell_retirement.rs | 66 +++++++++++++-- crates/batten/tests/it/sinks.rs | 26 +++--- crates/batten/tests/it/staged_facts.rs | 42 +++++----- crates/batten/tests/it/stop_posture.rs | 2 +- crates/batten/tests/it/task_receipt.rs | 16 ++-- crates/batten/tests/it/tool_verdict_facts.rs | 12 +-- crates/batten/tests/it/verdict_registry.rs | 82 ++++++++++--------- crates/batten/tests/policy_modules.rs | 38 ++++----- man/batten-override-request.1 | 2 +- man/batten-override-spend.1 | 2 +- man/batten-policy-explain.1 | 2 +- policy/shell-retirement.rego | 76 +++++++++++++++++ policy/verdict-routes-resolve.rego | 22 ++--- 41 files changed, 561 insertions(+), 321 deletions(-) diff --git a/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego b/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego index d60c29402..2c9a97309 100644 --- a/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego +++ b/.regal/rules/custom/regal/rules/abi/refusal-is-typed/refusal_is_typed_test.rego @@ -12,7 +12,7 @@ typed := `package batten.example violation contains { "rule": "a-gate", - "verdict": "V-A-CLASS", + "verdict": "a class probe", "subjects": [{"path": "a.rs"}], } if { input.call.operation == "write" @@ -52,7 +52,7 @@ msg_as_a_value := `package batten.example violation contains { "rule": "a-gate", - "verdict": "V-A-CLASS", + "verdict": "a class probe", "subjects": [{"artifact": "msg"}], } if { input.call.operation == "write" diff --git a/batten.toml b/batten.toml index 750e5e7e6..b9a35cb1e 100644 --- a/batten.toml +++ b/batten.toml @@ -4139,6 +4139,15 @@ delta_sources = ["**"] # Measured on CLOUD-312 row 10: three cases in two suites, no landable spelling. # The cost of reading them is `.claude/rules/rust.md`'s ~5.4 µs per declared # document against ~137 suites — under a millisecond, on a 100 ms budget. +# +# AND CLOUD-1088 IS ITS SECOND CONSUMER, which is why the glob now carries two +# reasons rather than one. `shell add refused` advertises a `# stays-bash:` +# declaration as its way out, and the ADDED arm honours one on the added path +# itself. That clause reads `input.tree.lines[path]` — exactly this list — so +# without the bats glob it would evaluate over a key nothing fills, never hold, +# and the route would still not clear the verdict it is offered for. The module +# would report clean while deciding nothing, which is the dead-gate class +# arriving inside the fix for a dead route. line_sources = ["mise-tasks/*.sh", "crates/batten/tests/**/*.rs", "tests/**/*.bats"] module = "policy/shell-retirement.rego" severity = "deny" @@ -5973,7 +5982,7 @@ expires = "2027-02-28" # holds every class and every route to it, so the naming convention is a gate # rather than a habit -- which it had never been. # -# WHY THREE WORDS RATHER THAN `V-SCREAMING-KEBAB`. Measured with `tiktoken` +# WHY THREE WORDS RATHER THAN `screaming kebab probe`. Measured with `tiktoken` # `o200k_base` over all 130 classes: the old spelling cost 9.9 tokens on # average, lowercase kebab 5.2, and a curated three-word name 3.0. At ~300 # refusals in a long session that is ~2,000 tokens an agent paid for a naming @@ -6933,11 +6942,15 @@ id = "shell add refused" gloss = "an authored shell rule or bats suite was added, moving CLOUD-843's corpus the wrong way" class = """ CLOUD-843's campaign is retiring 144 shell programs and 161 bats suites onto the \ -policy engine. A new one is invisible to every other sensor here: \ -`bash-surface-not-growing` admits it with a `# stays-bash:` declaration, and no \ -other gate asks whether a Rego surface could have carried the predicate instead. \ -Write it as a policy module with a compiled-binary test, or declare on its own \ -row that it stays bash. +policy engine. A new one is invisible to every other sensor here, because \ +`bash-surface-not-growing` counts programs and `bats-tests-not-deleted` counts \ +cases, and no other gate asks whether a Rego surface could have carried the \ +predicate instead. Write it as a policy module with a compiled-binary test, or \ +write `# stays-bash: ` IN THE ADDED FILE and own the increase. That \ +line is read on the added path itself (CLOUD-1088): it used to name only \ +`bash-surface-not-growing`'s ratchet, whose glob is `mise-tasks/**`, so on a \ +`tests/**` suite the declared route cleared a different rule and left this one \ +standing. """ [[verdict.route]] diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index ce2cf9fe2..de334e0d4 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -3726,7 +3726,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // agent could set the variable. This repository ruled on exactly that shape // for `issue file same`: *the point of the admission mechanism is that // the bare variable stops working*. The class declares an override route now - // (`R-ARTICULATE-THE-WRITE`) and the boundary honours a spent admission + // (`articulate the write`) and the boundary honours a spent admission // (`admit_mediated`), so there is a way through that leaves a record — which // is what makes taking this one away a repair rather than a wall. // @@ -9304,7 +9304,7 @@ mod tests { // vocabulary and the load would refuse it — which is the check doing its // job, and the reason the list is derived from the module rather than // shared across the cases. - let fixture_verdicts = ["V-VERIFY-RECEIPT-STALE", "V-REFUSED-BY-THE-MODULE"] + let fixture_verdicts = ["verify receipt stale", "refused by themodule"] .into_iter() .filter(|id| source.contains(id)) .map(|id| crate::verdict::DeclaredVerdict { @@ -9312,7 +9312,7 @@ mod tests { gloss: format!("the fixture class {id}"), class: format!("What {id} means, at length."), routes: vec![crate::verdict::Route { - id: "R-READ-THE-AUTHORITY".to_owned(), + id: "read the authority".to_owned(), kind: crate::verdict::RouteKind::Document, target: "batten.toml".to_owned(), precondition: None, @@ -9545,7 +9545,7 @@ rules contains "verify-receipt-stale" violation contains { "rule": "verify-receipt-stale", - "verdict": "V-VERIFY-RECEIPT-STALE", + "verdict": "verify receipt stale", } if { input.facts.receipts.verify == "stale-head" } @@ -9675,7 +9675,7 @@ package batten import rego.v1 -deny contains "V-REFUSED-BY-THE-MODULE" if { +deny contains "refused by themodule" if { contains(input.call.command, "forbidden") } "#; @@ -9699,7 +9699,7 @@ deny contains "V-REFUSED-BY-THE-MODULE" if { // fails a changed class, which is the discrimination the old one // had backwards. assert!( - rendered.contains("V-REFUSED-BY-THE-MODULE"), + rendered.contains("refused by themodule"), "the class the module raised travels: {rendered}" ); assert!( diff --git a/crates/batten/src/perf.rs b/crates/batten/src/perf.rs index c543312ff..efd97627a 100644 --- a/crates/batten/src/perf.rs +++ b/crates/batten/src/perf.rs @@ -963,7 +963,7 @@ const SWEEP_MODULE: &str = "package batten.acquisition\n\ \n\ violation contains {\n\ \t\"rule\": \"acquisition-bench\",\n\ - \t\"verdict\": \"V-ACQUISITION-BENCH\",\n\ + \t\"verdict\": \"acquisition bench probe\",\n\ \t\"subjects\": [{\"path\": path}],\n\ } if {\n\ \tsome path, doc in input.tree.documents\n\ @@ -976,7 +976,7 @@ const SWEEP_MODULE: &str = "package batten.acquisition\n\ const SWEEP_AUTHORITY_HEAD: &str = r#"version = 1 [[verdict]] -id = "V-ACQUISITION-BENCH" +id = "acquisition bench probe" gloss = "the bench fixture declared a document carrying the sentinel key" class = """ A generated fixture for CLOUD-935's acquisition sweep. It is never raised: the @@ -985,7 +985,7 @@ rather than about rendering findings. """ [[verdict.route]] -id = "R-REGENERATE-THE-FIXTURE" +id = "regenerate the fixture" kind = "document" target = "batten.toml" "#; diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index eb1bc57c2..58187cc43 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -2163,10 +2163,10 @@ struct DescribedRule { /// The string literal this rule's HEAD contributes, when it contributes one /// (CLOUD-1050). /// - /// `deny contains "V-X" if …` builds a set of strings, and the member is the + /// `deny contains "x probe probex" if …` builds a set of strings, and the member is the /// head's key. Read from the head rather than from the rule's literals, /// because a rule's literals include every argument it passes: the fixture - /// `deny contains "V-X" if contains(input.call.command, "forbidden")` has two + /// `deny contains "x probe probex" if contains(input.call.command, "forbidden")` has two /// string literals and exactly one of them is a token. Taking both reported /// `forbidden` as an undeclared class, which is this reader's own first /// firing and was caught by the suite rather than by reading. @@ -2647,7 +2647,7 @@ fn collect_string_values(value: &serde_json::Value, kind: &str, found: &mut Vec< /// `{"Object": {"fields": [[, , ], …]}}`, so a field /// is a three-element array and the pair this asks about is positional. Reading /// it that way rather than scanning for adjacent string literals is what keeps -/// `{"verdict": "V-X"}` distinguishable from `{"x": "verdict"}` — the second is +/// `{"verdict": "x probe probex"}` distinguishable from `{"x": "verdict"}` — the second is /// two literals in the same order and a proximity reader cannot tell them apart. /// /// `found` collects the STRING-literal values only. A value composed at runtime diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 526752c13..116b88bcb 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -832,6 +832,7 @@ impl WeakeningKind { WeakeningKind::HandlerRemoved, WeakeningKind::OfflineFallbackEnabled, WeakeningKind::ProtectedReaderAdded, + WeakeningKind::VocabularyAbandoned, ]; /// The stable, lowercase identifier used in machine output (§6). @@ -3062,11 +3063,11 @@ mod tests { fn verdict_row(id: &str, hatched: bool) -> String { let mut row = format!( "\n[[verdict]]\nid = \"{id}\"\ngloss = \"a class\"\nclass = \"what it means\"\n\n\ - [[verdict.route]]\nid = \"R-FIX-IT\"\nkind = \"document\"\ntarget = \"batten.toml\"\n" + [[verdict.route]]\nid = \"fix it probe\"\nkind = \"document\"\ntarget = \"batten.toml\"\n" ); if hatched { row.push_str( - "\n[[verdict.route]]\nid = \"R-ASK\"\nkind = \"override\"\n\ + "\n[[verdict.route]]\nid = \"ask probe probe\"\nkind = \"override\"\n\ precondition = \"you can state why the gate should not stand here\"\n", ); } @@ -3081,13 +3082,13 @@ mod tests { /// generates the questions an admission answers from its precondition. #[test] fn a_verdict_that_gained_an_override_route_is_a_weakening() { - let base = config(&verdict_row("V-A-CLASS", false)); - let working = config(&verdict_row("V-A-CLASS", true)); + let base = config(&verdict_row("a class probe", false)); + let working = config(&verdict_row("a class probe", true)); assert_eq!( only(&base, &working), Weakening::new( WeakeningKind::VerdictOverrideAdded, - "verdict[V-A-CLASS].override", + "verdict[a class probe].override", "absent", "present", ) @@ -3103,14 +3104,14 @@ mod tests { /// a property of the code rather than of the doc comment above it. #[test] fn deleting_or_rewording_a_verdict_is_not_a_weakening() { - let hatched = config(&verdict_row("V-A-CLASS", true)); + let hatched = config(&verdict_row("a class probe", true)); // Deleted entirely: fail-closed, because a module still raising the // token no longer loads. assert!(weakenings(&hatched, &config("")).is_empty()); // Reworded, hatch unchanged: what the refusal SAYS moved and what it // decides did not. let reworded = config( - &verdict_row("V-A-CLASS", true).replace("what it means", "what it means, restated"), + &verdict_row("a class probe", true).replace("what it means", "what it means, restated"), ); assert!(weakenings(&hatched, &reworded).is_empty()); } @@ -3637,6 +3638,70 @@ mod tests { } } + #[test] + fn abandoning_the_naming_vocabulary_is_a_weakening() { + // CLOUD-1284's grammar is OPT-IN on a declared `[vocabulary]`, which is + // what makes deleting the table the dangerous direction: arms 1, 2 and 5 + // stop deciding anything and every class name goes back to free text, + // with the config still loading clean. Nothing else in this comparison + // would see that. + let word = |w: &str| crate::verdict::VocabularyWord { + word: w.to_owned(), + gloss: "a word".to_owned(), + }; + let mut base = Config::declaring_nothing(); + base.vocabulary = crate::verdict::Vocabulary { + subject: vec![word("task")], + action: vec![word("read")], + condition: vec![word("first")], + ..crate::verdict::Vocabulary::default() + }; + let working = Config::declaring_nothing(); + + let found = weakenings(&base, &working); + assert!( + found + .iter() + .any(|weakening| weakening.kind == WeakeningKind::VocabularyAbandoned), + "a declared vocabulary going absent is a weakening: {found:?}" + ); + } + + #[test] + fn shrinking_the_naming_vocabulary_is_not_reported() { + // THE DIRECTION THAT MUST STAY QUIET, and without it the arm above would + // fire on ordinary work. Removing a word is not a weakening: a name that + // still spends it fails the load on its own, loudly, naming the word. So + // the whole table going away is the only silent case, and it is the only + // one reported. + let word = |w: &str| crate::verdict::VocabularyWord { + word: w.to_owned(), + gloss: "a word".to_owned(), + }; + let mut base = Config::declaring_nothing(); + base.vocabulary = crate::verdict::Vocabulary { + subject: vec![word("task"), word("shell")], + action: vec![word("read")], + condition: vec![word("first")], + ..crate::verdict::Vocabulary::default() + }; + let mut working = Config::declaring_nothing(); + working.vocabulary = crate::verdict::Vocabulary { + subject: vec![word("task")], + action: vec![word("read")], + condition: vec![word("first")], + ..crate::verdict::Vocabulary::default() + }; + + let found = weakenings(&base, &working); + assert!( + !found + .iter() + .any(|weakening| weakening.kind == WeakeningKind::VocabularyAbandoned), + "a narrowed vocabulary is not this weakening: {found:?}" + ); + } + #[test] fn a_rewritten_fact_command_is_a_weakening() { // The one weakening whose payoff is a FORGED FACT rather than a skipped diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 6930a7862..fae64ddfd 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -59,7 +59,7 @@ use crate::error::UsageError; // so the prefix was paying tokens for a job the arity now does for free. // // Measured over all 130 classes with `tiktoken` `o200k_base`, and the prefix is -// most of the bill rather than a rounding error: `V-SCREAMING-KEBAB` costs 9.9 +// most of the bill rather than a rounding error: `screaming kebab probe` costs 9.9 // tokens on average against a curated three-word name's 3.0, and the drop from // `V-` plus the uppercase run alone is 9.9 -> 5.2. At ~300 refusals a session // that is ~2,000 tokens the agent used to pay for a sigil. @@ -1026,7 +1026,7 @@ refusal names when one exists.", // surface deliberately, so the first question forces the asker to say // why the route they were already given does not reach. admit( - "R-ARTICULATE-THE-WRITE", + "articulate the write", "the surface this class names cannot express the change, so writing the \ protected path directly is the only route left, and the write is one a reviewer will see \ in the diff it lands in", @@ -1405,7 +1405,7 @@ mod tests { id: id.to_owned(), gloss: "a short line".to_owned(), class: "the long definition".to_owned(), - routes: vec![route("R-DO-THE-THING")], + routes: vec![route("do the thing")], successor: None, withdrawn: None, } @@ -1413,7 +1413,8 @@ mod tests { #[test] fn a_conforming_entry_validates() { - validate(&[entry("V-ONE")], &Vocabulary::default()).expect("a conforming registry loads"); + validate(&[entry("one probe probe")], &Vocabulary::default()) + .expect("a conforming registry loads"); } /// A three-slot fixture vocabulary, enough to spell `task read first`. @@ -1508,37 +1509,43 @@ mod tests { // consumer with no lists cannot satisfy membership, so refusing them // would be a demand with no fix available. `[[pattern]]`'s preset // exemption is the landed precedent. - validate(&[entry("V-LEGACY-NAME")], &Vocabulary::default()) + validate(&[entry("legacy name probe")], &Vocabulary::default()) .expect("a consumer that has not adopted the grammar still loads"); } #[test] fn a_duplicate_token_is_refused() { - assert!(validate(&[entry("V-ONE"), entry("V-ONE")], &Vocabulary::default()).is_err()); + assert!( + validate( + &[entry("one probe probe"), entry("one probe probe")], + &Vocabulary::default() + ) + .is_err() + ); } #[test] fn a_paragraph_gloss_is_refused() { - let mut bad = entry("V-ONE"); + let mut bad = entry("one probe probe"); bad.gloss = "x".repeat(GLOSS_MAX + 1); assert!(validate(&[bad], &Vocabulary::default()).is_err()); - let mut wrapped = entry("V-ONE"); + let mut wrapped = entry("one probe probe"); wrapped.gloss = "one\ntwo".to_owned(); assert!(validate(&[wrapped], &Vocabulary::default()).is_err()); } #[test] fn a_verdict_with_no_route_is_refused() { - let mut bad = entry("V-ONE"); + let mut bad = entry("one probe probe"); bad.routes.clear(); assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] fn an_override_alone_is_refused() { - let mut bad = entry("V-ONE"); + let mut bad = entry("one probe probe"); bad.routes = vec![Route { - id: "R-ASK".to_owned(), + id: "ask probe probe".to_owned(), kind: RouteKind::Override, target: String::new(), precondition: Some("you can state why".to_owned()), @@ -1548,9 +1555,9 @@ mod tests { #[test] fn an_override_with_no_precondition_is_refused() { - let mut bad = entry("V-ONE"); + let mut bad = entry("one probe probe"); bad.routes.push(Route { - id: "R-ASK".to_owned(), + id: "ask probe probe".to_owned(), kind: RouteKind::Override, target: String::new(), precondition: None, @@ -1560,38 +1567,38 @@ mod tests { #[test] fn a_command_route_carrying_a_precondition_is_refused() { - let mut bad = entry("V-ONE"); + let mut bad = entry("one probe probe"); bad.routes[0].precondition = Some("something".to_owned()); assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] fn a_successor_naming_nothing_is_refused() { - let mut bad = entry("V-OLD"); - bad.successor = Some("V-GONE".to_owned()); + let mut bad = entry("old probe probe"); + bad.successor = Some("gone probe probe".to_owned()); assert!(validate(&[bad], &Vocabulary::default()).is_err()); } #[test] fn a_cycling_chain_is_refused() { - let mut first = entry("V-A"); - first.successor = Some("V-B".to_owned()); - let mut second = entry("V-B"); - second.successor = Some("V-A".to_owned()); + let mut first = entry("a probe probe"); + first.successor = Some("b probe probe".to_owned()); + let mut second = entry("b probe probe"); + second.successor = Some("a probe probe".to_owned()); assert!(validate(&[first, second], &Vocabulary::default()).is_err()); } #[test] fn a_tombstone_resolves_to_its_live_successor() { - let mut old = entry("V-OLD"); - old.successor = Some("V-NEW".to_owned()); - let new = entry("V-NEW"); + let mut old = entry("old probe probe"); + old.successor = Some("new probe probe".to_owned()); + let new = entry("new probe probe"); let table = vec![old, new]; validate(&table, &Vocabulary::default()).expect("a terminating chain loads"); - let (resolved, retired) = resolve(&table, "V-OLD").expect("the token resolves"); - assert_eq!(resolved.id, "V-NEW"); + let (resolved, retired) = resolve(&table, "old probe probe").expect("the token resolves"); + assert_eq!(resolved.id, "new probe probe"); assert!(retired, "the token the reader asked for was retired"); - assert_eq!(live_tokens(&table), BTreeSet::from(["V-NEW"])); + assert_eq!(live_tokens(&table), BTreeSet::from(["new probe probe"])); } #[test] diff --git a/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in b/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in index 577a5086e..73c4cc993 100644 --- a/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in +++ b/crates/batten/tests/fixtures/repos/policy-mediated-call/batten.toml.in @@ -29,7 +29,7 @@ reason = "the module decides; this row only registers it" # names a class, and the authority says what that class means and what to do # about it. [[verdict]] -id = "V-VERDICT-DISCARDED" +id = "verdict discarded probe" gloss = "a detached mediated call discards its own verdict" class = """ Backgrounding a verdict-bearing command with `&` loses its exit status: the \ @@ -38,6 +38,6 @@ a success. Redirect to a file and read the log in a separate step. """ [[verdict.route]] -id = "R-REDIRECT-TO-A-FILE" +id = "redirect to afile" kind = "document" target = "batten.toml" diff --git a/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in b/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in index 197d818f0..83362481e 100644 --- a/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in +++ b/crates/batten/tests/fixtures/repos/policy-mediated-call/gate.rego.in @@ -8,6 +8,6 @@ import rego.v1 # The member is a VERDICT TOKEN since CLOUD-1050, not prose — the bare-string # channel is held to the same registry the attributed one is, so this class is # declared in the authority beside the row that registers this module. -deny contains "V-VERDICT-DISCARDED" if { +deny contains "verdict discarded probe" if { endswith(input.call.command, "&") } diff --git a/crates/batten/tests/it/acquisition_sweep.rs b/crates/batten/tests/it/acquisition_sweep.rs index 67cbd5746..c219a326d 100644 --- a/crates/batten/tests/it/acquisition_sweep.rs +++ b/crates/batten/tests/it/acquisition_sweep.rs @@ -156,7 +156,10 @@ fn the_floor_arm_carries_neither_a_rule_nor_the_verdict_it_would_raise() { let authority = std::fs::read_to_string(tree.join("batten.toml")).expect("the fixture authority"); assert!(!authority.contains("[[rule]]"), "{authority}"); - assert!(!authority.contains("V-ACQUISITION-BENCH"), "{authority}"); + assert!( + !authority.contains("acquisition bench probe"), + "{authority}" + ); let output = run(&tree, &["check"]); assert!( diff --git a/crates/batten/tests/it/admission.rs b/crates/batten/tests/it/admission.rs index 97c120961..6e8d84405 100644 --- a/crates/batten/tests/it/admission.rs +++ b/crates/batten/tests/it/admission.rs @@ -530,7 +530,7 @@ fn an_undeclared_class_is_refused_naming_the_registry_size() { "--rule", "prose-only", "--verdict", - "V-NO-SUCH-CLASS", + "no such class", "--subject", "a.rs", ], @@ -821,17 +821,17 @@ bundle = "policy-admits/" severity = "deny" [[verdict]] -id = "V-ALWAYS" +id = "always probe probe" gloss = "the fixture's row refuses unconditionally" class = "A fixture predicate that always fires, so a case has a finding to admit." [[verdict.route]] -id = "R-ADMITS-FIX" +id = "admits fix probe" kind = "command" target = "change the thing the fixture refuses" [[verdict.route]] -id = "R-OVERRIDE-ADMITS" +id = "override admits probe" kind = "override" precondition = "the refusal is the fixture's point and there is nothing to fix" "#; @@ -847,7 +847,7 @@ rules contains "always-refuses" violation contains { "rule": "always-refuses", - "verdict": "V-ALWAYS", + "verdict": "always probe probe", "subjects": [{"path": "a.rs"}], } "#; @@ -878,13 +878,13 @@ fn spend_for(root: &Path, subject: &str, reason: &str) -> String { "--rule", "always-refuses", "--verdict", - "V-ALWAYS", + "always probe probe", "--subject", subject, ], &format!( "precondition=the refusal is the fixture's point\nlost={reason}\n\ - rejected-route=R-ADMITS-FIX has nothing to change\n" + rejected-route=admits fix probe has nothing to change\n" ), ); let address = String::from_utf8_lossy(&issued.stdout).trim().to_owned(); @@ -899,7 +899,7 @@ fn spend_for(root: &Path, subject: &str, reason: &str) -> String { "--rule", "always-refuses", "--verdict", - "V-ALWAYS", + "always probe probe", "--subject", subject, ], @@ -963,13 +963,13 @@ fn an_issued_admission_that_was_never_spent_admits_nothing() { "--rule", "always-refuses", "--verdict", - "V-ALWAYS", + "always probe probe", "--subject", "a.rs", ], "precondition=the refusal is the fixture's point\n\ lost=nothing, which is the point of this case\n\ - rejected-route=R-ADMITS-FIX has nothing to change\n", + rejected-route=admits fix probe has nothing to change\n", ); assert_eq!( issued.status.code(), diff --git a/crates/batten/tests/it/call_background_flag.rs b/crates/batten/tests/it/call_background_flag.rs index ca416def2..95a861d88 100644 --- a/crates/batten/tests/it/call_background_flag.rs +++ b/crates/batten/tests/it/call_background_flag.rs @@ -31,12 +31,12 @@ module = "probe.rego" severity = "deny" [[verdict]] -id = "V-PROBE-BACKGROUNDED" +id = "probe backgrounded probe" gloss = "the probe saw a backgrounded call" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE" +id = "probe probe probe" kind = "document" target = "probe.rego" "#; @@ -54,7 +54,7 @@ rules contains "probe" violation contains { "rule": "probe", - "verdict": "V-PROBE-BACKGROUNDED", + "verdict": "probe backgrounded probe", } if { input.call["run-in-background"] == true } @@ -109,7 +109,7 @@ fn a_backgrounded_call_reaches_the_module() { let dir = fixture("backgrounded"); let (code, cause) = verdict(&dir, &envelope(Some(true))); assert_eq!(code, Some(2), "the flag must reach the module\n{cause}"); - assert!(cause.contains("V-PROBE-BACKGROUNDED"), "{cause}"); + assert!(cause.contains("probe backgrounded probe"), "{cause}"); } #[test] diff --git a/crates/batten/tests/it/captured_facts.rs b/crates/batten/tests/it/captured_facts.rs index cb154d297..81f0e4da3 100644 --- a/crates/batten/tests/it/captured_facts.rs +++ b/crates/batten/tests/it/captured_facts.rs @@ -74,42 +74,42 @@ node = "status" reduce = "present" [[verdict]] -id = "V-CAPTURED-STATE" +id = "captured state probe" gloss = "the declared reduction produced the expected state token" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-STATE" +id = "probe state probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-CAPTURED-COUNT" +id = "captured count probe" gloss = "the declared count reduction produced the expected number" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-COUNT" +id = "probe count probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-CAPTURED-PROSE" +id = "captured prose probe" gloss = "a payload's prose reached the policy input, which rule 4 refuses" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-PROSE" +id = "probe prose probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-CAPTURED-ABSENT" +id = "captured absent probe" gloss = "a key nothing captured answered anyway" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-ABSENT" +id = "probe absent probe" kind = "document" target = "probe.rego" "# @@ -135,7 +135,7 @@ rules contains "probe-absent" violation contains { "rule": "probe-state", - "verdict": "V-CAPTURED-STATE", + "verdict": "captured state probe", } if { is_object(input.tree.captured) input.tree.captured.state == "unstarted" @@ -143,7 +143,7 @@ violation contains { violation contains { "rule": "probe-count", - "verdict": "V-CAPTURED-COUNT", + "verdict": "captured count probe", } if { is_object(input.tree.captured) input.tree.captured.labels == 2 @@ -153,7 +153,7 @@ violation contains { # prose, so a widened projection or a relaxed reduction turns this red. violation contains { "rule": "probe-prose", - "verdict": "V-CAPTURED-PROSE", + "verdict": "captured prose probe", } if { is_object(input.tree.captured) some value in input.tree.captured @@ -164,7 +164,7 @@ violation contains { # A key nothing captured must be ABSENT, never present with a falsy answer. violation contains { "rule": "probe-absent", - "verdict": "V-CAPTURED-ABSENT", + "verdict": "captured absent probe", } if { is_object(input.tree.captured) input.tree.captured.missing == false diff --git a/crates/batten/tests/it/commit_meta_facts.rs b/crates/batten/tests/it/commit_meta_facts.rs index fd7b99d2a..1ce038f20 100644 --- a/crates/batten/tests/it/commit_meta_facts.rs +++ b/crates/batten/tests/it/commit_meta_facts.rs @@ -42,32 +42,32 @@ severity = "deny" commits = ["HEAD~1..HEAD"] [[verdict]] -id = "V-COMMIT-META-TRAILER" +id = "commit meta trailer" gloss = "a commit in the declared range carries the trailer the probe looks for" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE" +id = "probe probe probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-COMMIT-META-BODY" +id = "commit meta body" gloss = "a message body reached the policy input, which rule 4 refuses" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-BODY" +id = "probe body probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-COMMIT-META-AUTHOR" +id = "commit meta author" gloss = "a commit in the declared range carries an author identity" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-AUTHOR" +id = "probe author probe" kind = "document" target = "probe.rego" "#; @@ -84,32 +84,32 @@ severity = "deny" documents = ["batten.toml"] [[verdict]] -id = "V-COMMIT-META-TRAILER" +id = "commit meta trailer" gloss = "a commit in the declared range carries the trailer the probe looks for" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE" +id = "probe probe probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-COMMIT-META-BODY" +id = "commit meta body" gloss = "a message body reached the policy input, which rule 4 refuses" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-BODY" +id = "probe body probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-COMMIT-META-AUTHOR" +id = "commit meta author" gloss = "a commit in the declared range carries an author identity" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-AUTHOR" +id = "probe author probe" kind = "document" target = "probe.rego" "#; @@ -134,7 +134,7 @@ rules contains "probe-body" violation contains { "rule": "probe-trailer", - "verdict": "V-COMMIT-META-TRAILER", + "verdict": "commit meta trailer", } if { some commits in input.tree["commit-meta"] some entry in commits @@ -144,7 +144,7 @@ violation contains { violation contains { "rule": "probe-author", - "verdict": "V-COMMIT-META-AUTHOR", + "verdict": "commit meta author", } if { some commits in input.tree["commit-meta"] some entry in commits @@ -153,7 +153,7 @@ violation contains { violation contains { "rule": "probe-body", - "verdict": "V-COMMIT-META-BODY", + "verdict": "commit meta body", } if { some commits in input.tree["commit-meta"] some entry in commits diff --git a/crates/batten/tests/it/common/mod.rs b/crates/batten/tests/it/common/mod.rs index 8532881e0..0c055a4d2 100644 --- a/crates/batten/tests/it/common/mod.rs +++ b/crates/batten/tests/it/common/mod.rs @@ -712,7 +712,7 @@ pub(crate) fn verdicts(ids: &[&str]) -> Vec { gloss: format!("the fixture class {id}"), class: format!("What {id} means, at the length `batten policy explain` answers with."), routes: vec![batten::verdict::Route { - id: "R-READ-THE-AUTHORITY".to_owned(), + id: "read the authority".to_owned(), kind: batten::verdict::RouteKind::Document, target: "batten.toml".to_owned(), precondition: None, @@ -841,7 +841,7 @@ pub(crate) fn verdicts_in(root: &Path) -> Vec /// /// **Bound to the raising position, not to the prefix.** A bare `V-…` scan also /// picks up the tokens a module's own `test_` rules construct as fixture input — -/// `policy/verdict-routes-resolve.rego` carries `V-X` in six of them — and +/// `policy/verdict-routes-resolve.rego` carries `x probe probex` in six of them — and /// declaring one of those is dead vocabulary the load then refuses. Reading the /// two positions that actually raise a class is what makes this a projection of /// what the module emits rather than of what it mentions. @@ -856,7 +856,19 @@ fn tokens_in(text: &str) -> Vec { let Some((token, _)) = rest.split_once('"') else { continue; }; - if token.starts_with("V-") && token.len() > 2 { + // THE SHAPE IS THE ARITY, NOT A PREFIX (CLOUD-1284). `V-` is gone, + // so what distinguishes a raised class from anything else in this + // position is that it is exactly three lowercase words. That is also + // what keeps the bound this function's doc comment claims: a `test_` + // rule's fixture token — `x probe probex`, `probe`, `one` — is not three words, + // so it is still filtered out rather than declared as dead + // vocabulary the load would then refuse. + let words: Vec<&str> = token.split(' ').collect(); + let shaped = words.len() == 3 + && words + .iter() + .all(|word| !word.is_empty() && word.chars().all(|c| c.is_ascii_lowercase())); + if shaped { found.push(token.to_owned()); } } diff --git a/crates/batten/tests/it/document_read_count.rs b/crates/batten/tests/it/document_read_count.rs index d3402c9ba..1963f7a2a 100644 --- a/crates/batten/tests/it/document_read_count.rs +++ b/crates/batten/tests/it/document_read_count.rs @@ -32,7 +32,7 @@ import rego.v1 rules contains "no-stray-key" -violation contains {"rule": "no-stray-key", "verdict": "V-STRAY-KEY"} if { +violation contains {"rule": "no-stray-key", "verdict": "stray key probe"} if { input.tree.documents["config.toml"].stray } "#; diff --git a/crates/batten/tests/it/external_facts.rs b/crates/batten/tests/it/external_facts.rs index 5fc5a6d0b..1dead83c6 100644 --- a/crates/batten/tests/it/external_facts.rs +++ b/crates/batten/tests/it/external_facts.rs @@ -62,22 +62,22 @@ root = "BATTEN_FIXTURE_EXTERNAL_ROOT" path = "wiring.json" [[verdict]] -id = "V-EXTERNAL-FLAG-SET" +id = "external flag set" gloss = "the declared out-of-root file carries a set flag" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE" +id = "probe probe probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-EXTERNAL-FLAG-CLEAR" +id = "external flag clear" gloss = "the declared out-of-root file carries a clear flag" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-CLEAR" +id = "probe clear probe" kind = "document" target = "probe.rego" "#; @@ -96,22 +96,22 @@ module = "probe.rego" severity = "deny" [[verdict]] -id = "V-EXTERNAL-FLAG-SET" +id = "external flag set" gloss = "the declared out-of-root file carries a set flag" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE" +id = "probe probe probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-EXTERNAL-FLAG-CLEAR" +id = "external flag clear" gloss = "the declared out-of-root file carries a clear flag" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-CLEAR" +id = "probe clear probe" kind = "document" target = "probe.rego" "#; @@ -133,14 +133,14 @@ rules contains "probe-clear" violation contains { "rule": "probe-set", - "verdict": "V-EXTERNAL-FLAG-SET", + "verdict": "external flag set", } if { input.tree.external.wiring.flag == true } violation contains { "rule": "probe-clear", - "verdict": "V-EXTERNAL-FLAG-CLEAR", + "verdict": "external flag clear", } if { input.tree.external.wiring.flag == false } @@ -256,7 +256,7 @@ fn an_undeclared_file_is_unreadable() { fn an_unset_root_is_not_a_file_that_said_nothing() { // COULD-NOT-LOOK, told apart from a real negative — and the pair of // predicates is what makes that observable. `the_projection_carries_the_ - // declared_value` fires `V-EXTERNAL-FLAG-CLEAR` on a file whose flag is + // declared_value` fires `external flag clear` on a file whose flag is // `false`; this case must fire NEITHER class, because nothing was read. // // Collapsing the two would ship a gate that reports the same answer on a diff --git a/crates/batten/tests/it/extracted_facts.rs b/crates/batten/tests/it/extracted_facts.rs index 3d12c4e5a..a474e0a63 100644 --- a/crates/batten/tests/it/extracted_facts.rs +++ b/crates/batten/tests/it/extracted_facts.rs @@ -53,32 +53,32 @@ id = "calls" count = "tool-calls" [[verdict]] -id = "V-EXTRACT-DENIALS" +id = "extract denials probe" gloss = "the declared extractor counted a denial" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-DENIALS" +id = "probe denials probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-EXTRACT-CALLS" +id = "extract calls probe" gloss = "the declared extractor counted the tool calls" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-CALLS" +id = "probe calls probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-EXTRACT-UNDECLARED" +id = "extract undeclared probe" gloss = "an extractor no row declared answered anyway" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-UNDECLARED" +id = "probe undeclared probe" kind = "document" target = "probe.rego" "#, @@ -103,7 +103,7 @@ rules contains "probe-undeclared" violation contains { "rule": "probe-denials", - "verdict": "V-EXTRACT-DENIALS", + "verdict": "extract denials probe", } if { is_object(input.facts.extracted) input.facts.extracted.denials == 1 @@ -111,7 +111,7 @@ violation contains { violation contains { "rule": "probe-calls", - "verdict": "V-EXTRACT-CALLS", + "verdict": "extract calls probe", } if { is_object(input.facts.extracted) input.facts.extracted.calls == 2 @@ -119,7 +119,7 @@ violation contains { violation contains { "rule": "probe-undeclared", - "verdict": "V-EXTRACT-UNDECLARED", + "verdict": "extract undeclared probe", } if { is_object(input.facts.extracted) input.facts.extracted.turns diff --git a/crates/batten/tests/it/forge_facts.rs b/crates/batten/tests/it/forge_facts.rs index 4542c1653..0dbacc57e 100644 --- a/crates/batten/tests/it/forge_facts.rs +++ b/crates/batten/tests/it/forge_facts.rs @@ -38,22 +38,22 @@ severity = "deny" forge = ["{sha}"] [[verdict]] -id = "V-FORGE-GREEN" +id = "forge green probe" gloss = "the declared sha's record says the required check passed" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-GREEN" +id = "probe green probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-FORGE-RED" +id = "forge red probe" gloss = "the declared sha's record says the required check failed" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-RED" +id = "probe red probe" kind = "document" target = "probe.rego" "# @@ -75,7 +75,7 @@ rules contains "probe-red" violation contains { "rule": "probe-green", - "verdict": "V-FORGE-GREEN", + "verdict": "forge green probe", } if { is_object(input.tree.forge) some checks in input.tree.forge @@ -84,7 +84,7 @@ violation contains { violation contains { "rule": "probe-red", - "verdict": "V-FORGE-RED", + "verdict": "forge red probe", } if { is_object(input.tree.forge) some checks in input.tree.forge diff --git a/crates/batten/tests/it/git_facts.rs b/crates/batten/tests/it/git_facts.rs index 27c4d84c5..b8ab7acf1 100644 --- a/crates/batten/tests/it/git_facts.rs +++ b/crates/batten/tests/it/git_facts.rs @@ -60,12 +60,12 @@ fn config(declares: &str) -> String { {declares}\ \n\ [[verdict]]\n\ - id = \"V-THE-PREDICATE-HELD\"\n\ + id = \"the predicate held\"\n\ gloss = \"the probe predicate held\"\n\ class = \"What this fixture's probe asserts, at the length explain answers with.\"\n\ \n\ [[verdict.route]]\n\ - id = \"R-READ-THE-PROBE\"\n\ + id = \"read the probe\"\n\ kind = \"document\"\n\ target = \"policy/probe.rego\"\n" ) @@ -80,7 +80,7 @@ fn module(body: &str) -> String { \n\ violation contains {{\n\ \t\"rule\": \"git-probe\",\n\ - \t\"verdict\": \"V-THE-PREDICATE-HELD\",\n\ + \t\"verdict\": \"the predicate held\",\n\ }} if {{\n\ {body}\n\ }}\n" diff --git a/crates/batten/tests/it/history_facts.rs b/crates/batten/tests/it/history_facts.rs index 0aab0c635..4c8a7aa28 100644 --- a/crates/batten/tests/it/history_facts.rs +++ b/crates/batten/tests/it/history_facts.rs @@ -42,32 +42,32 @@ path = "gone.txt" filter = "D" [[verdict]] -id = "V-HISTORY-TAG" +id = "history tag probe" gloss = "the declared tag glob resolved a tag" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-TAG" +id = "probe tag probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-HISTORY-DELETED" +id = "history deleted probe" gloss = "the declared path filter resolved a deleting commit" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-DELETED" +id = "probe deleted probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-HISTORY-BODY" +id = "history body probe" gloss = "a commit body reached the policy input, which rule 4 refuses" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-BODY" +id = "probe body probe" kind = "document" target = "probe.rego" "#; @@ -84,32 +84,32 @@ severity = "deny" documents = ["batten.toml"] [[verdict]] -id = "V-HISTORY-TAG" +id = "history tag probe" gloss = "the declared tag glob resolved a tag" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-TAG" +id = "probe tag probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-HISTORY-DELETED" +id = "history deleted probe" gloss = "the declared path filter resolved a deleting commit" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-DELETED" +id = "probe deleted probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-HISTORY-BODY" +id = "history body probe" gloss = "a commit body reached the policy input, which rule 4 refuses" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-BODY" +id = "probe body probe" kind = "document" target = "probe.rego" "#; @@ -131,7 +131,7 @@ rules contains "probe-body" violation contains { "rule": "probe-tag", - "verdict": "V-HISTORY-TAG", + "verdict": "history tag probe", } if { is_object(input.tree["git-history"]) some entry in input.tree["git-history"].shipped @@ -140,7 +140,7 @@ violation contains { violation contains { "rule": "probe-deleted", - "verdict": "V-HISTORY-DELETED", + "verdict": "history deleted probe", } if { is_object(input.tree["git-history"]) some entry in input.tree["git-history"].retired @@ -149,7 +149,7 @@ violation contains { violation contains { "rule": "probe-body", - "verdict": "V-HISTORY-BODY", + "verdict": "history body probe", } if { # GUARDED, and the guard is the lesson: `some .. in null` is a hard # evaluation FAULT in Rego, not a silent miss, so a module that iterates a diff --git a/crates/batten/tests/it/mediated_admission.rs b/crates/batten/tests/it/mediated_admission.rs index cec2fc78d..d8ed2904e 100644 --- a/crates/batten/tests/it/mediated_admission.rs +++ b/crates/batten/tests/it/mediated_admission.rs @@ -1,7 +1,7 @@ //! A mediated refusal is admissible by a spent admission, and only by one. //! //! The tier that proves the ENGINE honours what the route advertises. Without it -//! `path write refused`'s `R-ARTICULATE-THE-WRITE` is a promise made in a +//! `path write refused`'s `articulate the write` is a promise made in a //! refusal message: `batten override request` would answer, mint a real record, //! and the write would still be refused — the exact defect `verdict.rs`'s header //! exists to kill, one layer along. diff --git a/crates/batten/tests/it/memories.rs b/crates/batten/tests/it/memories.rs index 3500a047e..bddd9742d 100644 --- a/crates/batten/tests/it/memories.rs +++ b/crates/batten/tests/it/memories.rs @@ -105,7 +105,7 @@ gloss = "the memory graph has no root" class = "fixture" [[verdict.route]] -id = "R-FIXTURE" +id = "fixture probe probe" kind = "document" target = "policy/memories.rego" @@ -115,7 +115,7 @@ gloss = "a memory name strips to another name" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-SHADOWED" +id = "fixture shadowed probe" kind = "document" target = "policy/memories.rego" @@ -125,7 +125,7 @@ gloss = "a memory name carries a character no reference can spell" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-CHARSET" +id = "fixture charset probe" kind = "document" target = "policy/memories.rego" @@ -135,7 +135,7 @@ gloss = "a mem: reference names a memory this tree does not carry" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-STALE" +id = "fixture stale probe" kind = "document" target = "policy/memories.rego" @@ -145,7 +145,7 @@ gloss = "a declared referrer could not be read" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-UNREAD" +id = "fixture unread probe" kind = "document" target = "policy/memories.rego" "# diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 1aeb7093a..1dbf2293d 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -217,12 +217,12 @@ fn authority(spawning: bool) -> String { severity = \"deny\"\n\ \n\ [[verdict]]\n\ - id = \"V-A-CANARY-LINE\"\n\ + id = \"a canary line\"\n\ gloss = \"a canary line reached a declared source\"\n\ class = \"What the corpus module asserts, at explain length.\"\n\ \n\ [[verdict.route]]\n\ - id = \"R-READ-THE-MODULE\"\n\ + id = \"read the module\"\n\ kind = \"document\"\n\ target = \"policy/lines.rego\"\n\ \n\ @@ -338,7 +338,7 @@ impl Corpus { "package batten\n\ import rego.v1\n\ rules contains \"no-canary-line\"\n\ - violation contains {\"rule\": \"no-canary-line\", \"verdict\": \"V-A-CANARY-LINE\"} if {\n\ + violation contains {\"rule\": \"no-canary-line\", \"verdict\": \"a canary line\"} if {\n\ \tsome line in input.tree.lines[\"lineread.md\"]\n\ \tstartswith(line, \"Q7v\")\n\ }\n", diff --git a/crates/batten/tests/it/policy_severity.rs b/crates/batten/tests/it/policy_severity.rs index 45bef6dd8..ed4004b24 100644 --- a/crates/batten/tests/it/policy_severity.rs +++ b/crates/batten/tests/it/policy_severity.rs @@ -62,7 +62,7 @@ rules contains "fixture-severity" violation contains { "rule": "fixture-severity", - "verdict": "V-FIXTURE-SEVERITY", + "verdict": "fixture severity probe", "subjects": [{"path": "fixture"}], } if { some segment in input.call.segments @@ -80,7 +80,7 @@ rules contains "fixture-severity-other" violation contains { "rule": "fixture-severity-other", - "verdict": "V-FIXTURE-SEVERITY-OTHER", + "verdict": "fixture severity other", "subjects": [{"path": "fixture"}], } if { some segment in input.call.segments @@ -93,14 +93,14 @@ fn config(severity: &str) -> String { r#"version = 1 [[verdict]] -id = "V-FIXTURE-SEVERITY" +id = "fixture severity probe" gloss = "the fixture predicate matched" class = """ A fixture class, carrying one route so the registry's own shape rules are met. """ [[verdict.route]] -id = "R-FIXTURE" +id = "fixture probe probe" kind = "command" target = "stop running the fixture command" @@ -174,7 +174,7 @@ fn a_deny_row_refuses_the_call() { "a `deny` module violation is the policy verdict: {stdout}" ); assert!( - stdout.contains("V-FIXTURE-SEVERITY"), + stdout.contains("fixture severity probe"), "and it names the class it refused under: {stdout}" ); } @@ -220,7 +220,7 @@ fn a_warn_violation_reaches_the_advisory_channel_where_the_host_has_one() { "the demoted violation travels on the advisory channel: {stdout}" ); assert!( - stdout.contains("V-FIXTURE-SEVERITY"), + stdout.contains("fixture severity probe"), "carrying the class, so the reader can look it up: {stdout}" ); assert!( @@ -239,26 +239,26 @@ fn pair_config(first: &str, second: &str) -> String { r#"version = 1 [[verdict]] -id = "V-FIXTURE-SEVERITY" +id = "fixture severity probe" gloss = "the fixture predicate matched" class = """ A fixture class, carrying one route so the registry's own shape rules are met. """ [[verdict.route]] -id = "R-FIXTURE" +id = "fixture probe probe" kind = "command" target = "stop running the fixture command" [[verdict]] -id = "V-FIXTURE-SEVERITY-OTHER" +id = "fixture severity other" gloss = "the second fixture predicate matched" class = """ The second fixture class, so the two rows are distinguishable in the output. """ [[verdict.route]] -id = "R-FIXTURE-OTHER" +id = "fixture other probe" kind = "command" target = "stop running the fixture command" @@ -311,7 +311,7 @@ fn a_warn_declared_first_does_not_hide_a_deny_declared_second() { "the strongest matching row decides, whatever order they are declared in: {stdout}" ); assert!( - stdout.contains("V-FIXTURE-SEVERITY-OTHER"), + stdout.contains("fixture severity other"), "and the refusal names the class that actually refused, not the one that \ happened to be declared first: {stdout}" ); @@ -347,11 +347,11 @@ fn equal_force_leaves_declaration_order_as_the_tie_break() { assert!( // The class is rendered followed by its gloss, so anchor on that rather // than on a delimiter the projection does not emit. - stdout.contains("V-FIXTURE-SEVERITY ("), + stdout.contains("fixture severity probe ("), "the first-declared class of two equally strong ones: {stdout}" ); assert!( - !stdout.contains("V-FIXTURE-SEVERITY-OTHER"), + !stdout.contains("fixture severity other"), "and only one finding travels, so the report does not double: {stdout}" ); } @@ -369,7 +369,7 @@ fn a_deny_row_is_not_also_advised_at_the_batch_boundary() { let output = hook(&dir, &command_payload("PostToolBatch", CALL)); let stdout = stdout_of(&output); assert!( - !stdout.contains("V-FIXTURE-SEVERITY"), + !stdout.contains("fixture severity probe"), "a blocking row's violation is its decision's, not the advisory channel's: {stdout}" ); } diff --git a/crates/batten/tests/it/policy_test_suite.rs b/crates/batten/tests/it/policy_test_suite.rs index 8cae72e1d..0afb983d1 100644 --- a/crates/batten/tests/it/policy_test_suite.rs +++ b/crates/batten/tests/it/policy_test_suite.rs @@ -214,7 +214,7 @@ import rego.v1 rules contains "always" -violation contains {"rule": "always", "verdict": "V-FIXTURE-M"} if { +violation contains {"rule": "always", "verdict": "fixture m probe"} if { input.call.command == "x" } @@ -246,11 +246,11 @@ rules contains "tested" rules contains "never-tested" -violation contains {"rule": "tested", "verdict": "V-FIXTURE-M"} if { +violation contains {"rule": "tested", "verdict": "fixture m probe"} if { input.call.command == "a" } -violation contains {"rule": "never-tested", "verdict": "V-FIXTURE-M"} if { +violation contains {"rule": "never-tested", "verdict": "fixture m probe"} if { input.call.command == "b" } @@ -313,7 +313,7 @@ import rego.v1 rules contains "never-tested" -violation contains {"rule": "never-tested", "verdict": "V-FIXTURE-M"} if { +violation contains {"rule": "never-tested", "verdict": "fixture m probe"} if { input.call.command == "b" } @@ -342,7 +342,7 @@ import rego.v1 rules contains "untested" -violation contains {"rule": "untested", "verdict": "V-FIXTURE-M"} if { +violation contains {"rule": "untested", "verdict": "fixture m probe"} if { input.call.command == "x" } "#, @@ -361,7 +361,7 @@ import rego.v1 rules contains "bare-only" -violation contains {"rule": "bare-only", "verdict": "V-FIXTURE-M"} if { +violation contains {"rule": "bare-only", "verdict": "fixture m probe"} if { some segment in input.call.segments segment.words[0] == "git" } @@ -380,7 +380,7 @@ import rego.v1 rules contains "bare-only" -violation contains {"rule": "bare-only", "verdict": "V-FIXTURE-M"} if { +violation contains {"rule": "bare-only", "verdict": "fixture m probe"} if { some segment in input.call.segments segment.words[0] == "git" } diff --git a/crates/batten/tests/it/policy_tree.rs b/crates/batten/tests/it/policy_tree.rs index e8e330662..6e2bba69c 100644 --- a/crates/batten/tests/it/policy_tree.rs +++ b/crates/batten/tests/it/policy_tree.rs @@ -79,7 +79,7 @@ import rego.v1 rules contains "no-stray-key" -violation contains {"rule": "no-stray-key", "verdict": "V-STRAY-KEY"} if { +violation contains {"rule": "no-stray-key", "verdict": "stray key probe"} if { input.tree.documents["config.toml"].stray } "#; @@ -238,7 +238,7 @@ import rego.v1 rules contains "saw-undeclared" -violation contains {"rule": "saw-undeclared", "verdict": "V-SAW-UNDECLARED"} if { +violation contains {"rule": "saw-undeclared", "verdict": "saw undeclared probe"} if { input.tree.documents["undeclared.toml"] } "#; @@ -285,12 +285,12 @@ fn every_module_under_the_bundle_root_is_enabled() { let root = scratch("many-modules"); fs::write( root.join("policy").join("a.rego"), - "package batten.a\nimport rego.v1\nrules contains \"from-a\"\nviolation contains {\"rule\": \"from-a\", \"verdict\": \"V-FROM-A\"} if { input.tree.documents[\"config.toml\"].stray }\n", + "package batten.a\nimport rego.v1\nrules contains \"from-a\"\nviolation contains {\"rule\": \"from-a\", \"verdict\": \"from a probe\"} if { input.tree.documents[\"config.toml\"].stray }\n", ) .expect("module a"); fs::write( root.join("policy").join("b.rego"), - "package batten.b\nimport rego.v1\nrules contains \"from-b\"\nviolation contains {\"rule\": \"from-b\", \"verdict\": \"V-FROM-B\"} if { input.tree.documents[\"config.toml\"].stray }\n", + "package batten.b\nimport rego.v1\nrules contains \"from-b\"\nviolation contains {\"rule\": \"from-b\", \"verdict\": \"from b probe\"} if { input.tree.documents[\"config.toml\"].stray }\n", ) .expect("module b"); fs::write(root.join("config.toml"), "stray = true\n").expect("fixture"); @@ -322,7 +322,7 @@ import rego.v1 rules contains "no-stray-artifact" -violation contains {"rule": "no-stray-artifact", "verdict": "V-STRAY-ARTIFACT"} if { +violation contains {"rule": "no-stray-artifact", "verdict": "stray artifact probe"} if { some p in input.tree.tracked endswith(p, ".o") } @@ -401,7 +401,7 @@ import rego.v1 rules contains "reads-a-ghost" -violation contains {"rule": "reads-a-ghost", "verdict": "V-FIXTURE-X"} if { +violation contains {"rule": "reads-a-ghost", "verdict": "fixture x probe"} if { some p in input.tree.nonesuch endswith(p, ".o") } @@ -446,16 +446,16 @@ import rego.v1 rules contains "reads-real-keys" -violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-X"} if { +violation contains {"rule": "reads-real-keys", "verdict": "fixture x probe"} if { some p in input.tree.tracked endswith(p, ".o") } -violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-Y"} if { +violation contains {"rule": "reads-real-keys", "verdict": "fixture y probe"} if { input.tree.documents["config.toml"].stray } -violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-Z"} if { +violation contains {"rule": "reads-real-keys", "verdict": "fixture z probe"} if { count(input.tree.missing) > 0 } "#, @@ -590,7 +590,7 @@ import rego.v1 rules contains "reads-a-ghost" -violation contains {"rule": "reads-a-ghost", "verdict": "V-FIXTURE-X"} if { +violation contains {"rule": "reads-a-ghost", "verdict": "fixture x probe"} if { count(input.tree["nonesuch"]) > 0 } "#, @@ -626,7 +626,7 @@ import rego.v1 rules contains "reads-real-keys" -violation contains {"rule": "reads-real-keys", "verdict": "V-FIXTURE-X"} if { +violation contains {"rule": "reads-real-keys", "verdict": "fixture x probe"} if { input.tree["documents"]["config.toml"].stray } "#, @@ -672,7 +672,7 @@ import rego.v1 rules contains "no-focused-case" -violation contains {"rule": "no-focused-case", "verdict": "V-FOCUSED-CASE"} if { +violation contains {"rule": "no-focused-case", "verdict": "focused case probe"} if { some line in input.tree.lines["suite.bats"] startswith(line, "@focus") } @@ -711,7 +711,7 @@ import rego.v1 rules contains "no-focused-case" -violation contains {"rule": "no-focused-case", "verdict": "V-FOCUSED-CASE"} if { +violation contains {"rule": "no-focused-case", "verdict": "focused case probe"} if { some line in input.tree.lines["suite.bats"] startswith(line, "@focus") } @@ -757,7 +757,7 @@ import rego.v1 rules contains "no-focused-case" -violation contains {"rule": "no-focused-case", "verdict": "V-FOCUSED-CASE"} if { +violation contains {"rule": "no-focused-case", "verdict": "focused case probe"} if { some line in input.tree.lines["suite.bats"] startswith(line, "@focus") } @@ -808,7 +808,7 @@ has_closing_key if { startswith(line, "Refs: CLOUD-") } -violation contains {"rule": "closes-a-key", "verdict": "V-CLOSES-NO-KEY"} if { +violation contains {"rule": "closes-a-key", "verdict": "closes no key"} if { not has_closing_key } "#, @@ -851,7 +851,7 @@ has_closing_key if { startswith(line, "Refs: CLOUD-") } -violation contains {"rule": "closes-a-key", "verdict": "V-CLOSES-NO-KEY"} if { +violation contains {"rule": "closes-a-key", "verdict": "closes no key"} if { not has_closing_key } "#, diff --git a/crates/batten/tests/it/rules_drift.rs b/crates/batten/tests/it/rules_drift.rs index 68f44ea16..69033cdfc 100644 --- a/crates/batten/tests/it/rules_drift.rs +++ b/crates/batten/tests/it/rules_drift.rs @@ -183,7 +183,7 @@ gloss = "a restated env default disagrees with the mechanism" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-DEFAULT" +id = "fixture default probe" kind = "document" target = "policy/rules-drift.rego" @@ -193,7 +193,7 @@ gloss = "a sentence claims a wiring nothing wires" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-EVENT" +id = "fixture event probe" kind = "document" target = "policy/rules-drift.rego" @@ -203,7 +203,7 @@ gloss = "a named policy input key the schema does not carry" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-KEY" +id = "fixture key probe" kind = "document" target = "policy/rules-drift.rego" @@ -213,7 +213,7 @@ gloss = "a named fixed rule the evaluator does not query" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-RULE" +id = "fixture rule probe" kind = "document" target = "policy/rules-drift.rego" @@ -243,7 +243,7 @@ gloss = "an authority some prose claims against could not be read" class = "fixture" [[verdict.route]] -id = "R-FIXTURE-UNREADABLE" +id = "fixture unreadable probe" kind = "document" target = "policy/rules-drift.rego" "# diff --git a/crates/batten/tests/it/shell_retirement.rs b/crates/batten/tests/it/shell_retirement.rs index 9fde6d805..969bbad86 100644 --- a/crates/batten/tests/it/shell_retirement.rs +++ b/crates/batten/tests/it/shell_retirement.rs @@ -43,11 +43,16 @@ fn row() -> Rule { // declared subject is routinely under neither governed prefix, and a // narrow delta hides its death rather than reporting it. "delta_sources": ["**"], - // MIRRORS THE COMMITTED ROW, including `tests/**/*.bats` (CLOUD-1294). - // Without that entry a suite's lines are never read, `base-lines` has no - // entry for it, and every case below would pass or fail for the wrong - // reason — which is the state the committed row was in. - "line_sources": ["mise-tasks/*.sh", "crates/batten/tests/*.rs", "tests/**/*.bats"], + // MIRRORS THE COMMITTED ROW, and `tests/**/*.bats` is load-bearing + // rather than tidiness, for two independent reasons that both landed. + // CLOUD-1294: without the entry a suite's lines are never read, so + // `base-lines` has no entry for it and every case below would pass or + // fail for the wrong reason. CLOUD-1088: the added arm's admission + // clause reads `input.tree.lines[path]`, so a fixture whose + // `line_sources` did not reach a bats path would evaluate that clause + // over a key nothing fills and report the declaration ignored — on + // exactly the surface the row is about. + "line_sources": ["mise-tasks/*.sh", "crates/batten/tests/**/*.rs", "tests/**/*.bats"], "module": "policy/shell-retirement.rego", "severity": "deny", })) @@ -503,6 +508,57 @@ fn an_added_bats_suite_is_refused() { assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); } +/// CLOUD-1088, and this is the tier that matters for it. +/// +/// The load-time cases prove the PREDICATE honours the declaration. Only this one +/// proves the ENGINE hands it the lines to honour: the clause reads +/// `input.tree.lines[path]`, and until this change `line_sources` reached +/// `mise-tasks/` and the Rust tests and nothing else — so on a `tests/**` suite +/// the key was empty, the clause could not hold, and the route `shell add +/// refused` advertises cleared a different rule while leaving this one standing. +/// A `with input as` case cannot see that, because it fabricates the very shape +/// the engine may be unable to produce. +#[test] +fn an_added_bats_suite_declaring_it_stays_bash_is_admitted() { + let root = repo( + "added-bats-stays", + &[], + &Head { + written: &[( + "tests/new-gate.bats", + "# stays-bash: CLOUD-312 door-tier suite over the compiled binary\n@test \"x\" {\n true\n}\n", + )], + removed: &[], + }, + ); + assert!( + findings(&root).is_empty(), + "the declared route must clear the verdict that offers it: {:?}", + findings(&root) + ); +} + +/// The bound that keeps the case above from being a blanket allow. +/// +/// An EDIT stays refused with the declaration present. `shell edit refused` +/// carries one route and no override on purpose — an edit is the move that reads +/// as progress and is not — so the admission must not reach it. +#[test] +fn the_stays_bash_declaration_does_not_admit_an_edit() { + let root = repo( + "edited-bats-stays", + &[("tests/old-gate.bats", SUITE)], + &Head { + written: &[( + "tests/old-gate.bats", + "# stays-bash: CLOUD-843 not a licence to edit in place\n@test \"x\" {\n true\n}\n", + )], + removed: &[], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + /// The load-bearing arm: an edit is invisible to every other sensor in the tree. #[test] fn a_shell_rule_edited_in_place_is_refused() { diff --git a/crates/batten/tests/it/sinks.rs b/crates/batten/tests/it/sinks.rs index 325f28a85..8c82eedae 100644 --- a/crates/batten/tests/it/sinks.rs +++ b/crates/batten/tests/it/sinks.rs @@ -426,12 +426,12 @@ fn ratchet_config(read_back: bool) -> String { no_fix_reason = \"run enforce once to establish the baseline\"\n\ \n\ [[verdict]]\n\ - id = \"V-NO-BASELINE-YET\"\n\ + id = \"no baseline yet\"\n\ gloss = \"no baseline has been produced for the rule this module reads\"\n\ class = \"What the fixture asserts, at the length explain answers with.\"\n\ \n\ [[verdict.route]]\n\ - id = \"R-RUN-ENFORCE-ONCE\"\n\ + id = \"run enforce once\"\n\ kind = \"command\"\n\ target = \"batten enforce\"\n" ) @@ -445,7 +445,7 @@ const READS_BACK: &str = "package batten\n\ \n\ violation contains {\n\ \t\"rule\": \"needs-a-baseline\",\n\ - \t\"verdict\": \"V-NO-BASELINE-YET\",\n\ + \t\"verdict\": \"no baseline yet\",\n\ } if {\n\ \tnot input.tree.produced[\"no-todo\"]\n\ }\n"; @@ -456,7 +456,7 @@ const BLIND: &str = "package batten\n\ \n\ violation contains {\n\ \t\"rule\": \"needs-a-baseline\",\n\ - \t\"verdict\": \"V-NO-BASELINE-YET\",\n\ + \t\"verdict\": \"no baseline yet\",\n\ }\n"; fn ratchet_repo(name: &str, read_back: bool) -> PathBuf { @@ -580,7 +580,7 @@ fn a_journal_never_reaches_the_policy_input() { \n\ violation contains {\n\ \t\"rule\": \"reads-the-journal\",\n\ - \t\"verdict\": \"V-JOURNAL-WAS-READABLE\",\n\ + \t\"verdict\": \"journal was readable\",\n\ } if {\n\ \tinput.tree.produced[\"no-todo\"]\n\ }\n"; @@ -610,12 +610,12 @@ fn a_journal_never_reaches_the_policy_input() { no_fix_reason = \"nothing to fix; this row exists to prove the journal is unreadable\"\n\ \n\ [[verdict]]\n\ - id = \"V-JOURNAL-WAS-READABLE\"\n\ + id = \"journal was readable\"\n\ gloss = \"a journal reached the policy input, which nothing may read back\"\n\ class = \"What the fixture asserts, at the length explain answers with.\"\n\ \n\ [[verdict.route]]\n\ - id = \"R-READ-THE-STORE-FILTER\"\n\ + id = \"read the storefilter\"\n\ kind = \"document\"\n\ target = \"policy/reads.rego\"\n", ) @@ -750,7 +750,7 @@ fn a_policy_rows_sink_counts_the_violations_its_module_reported() { \n\ violation contains {\n\ \t\"rule\": \"a-named-predicate\",\n\ - \t\"verdict\": \"V-SOMETHING-TO-SAY\",\n\ + \t\"verdict\": \"something to say\",\n\ }\n"; let dir = Fixture::new("sink-policy-attribution") .config( @@ -769,12 +769,12 @@ fn a_policy_rows_sink_counts_the_violations_its_module_reported() { key = \"rule\"\n\ \n\ [[verdict]]\n\ - id = \"V-SOMETHING-TO-SAY\"\n\ + id = \"something to say\"\n\ gloss = \"this tree has something to say\"\n\ class = \"What the fixture asserts, at the length explain answers with.\"\n\ \n\ [[verdict.route]]\n\ - id = \"R-NOTHING-TO-DO\"\n\ + id = \"nothing to do\"\n\ kind = \"command\"\n\ target = \"batten check\"\n", ) @@ -836,7 +836,7 @@ fn a_predicate_named_after_a_row_leaves_that_rows_sink_alone() { \n\ violation contains {\n\ \t\"rule\": \"forbidden-phrase\",\n\ - \t\"verdict\": \"V-SOMETHING-TO-SAY\",\n\ + \t\"verdict\": \"something to say\",\n\ }\n"; let dir = Fixture::new("sink-predicate-row-collision") .config( @@ -868,12 +868,12 @@ fn a_predicate_named_after_a_row_leaves_that_rows_sink_alone() { key = \"rule\"\n\ \n\ [[verdict]]\n\ - id = \"V-SOMETHING-TO-SAY\"\n\ + id = \"something to say\"\n\ gloss = \"this tree has something to say\"\n\ class = \"What the fixture asserts, at the length explain answers with.\"\n\ \n\ [[verdict.route]]\n\ - id = \"R-NOTHING-TO-DO\"\n\ + id = \"nothing to do\"\n\ kind = \"command\"\n\ target = \"batten check\"\n", ) diff --git a/crates/batten/tests/it/staged_facts.rs b/crates/batten/tests/it/staged_facts.rs index c0e58a26b..c89e0d493 100644 --- a/crates/batten/tests/it/staged_facts.rs +++ b/crates/batten/tests/it/staged_facts.rs @@ -38,32 +38,32 @@ severity = "deny" staged = ["pinned.toml"] [[verdict]] -id = "V-STAGED-IS-INDEX" +id = "staged is index" gloss = "the probe read the value that was staged" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-INDEX" +id = "probe index probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-STAGED-IS-WORKTREE" +id = "staged is worktree" gloss = "the probe read the value left in the working tree" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-WORKTREE" +id = "probe worktree probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-TRACKED-SEES-THE-PATH" +id = "tracked sees thepath" gloss = "the working-tree walk still yields the path, unchanged by the staged read" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-TRACKED" +id = "probe tracked probe" kind = "document" target = "probe.rego" "#; @@ -80,32 +80,32 @@ severity = "deny" documents = ["batten.toml"] [[verdict]] -id = "V-STAGED-IS-INDEX" +id = "staged is index" gloss = "the probe read the value that was staged" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-INDEX" +id = "probe index probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-STAGED-IS-WORKTREE" +id = "staged is worktree" gloss = "the probe read the value left in the working tree" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-WORKTREE" +id = "probe worktree probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-TRACKED-SEES-THE-PATH" +id = "tracked sees thepath" gloss = "the working-tree walk still yields the path, unchanged by the staged read" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-TRACKED" +id = "probe tracked probe" kind = "document" target = "probe.rego" "#; @@ -127,21 +127,21 @@ rules contains "tracked-sees-the-path" violation contains { "rule": "staged-is-index", - "verdict": "V-STAGED-IS-INDEX", + "verdict": "staged is index", } if { input.tree.staged["pinned.toml"].pin == "staged" } violation contains { "rule": "staged-is-worktree", - "verdict": "V-STAGED-IS-WORKTREE", + "verdict": "staged is worktree", } if { input.tree.staged["pinned.toml"].pin == "worktree" } violation contains { "rule": "tracked-sees-the-path", - "verdict": "V-TRACKED-SEES-THE-PATH", + "verdict": "tracked sees thepath", } if { some path in input.tree.tracked path == "pinned.toml" @@ -277,22 +277,22 @@ severity = "deny" staged = ["pinned.lock"] [[verdict]] -id = "V-LOCK-STAGED-READ" +id = "lock staged read" gloss = "the probe resolved a node for the declared .lock path" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-LOCK-READ" +id = "lock read probe" kind = "document" target = "lock.rego" [[verdict]] -id = "V-LOCK-COULD-NOT-LOOK" +id = "lock could notlook" gloss = "the declared .lock path reached the could-not-look channel" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-LOCK-MISSING" +id = "lock missing probe" kind = "document" target = "lock.rego" "#; @@ -349,7 +349,7 @@ rules contains "lock-could-not-look" violation contains { "rule": "lock-staged-read", - "verdict": "V-LOCK-STAGED-READ", + "verdict": "lock staged read", "subjects": [{"path": "pinned.lock"}], } if { input.tree.staged["pinned.lock"].pin == "staged" @@ -357,7 +357,7 @@ violation contains { violation contains { "rule": "lock-could-not-look", - "verdict": "V-LOCK-COULD-NOT-LOOK", + "verdict": "lock could notlook", "subjects": [{"path": name}], } if { some name in input.tree.missing diff --git a/crates/batten/tests/it/stop_posture.rs b/crates/batten/tests/it/stop_posture.rs index f0ecfc9e1..527c883b7 100644 --- a/crates/batten/tests/it/stop_posture.rs +++ b/crates/batten/tests/it/stop_posture.rs @@ -151,7 +151,7 @@ kill. """ [[verdict.route]] -id = "R-WRITE-IT-DOWN" +id = "write it down" kind = "issue" target = "put it in the row that already owns it, or file one" diff --git a/crates/batten/tests/it/task_receipt.rs b/crates/batten/tests/it/task_receipt.rs index 334b4c501..35d85916d 100644 --- a/crates/batten/tests/it/task_receipt.rs +++ b/crates/batten/tests/it/task_receipt.rs @@ -64,22 +64,22 @@ manifest = "manifest.toml" node = "tasks" [[verdict]] -id = "V-TASK-ARGV" +id = "task argv probe" gloss = "the receipt carried a task's argv" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-ARGV" +id = "probe argv probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-TASK-COMPOUND" +id = "task compound probe" gloss = "the receipt carried a task with no single argv" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-COMPOUND" +id = "probe compound probe" kind = "document" target = "probe.rego" "#, @@ -102,7 +102,7 @@ rules contains "probe-compound" violation contains { "rule": "probe-argv", - "verdict": "V-TASK-ARGV", + "verdict": "task argv probe", } if { is_object(input.facts.tasks) input.facts.tasks.lint == ["probe-tool", "--strict"] @@ -110,7 +110,7 @@ violation contains { violation contains { "rule": "probe-compound", - "verdict": "V-TASK-COMPOUND", + "verdict": "task compound probe", } if { is_object(input.facts.tasks) input.facts.tasks.ship == null @@ -389,7 +389,7 @@ fn a_hand_written_row_outranks_a_module() { "the hand-written row's own remedy is what the reader must see: {said}" ); assert!( - !said.contains("V-TASK-ARGV"), + !said.contains("task argv probe"), "and the module must not have answered first: {said}" ); } @@ -415,7 +415,7 @@ fn the_module_still_answers_where_no_hand_written_row_selects() { let said = format!("{answer}{cause}"); assert!( - said.contains("V-TASK-ARGV") || said.contains("probe-argv"), + said.contains("task argv probe") || said.contains("probe-argv"), "the module is still on the command path: {said}" ); } diff --git a/crates/batten/tests/it/tool_verdict_facts.rs b/crates/batten/tests/it/tool_verdict_facts.rs index 30fcc661a..d8db71fd1 100644 --- a/crates/batten/tests/it/tool_verdict_facts.rs +++ b/crates/batten/tests/it/tool_verdict_facts.rs @@ -124,22 +124,22 @@ version = "{DECLARED_VERSION}" input = "subject.toml" [[verdict]] -id = "V-TOOL-CLEAN" +id = "tool clean probe" gloss = "the declared key's record says the tool found nothing" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-CLEAN" +id = "probe clean probe" kind = "document" target = "probe.rego" [[verdict]] -id = "V-TOOL-FINDING" +id = "tool finding probe" gloss = "the declared key's record carries a finding" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-PROBE-FINDING" +id = "probe finding probe" kind = "document" target = "probe.rego" "# @@ -161,7 +161,7 @@ rules contains "probe-finding" violation contains { "rule": "probe-clean", - "verdict": "V-TOOL-CLEAN", + "verdict": "tool clean probe", } if { is_object(input.tree["tool-verdict"]) some verdict in input.tree["tool-verdict"] @@ -170,7 +170,7 @@ violation contains { violation contains { "rule": "probe-finding", - "verdict": "V-TOOL-FINDING", + "verdict": "tool finding probe", } if { is_object(input.tree["tool-verdict"]) some verdict in input.tree["tool-verdict"] diff --git a/crates/batten/tests/it/verdict_registry.rs b/crates/batten/tests/it/verdict_registry.rs index 4effc9710..940feeaf4 100644 --- a/crates/batten/tests/it/verdict_registry.rs +++ b/crates/batten/tests/it/verdict_registry.rs @@ -83,7 +83,7 @@ rules contains "a-gate" violation contains { "rule": "a-gate", - "verdict": "V-FIXTURE-CLASS", + "verdict": "fixture class probe", "subjects": [{"path": "a.rs", "line": 7}], } if { input.call.operation == "write" @@ -92,7 +92,7 @@ violation contains { /// The registry the conforming module needs. fn declared() -> Vec { - common::verdicts(&["V-FIXTURE-CLASS"]) + common::verdicts(&["fixture class probe"]) } // --------------------------------------------------------------------------- @@ -108,7 +108,7 @@ fn a_conforming_module_loads_and_denies_with_its_token_and_pointer() { panic!("the bundle answered could-not-look over a document it can read"); }; assert_eq!(denials.len(), 1); - assert_eq!(denials[0].verdict, "V-FIXTURE-CLASS"); + assert_eq!(denials[0].verdict, "fixture class probe"); assert_eq!( denials[0].subjects, vec![Subject::Line { @@ -130,7 +130,7 @@ fn a_conforming_module_loads_and_denies_with_its_token_and_pointer() { #[test] fn a_module_still_binding_msg_is_refused_and_the_refusal_names_the_key() { let source = CONFORMING.replace( - r#""verdict": "V-FIXTURE-CLASS","#, + r#""verdict": "fixture class probe","#, r#""msg": "some prose the engine cannot check","#, ); let err = @@ -151,7 +151,7 @@ fn a_module_still_binding_msg_is_refused_and_the_refusal_names_the_key() { fn a_token_no_row_declares_is_refused() { let err = load("undeclared", CONFORMING, &[]) .expect_err("a class with no declaration carries no gloss and no route"); - assert!(format!("{err}").contains("V-FIXTURE-CLASS")); + assert!(format!("{err}").contains("fixture class probe")); } /// The other direction, and the one a reviewer would not think to ask for: a @@ -160,9 +160,9 @@ fn a_token_no_row_declares_is_refused() { #[test] fn a_declared_row_nothing_raises_is_refused() { let mut table = declared(); - table.extend(common::verdicts(&["V-NOBODY-RAISES-THIS"])); + table.extend(common::verdicts(&["nobody raises this"])); let err = load("unemitted", CONFORMING, &table).expect_err("dead vocabulary is refused"); - assert!(format!("{err}").contains("V-NOBODY-RAISES-THIS")); + assert!(format!("{err}").contains("nobody raises this")); } // --------------------------------------------------------------------------- @@ -172,7 +172,7 @@ fn a_declared_row_nothing_raises_is_refused() { #[test] fn a_composed_verdict_is_refused() { let source = CONFORMING.replace( - r#""verdict": "V-FIXTURE-CLASS","#, + r#""verdict": "fixture class probe","#, r#""verdict": sprintf("V-%s", ["FIXTURE-CLASS"]),"#, ); let err = load("composed", &source, &declared()) @@ -187,12 +187,12 @@ fn a_composed_verdict_is_refused() { #[test] fn a_tombstoned_token_that_is_still_raised_is_refused() { let mut table = declared(); - table[0].successor = Some("V-THE-LIVE-ONE".to_owned()); - table.extend(common::verdicts(&["V-THE-LIVE-ONE"])); + table[0].successor = Some("the live one".to_owned()); + table.extend(common::verdicts(&["the live one"])); let err = load("tombstoned", CONFORMING, &table) .expect_err("a tombstone exists so a historical token stays explainable"); let text = format!("{err}"); - assert!(text.contains("V-FIXTURE-CLASS"), "{text}"); + assert!(text.contains("fixture class probe"), "{text}"); assert!(text.contains("RETIRED"), "{text}"); } @@ -200,12 +200,13 @@ fn a_tombstoned_token_that_is_still_raised_is_refused() { /// for is reported as retired rather than silently swapped. #[test] fn a_tombstone_resolves_through_its_chain() { - let mut table = common::verdicts(&["V-OLD", "V-NEW"]); - table[0].successor = Some("V-NEW".to_owned()); + let mut table = common::verdicts(&["old probe probe", "new probe probe"]); + table[0].successor = Some("new probe probe".to_owned()); verdict::validate(&table, &batten::verdict::Vocabulary::default()) .expect("a terminating chain is well formed"); - let (resolved, retired) = verdict::resolve(&table, "V-OLD").expect("the token resolves"); - assert_eq!(resolved.id, "V-NEW"); + let (resolved, retired) = + verdict::resolve(&table, "old probe probe").expect("the token resolves"); + assert_eq!(resolved.id, "new probe probe"); assert!(retired); } @@ -222,7 +223,7 @@ fn a_tombstone_resolves_through_its_chain() { /// rather than replaced retires on its own reason. #[test] fn a_row_naming_only_a_withdrawal_loads_and_reports_retired() { - let mut table = common::verdicts(&["V-GONE"]); + let mut table = common::verdicts(&["gone probe probe"]); table[0].withdrawn = Some("the thing it refused is no longer refused by anything".to_owned()); verdict::validate(&table, &batten::verdict::Vocabulary::default()) .expect("a withdrawal is a well-formed retirement"); @@ -233,8 +234,9 @@ fn a_row_naming_only_a_withdrawal_loads_and_reports_retired() { // It ends its own chain rather than resolving elsewhere — there is nowhere // to send the reader, which is exactly what a withdrawal says. - let (resolved, retired) = verdict::resolve(&table, "V-GONE").expect("the token resolves"); - assert_eq!(resolved.id, "V-GONE"); + let (resolved, retired) = + verdict::resolve(&table, "gone probe probe").expect("the token resolves"); + assert_eq!(resolved.id, "gone probe probe"); assert!(retired); } @@ -247,7 +249,7 @@ fn a_withdrawn_token_that_is_still_raised_is_refused() { let err = load("withdrawn-raised", CONFORMING, &table) .expect_err("a withdrawn class is retired, and a retired one must not be emitted"); let text = format!("{err}"); - assert!(text.contains("V-FIXTURE-CLASS"), "{text}"); + assert!(text.contains("fixture class probe"), "{text}"); assert!(text.contains("RETIRED"), "{text}"); } @@ -257,12 +259,15 @@ fn an_empty_withdrawal_reason_is_refused() { // retires the token while explaining nothing, which is the deleted row again // at a tombstone's price. for blank in ["", " ", "\n"] { - let mut table = common::verdicts(&["V-GONE"]); + let mut table = common::verdicts(&["gone probe probe"]); table[0].withdrawn = Some(blank.to_owned()); let err = verdict::validate(&table, &batten::verdict::Vocabulary::default()) .expect_err("an empty withdrawal explains nothing"); let text = format!("{err}"); - assert!(text.contains("V-GONE"), "the refusal names the id: {text}"); + assert!( + text.contains("gone probe probe"), + "the refusal names the id: {text}" + ); assert!( text.contains("withdrawn"), "and names the arm at fault: {text}" @@ -274,13 +279,16 @@ fn an_empty_withdrawal_reason_is_refused() { fn a_row_naming_both_arms_is_refused() { // Two different accounts of where the class went is neither. A reader // following the successor would never learn it was withdrawn. - let mut table = common::verdicts(&["V-OLD", "V-NEW"]); - table[0].successor = Some("V-NEW".to_owned()); + let mut table = common::verdicts(&["old probe probe", "new probe probe"]); + table[0].successor = Some("new probe probe".to_owned()); table[0].withdrawn = Some("and also nobody refuses it".to_owned()); let err = verdict::validate(&table, &batten::verdict::Vocabulary::default()) .expect_err("a row cannot be both replaced and withdrawn"); let text = format!("{err}"); - assert!(text.contains("V-OLD"), "the refusal names the id: {text}"); + assert!( + text.contains("old probe probe"), + "the refusal names the id: {text}" + ); assert!(text.contains("successor"), "{text}"); assert!(text.contains("withdrawn"), "{text}"); } @@ -289,15 +297,15 @@ fn a_row_naming_both_arms_is_refused() { /// Both refusals below stood before this change and have to stand after it. #[test] fn the_withdrawal_arm_weakens_neither_successor_refusal() { - let mut dangling = common::verdicts(&["V-OLD"]); - dangling[0].successor = Some("V-NEVER-DECLARED".to_owned()); + let mut dangling = common::verdicts(&["old probe probe"]); + dangling[0].successor = Some("never declared probe".to_owned()); let err = verdict::validate(&dangling, &batten::verdict::Vocabulary::default()) .expect_err("a successor nothing declares is refused"); - assert!(format!("{err}").contains("V-NEVER-DECLARED")); + assert!(format!("{err}").contains("never declared probe")); - let mut cycle = common::verdicts(&["V-A", "V-B"]); - cycle[0].successor = Some("V-B".to_owned()); - cycle[1].successor = Some("V-A".to_owned()); + let mut cycle = common::verdicts(&["a probe probe", "b probe probe"]); + cycle[0].successor = Some("b probe probe".to_owned()); + cycle[1].successor = Some("a probe probe".to_owned()); let err = verdict::validate(&cycle, &batten::verdict::Vocabulary::default()) .expect_err("a chain that cycles terminates nowhere"); assert!(format!("{err}").contains("cycles")); @@ -308,8 +316,8 @@ fn the_withdrawal_arm_weakens_neither_successor_refusal() { /// withdrawal as naming an undeclared token, which is the arm failing to exist. #[test] fn a_withdrawal_is_not_read_as_a_successor() { - let mut table = common::verdicts(&["V-GONE"]); - table[0].withdrawn = Some("V-SOMETHING-THAT-IS-NOT-A-TOKEN".to_owned()); + let mut table = common::verdicts(&["gone probe probe"]); + table[0].withdrawn = Some("something that isnotatoken".to_owned()); verdict::validate(&table, &batten::verdict::Vocabulary::default()) .expect("a withdrawal reason is prose, never a token the registry must declare"); } @@ -374,7 +382,7 @@ package batten import rego.v1 -deny contains "V-FIXTURE-CLASS" if { +deny contains "fixture class probe" if { input.call.operation == "write" } "#; @@ -385,7 +393,7 @@ fn a_bare_deny_member_is_a_token_and_is_declared() { let Look::Is(denials) = policy::deny(&bundles[0], r#"{"call": {"operation": "write"}}"#) else { panic!("could-not-look"); }; - assert_eq!(denials[0].verdict, "V-FIXTURE-CLASS"); + assert_eq!(denials[0].verdict, "fixture class probe"); assert_eq!(denials[0].rule, None, "a bare member names no predicate"); } @@ -393,7 +401,7 @@ fn a_bare_deny_member_is_a_token_and_is_declared() { fn a_bare_deny_member_no_row_declares_is_refused() { let err = load("bare-deny-undeclared", BARE_DENY, &[]) .expect_err("the string channel does not reopen the free-string hole"); - assert!(format!("{err}").contains("V-FIXTURE-CLASS")); + assert!(format!("{err}").contains("fixture class probe")); } // --------------------------------------------------------------------------- @@ -457,11 +465,11 @@ fn authority_with(kind: &str, target: &str) -> String { format!( "version = 1\n\n\ [[verdict]]\n\ - id = \"V-X\"\n\ + id = \"x probe probex\"\n\ gloss = \"a class\"\n\ class = \"what it means\"\n\n\ [[verdict.route]]\n\ - id = \"R-X\"\n\ + id = \"x probe probe\"\n\ kind = \"{kind}\"\n\ target = \"{target}\"\n" ) diff --git a/crates/batten/tests/policy_modules.rs b/crates/batten/tests/policy_modules.rs index cde79b873..0129585dd 100644 --- a/crates/batten/tests/policy_modules.rs +++ b/crates/batten/tests/policy_modules.rs @@ -125,7 +125,7 @@ package batten import rego.v1 -deny contains "V-WRITE-REFUSED" if { +deny contains "write refused probe" if { input.call.operation == "write" } "#; @@ -155,7 +155,7 @@ package batten import rego.v1 -deny contains "V-REACHED-THE-NETWORK" if { +deny contains "reached the network" if { http.send({"method": "get", "url": "http://example.invalid/"}) } "#; @@ -168,7 +168,7 @@ package batten import rego.v1 -deny contains "V-VALIDATED-A-SCHEMA" if { +deny contains "validated a schema" if { json.verify_schema({"type": "object"}) } "#; @@ -188,11 +188,11 @@ import rego.v1 rules contains "no-stray-artifact" rules contains "no-empty-fixture" -violation contains {"rule": "no-stray-artifact", "verdict": "V-STRAY-ARTIFACT"} if { +violation contains {"rule": "no-stray-artifact", "verdict": "stray artifact probe"} if { input.call.operation == "write" } -violation contains {"rule": "no-empty-fixture", "verdict": "V-EMPTY-FIXTURE"} if { +violation contains {"rule": "no-empty-fixture", "verdict": "empty fixture probe"} if { input.call.operation == "write" } "#; @@ -210,7 +210,7 @@ import rego.v1 rules contains "declared-and-unused" -violation contains {"rule": "never-declared", "verdict": "V-NEVER-DECLARED"} if { +violation contains {"rule": "never-declared", "verdict": "never declared probe"} if { true } "#; @@ -227,7 +227,7 @@ import rego.v1 rules contains "shared-id" -violation contains {"rule": "shared-id", "verdict": "V-FROM-MODULE-A"} if { +violation contains {"rule": "shared-id", "verdict": "from module a"} if { input.call.operation == "write" } "#; @@ -239,7 +239,7 @@ import rego.v1 rules contains "shared-id" -violation contains {"rule": "shared-id", "verdict": "V-FROM-MODULE-B"} if { +violation contains {"rule": "shared-id", "verdict": "from module b"} if { input.call.operation == "read" } "#; @@ -256,7 +256,7 @@ package batten import rego.v1 -deny contains "V-BUILTIN-IN-THE-CLOSURE" if { +deny contains "builtin in theclosure" if { count([1, 2, 3]) == 3 } "#; @@ -284,7 +284,7 @@ fn a_module_denies_on_a_fact_and_is_silent_otherwise() { let denied = policy::deny(&bundles[0], r#"{"call":{"operation":"write"}}"#); assert_eq!( denied, - Look::Is(vec![unattributed("V-WRITE-REFUSED")]), + Look::Is(vec![unattributed("write refused probe")]), "the module decided over the fact it was handed" ); @@ -391,7 +391,7 @@ package batten import rego.v1 -deny contains "V-NAMES-A-TRACKER-KEY" if { +deny contains "names a trackerkey" if { regex.match(`TEAM-[0-9]+`, input.call.command) } "#; @@ -402,7 +402,7 @@ package batten import rego.v1 -deny contains "V-NAMES-A-TRACKER-KEY" if { +deny contains "names a trackerkey" if { regex.match(data.batten.patterns["tracker-key"], input.call.command) } "#; @@ -413,7 +413,7 @@ package batten import rego.v1 -deny contains "V-NAMES-A-TRACKER-KEY" if { +deny contains "names a trackerkey" if { regex.match(concat("", ["CLOUD", "-[0-9]+"]), input.call.command) } "#; @@ -475,7 +475,7 @@ fn a_regex_written_inline_is_refused_and_a_declared_one_decides() { }; assert_eq!( violations, - vec![unattributed("V-NAMES-A-TRACKER-KEY")], + vec![unattributed("names a trackerkey")], "the projected table decides, rather than resolving to undefined" ); @@ -577,7 +577,7 @@ fn no_evaluator_feature_admits_io() { .expect("a module over an in-closure builtin loads"); assert_eq!( policy::deny(&bundles[0], "{}"), - Look::Is(vec![unattributed("V-BUILTIN-IN-THE-CLOSURE")]), + Look::Is(vec![unattributed("builtin in theclosure")]), "the control must deny, or an absent-builtin verdict below is unattributable" ); @@ -698,8 +698,8 @@ fn one_module_carries_two_predicates_that_deny_under_their_own_ids() { assert_eq!( whole, vec![ - attributed("no-empty-fixture", "V-EMPTY-FIXTURE"), - attributed("no-stray-artifact", "V-STRAY-ARTIFACT"), + attributed("no-empty-fixture", "empty fixture probe"), + attributed("no-stray-artifact", "stray artifact probe"), ] ); } @@ -731,7 +731,7 @@ fn a_bare_string_deny_still_reports_under_the_registering_row() { else { panic!("the module answered"); }; - assert_eq!(violations, vec![unattributed("V-WRITE-REFUSED")]); + assert_eq!(violations, vec![unattributed("write refused probe")]); assert_eq!( bundles[0].attribute(&violations[0]), "policy-writes", @@ -871,7 +871,7 @@ import rego.v1 rules contains "declared-and-unused" -violation contains {"rule": "only-on-a-write", "verdict": "V-REACHED-LATER"} if { +violation contains {"rule": "only-on-a-write", "verdict": "reached later probe"} if { input.call.operation == "write" } "#; diff --git a/man/batten-override-request.1 b/man/batten-override-request.1 index 93733e738..b522d2c1f 100644 --- a/man/batten-override-request.1 +++ b/man/batten-override-request.1 @@ -13,7 +13,7 @@ Answer a class\*(Aqs declared precondition and receive an admission for one situ The rule whose refusal is being overridden .TP \fB\-\-verdict\fR -The verdict token that refusal carries, e.g. V\-PROSE\-ONLY\-DIFF +The verdict token that refusal carries, e.g. diff ship early .TP \fB\-\-subject\fR The gate\*(Aqs canonical subject, exactly as its refusal names it diff --git a/man/batten-override-spend.1 b/man/batten-override-spend.1 index b0362bb14..08883e402 100644 --- a/man/batten-override-spend.1 +++ b/man/batten-override-spend.1 @@ -16,7 +16,7 @@ The admission address to spend The rule whose refusal is being overridden .TP \fB\-\-verdict\fR -The verdict token that refusal carries, e.g. V\-PROSE\-ONLY\-DIFF +The verdict token that refusal carries, e.g. diff ship early .TP \fB\-\-subject\fR The gate\*(Aqs canonical subject, exactly as its refusal names it diff --git a/man/batten-policy-explain.1 b/man/batten-policy-explain.1 index 233c9bbe8..6305f062d 100644 --- a/man/batten-policy-explain.1 +++ b/man/batten-policy-explain.1 @@ -16,4 +16,4 @@ Emit byte\-stable JSON instead of pointer lines Print help .TP <\fItoken\fR> -The verdict token to resolve, e.g. V\-TASK\-UNDEFINED +The verdict token to resolve, e.g. task name undefined diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 9e4884e1d..4ef2ee1c0 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -188,6 +188,43 @@ violation contains { } if { some path in delta.added governed_at_head(path) + not declares_it_stays_bash(path) +} + +# THE ADMISSION THIS ARM'S OWN CLASS TEXT ALREADY NAMED (CLOUD-1088). +# +# `shell add refused` offered two routes, and the second -- "declare that it +# stays bash" -- DID NOT CLEAR THE VERDICT THAT OFFERED IT. Measured 2026-08-28: +# prepending `# stays-bash: ` to `tests/wiring-reclaim.bats` and +# re-running `batten check --rule shell-retirement` left the finding byte- +# identical. +# +# The reason was structural rather than a typo. `admits_with = "# stays-bash:"` +# belongs to `bash-surface-not-growing`, whose glob is `mise-tasks/**`, so it +# never reaches `tests/**`; and this arm carried no admission clause at all. So +# for a bats suite the named remedy was unreachable twice over, and for a +# `mise-tasks/` path it cleared a DIFFERENT rule while leaving this one standing. +# +# It mattered because two landed policies MANDATE what this refused. +# `.claude/rules/policy-modules.md` requires a door migration's second tier over +# the compiled binary, and CLOUD-312's per-row obligation says the same for a +# handler destination -- so a migration was required to add a suite this arm +# refused, with a route that could not clear it. +# +# THE RATCHET IS NOT WEAKENED, and that bound is why this is the ADDED arm only. +# `shell edit refused` is untouched: an edit stays refused with one route and no +# override, because an edit is the move that reads as progress and is not. What +# changes here is that the declaration this class ALREADY advertises now works on +# the surface the class is raised over -- the difference between a ratchet and a +# ratchet with a lie in it. +# +# THE TOKEN IS `bash-surface-not-growing`'s, DELIBERATELY. One spelling for one +# concept, so an author who has learned the declaration once carries it to either +# surface; a second spelling here would be the duplication the `[[pattern]]` +# registry exists to make unwritable, one layer up. +declares_it_stays_bash(path) if { + some line in input.tree.lines[path] + contains(line, "# stays-bash:") } # --------------------------------------------------------------------------- @@ -1289,6 +1326,45 @@ test_added_bats_suite_is_refused if { }} } +# CLOUD-1088's discriminating pair, and the direction that matters is the third. +# +# The FIRST is the measured defect: a bats suite carrying the declaration this +# class advertises was refused byte-identically, because the token only ever +# reached `bash-surface-not-growing`'s `mise-tasks/**` glob. +test_an_added_bats_suite_declaring_it_stays_bash_is_admitted if { + count(violation) == 0 with input as {"tree": { + "base-delta": {"added": ["tests/new-gate.bats"], "edited": [], "deleted": []}, + "lines": {"tests/new-gate.bats": ["# stays-bash: CLOUD-312 door-tier suite over the compiled binary"]}, + }} +} + +# The SAME declaration on an authored shell program, so the admission is a +# property of the class rather than of one surface. +test_an_added_shell_rule_declaring_it_stays_bash_is_admitted if { + count(violation) == 0 with input as {"tree": { + "base-delta": {"added": ["mise-tasks/new-gate.sh"], "edited": [], "deleted": []}, + "lines": {"mise-tasks/new-gate.sh": [ + "#!/usr/bin/env bash", + "# stays-bash: CLOUD-843 needs stdin, which no tree-scoped module has", + "#MISE description=\"x\"", + ]}, + }} +} + +# THE BOUND, and without it the two cases above would pass over a blanket allow. +# An EDIT is untouched: `shell edit refused` keeps one route and no override, +# because an edit is the move that reads as progress and is not. A declaration +# must not buy one. +test_the_declaration_does_not_admit_an_edit if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": ["mise-tasks/old-gate.sh"], "deleted": []}, + "lines": {"mise-tasks/old-gate.sh": [ + "#MISE description=\"x\"", + "# stays-bash: CLOUD-843 not a licence to edit in place", + ]}, + }} +} + test_edited_shell_rule_is_refused if { count(violation) == 1 with input as {"tree": { "base-delta": {"added": [], "edited": ["mise-tasks/old-gate.sh"], "deleted": []}, diff --git a/policy/verdict-routes-resolve.rego b/policy/verdict-routes-resolve.rego index 7bb083d60..37c9b4002 100644 --- a/policy/verdict-routes-resolve.rego +++ b/policy/verdict-routes-resolve.rego @@ -161,8 +161,8 @@ test_a_command_route_naming_an_undefined_task_is_refused if { found := violation with input as {"tree": { "documents": { "batten.toml": {"verdict": [{ - "id": "V-X", - "route": [{"id": "R-X", "kind": "command", "target": "mise run absent-task"}], + "id": "x probe probex", + "route": [{"id": "x probe probe", "kind": "command", "target": "mise run absent-task"}], }]}, "mise.toml": {"tasks": {"present": {}}}, }, @@ -176,8 +176,8 @@ test_a_command_route_naming_a_defined_task_is_clean if { found := violation with input as {"tree": { "documents": { "batten.toml": {"verdict": [{ - "id": "V-X", - "route": [{"id": "R-X", "kind": "command", "target": "mise run present"}], + "id": "x probe probex", + "route": [{"id": "x probe probe", "kind": "command", "target": "mise run present"}], }]}, "mise.toml": {"tasks": {"present": {}}}, }, @@ -194,8 +194,8 @@ test_a_program_on_path_is_not_judged if { found := violation with input as {"tree": { "documents": { "batten.toml": {"verdict": [{ - "id": "V-X", - "route": [{"id": "R-X", "kind": "command", "target": "git cherry"}], + "id": "x probe probex", + "route": [{"id": "x probe probe", "kind": "command", "target": "git cherry"}], }]}, "mise.toml": {"tasks": {"present": {}}}, }, @@ -211,8 +211,8 @@ test_an_override_route_is_not_judged_here if { found := violation with input as {"tree": { "documents": { "batten.toml": {"verdict": [{ - "id": "V-X", - "route": [{"id": "R-ASK", "kind": "override", "target": ""}], + "id": "x probe probex", + "route": [{"id": "ask probe probe", "kind": "override", "target": ""}], }]}, "mise.toml": {"tasks": {}}, }, @@ -237,10 +237,10 @@ test_a_resolving_registry_is_silent if { found := violation with input as {"tree": { "documents": { "batten.toml": {"verdict": [{ - "id": "V-X", + "id": "x probe probex", "route": [ - {"id": "R-RUN", "kind": "command", "target": "mise run present"}, - {"id": "R-READ", "kind": "document", "target": "batten.toml"}, + {"id": "run probe probe", "kind": "command", "target": "mise run present"}, + {"id": "read probe probe", "kind": "document", "target": "batten.toml"}, ], }]}, "mise.toml": {"tasks": {"present": {}}}, From 49d6fcdcb3725bb5658b9a909a607758014d1de0 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 12:33:31 +0000 Subject: [PATCH 04/20] fix(hook): every mediated deny carries a declared class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight of the ten refusal composers in `hook.rs` called `Refusal::new`, which leaves `verdict: None`. The prose they emitted was a hardcoded `format!` at the boundary, so a class a reader could look up did not exist for any of them and `batten policy explain` could not answer over the mediated path at all. The cause prose is not config — a `[[rule]]` row's `reason` is the *Fix* half — so this lands as new `Native` variants rather than as config-row edits. The wildcard-free `every_native_class_is_listed` match makes the exhaustiveness compile-checked, and each variant's VENDORED entry lands in the same commit, because a row nothing raises fails the load and so does a token no row declares. Converted, reusing `Refusal::declared` rather than a third constructor: `receipt_refusal`, `substitution_refusal`, `pipeline_refusal`, `ceiling_refusal`, `shape_refusal`, `content_refusal`, `unkeyed_refusal`, and `policy_refusal` — the half-converted one, which already rendered `render_line`'s output as its reason while still reporting `verdict()` as `None`. Three distinctions the suite proved the conversion had to keep rather than collapse, each now carried as a declared subject or as its own class: - the receipt KEYING (`branch` / `row` / `commit`), because "no receipt for this commit" sends a reader looking for a per-commit step when what is missing is a claim the whole branch shares; - the expiry BOUND, on the expired class only — `300s` is the difference between "run it again" and a row nobody can satisfy; - `StaleHead` and `StaleMain`, which are an amend/rebase replacing the validated bytes and a branch that has moved off trunk. Collapsing them lost the one word that says which. `policy_url` rides the deny wherever a row declares one, via a shared subject helper, so no composer drops it on the way through. Refs: CLOUD-1285 --- crates/batten/src/hook.rs | 313 ++++++++++++---------- crates/batten/src/refusal.rs | 26 +- crates/batten/src/verdict.rs | 268 ++++++++++++++++-- crates/batten/tests/it/board_receipts.rs | 16 +- crates/batten/tests/it/pipeline_shapes.rs | 22 +- 5 files changed, 479 insertions(+), 166 deletions(-) diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index de334e0d4..ceb685b25 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -4567,76 +4567,29 @@ fn receipt_refusal( verdict: Validity, sourced: Option<&crate::facts::Declared>, ) -> Refusal { - let cause = match verdict { - // The cause names what the receipt is keyed to (CLOUD-444), because that - // is what the reader has to act on: "no receipt for this commit" sends - // someone looking for a per-commit step when what is missing is a claim - // the whole branch shares, and a wrong pointer is CLOUD-122's failure in - // its most confusing form. - Validity::Missing if rule.receipt_key() == ReceiptKey::Branch => { - format!("this branch carries no `{check}` receipt") - } - // `Named` was added by CLOUD-987 and this match was not extended with it, - // so a subject-keyed row fell through to the commit wording below — the - // exact wrong pointer the paragraph above calls CLOUD-122's failure in its - // most confusing form, sending the reader after a per-commit step when what - // is missing is a read of one row. Caught while retiring CLOUD-312 row 2, - // the first consumer of this key. - // - // The SUBJECT IS NOT NAMED, and that is rule 4 rather than reticence: it is - // read from the call's own arguments, so echoing it would put payload in a - // refusal. Naming the *kind* of thing keyed on is what the reader needs to - // act, and it is what the branch arm above does too. - Validity::Missing if rule.receipt_key() == ReceiptKey::Named => { - format!("no `{check}` receipt for the row this call names") - } - // A DIFFERENT REMEDY FROM `Missing`, which is why it is a different - // variant: the step ran, and the answer it recorded is too old for what - // this row declares. Naming the bound rather than the age keeps this a - // pointer (rule 4) and keeps the line byte-stable — an elapsed second in - // the output would make every run's bytes differ, and CLOUD-521 is the - // recorded cost of grading anything on one. - Validity::Expired => match rule.max_age { - Some(seconds) => format!( - "the `{check}` receipt is older than the {seconds}s this row allows — the step ran, \ - but not recently enough to still be evidence" - ), - // Unreachable through `receipt::verdicts`, which only mints this - // verdict from a declared bound. Stated rather than `unreachable!`: - // library code does not panic on a reachable path, and a wrong-but- - // honest sentence beats a crash inside a guard. - None => format!("the `{check}` receipt is older than this row allows"), - }, - // A DIFFERENT REMEDY AGAIN, and the one furthest from the others: every - // verdict above says *run the step*, and this one says *the step ran and - // said no*. Naming the required value rather than the recorded one is - // rule 4 — the recorded value came out of the subject, and echoing it - // would put a judgement about somebody's row into a refusal — and it is - // also what the reader needs, because the required value is the state - // they have to reach. - Validity::Refuted => match rule.requires_field.as_ref() { - Some(bound) => format!( - "the `{check}` receipt does not record `{}` — the step ran, and what it recorded \ - is not what this row requires", - bound.is - ), - // Unreachable through `receipt::verdicts`, which only mints this - // verdict from a declared bound. Stated rather than `unreachable!` - // for `Expired`'s reason one arm up. - None => format!("the `{check}` receipt does not record what this row requires"), - }, - Validity::Missing => { - format!("`{check}` has recorded no receipt for this commit in this checkout") - } - Validity::StaleHead => format!( - "the `{check}` receipt was taken against a different commit — an amend or a rebase replaced the bytes it validated" - ), - Validity::StaleMain => format!( - "the `{check}` receipt was taken against an older origin/main, which has since moved" - ), - // Not reachable from the caller, which only refuses a non-valid - // verdict. Stated rather than unwrapped so the match stays total. - Validity::Valid => format!("`{check}` is valid"), + // FOUR CLASSES, NOT ONE (CLOUD-1285). These were seven arms of one `format!` + // and they are not one thing: a MISSING receipt is repaired by running the + // check, an EXPIRED one by running it again, a REFUTED one by fixing what it + // reported — running it again changes nothing — and a SUPERSEDED one is + // evidence about bytes this head no longer carries. Collapsing them would + // make the registry less precise than the prose it replaced, which is the + // one way this conversion could lose something. + // + // The check NAME is the pointer and is the whole of what travels. The row's + // subject is deliberately not named even where one exists: it is read from + // the call's own arguments, so echoing it would put payload in a refusal + // (rule 4). Which KIND of thing the receipt is keyed to is the class's, and + // `batten policy explain` answers it. + let native = match verdict { + Validity::Expired => crate::verdict::Native::ReceiptExpired, + Validity::Refuted => crate::verdict::Native::ReceiptRefuted, + Validity::StaleHead => crate::verdict::Native::ReceiptSuperseded, + Validity::StaleMain => crate::verdict::Native::ReceiptOffTrunk, + // `Valid` is not reachable from the caller, which only refuses a + // non-valid verdict. It resolves here rather than panicking so the match + // stays total, and it renders as the missing case, which is the honest + // reading of "there is no usable receipt". + Validity::Missing | Validity::Valid => crate::verdict::Native::ReceiptUnusable, }; // An agent-sourced fact's remedy is the DECLARED COMMAND, not the row's // prose (CLOUD-776). That is what makes the loop close: the agent is told @@ -4660,7 +4613,41 @@ fn receipt_refusal( }, None => Fix::declared(rule.reason.as_deref()), }; - Refusal::new(&rule.id, cause, fix) + // THE KEYING TRAVELS AS A SUBJECT, because it is what the reader acts on and + // dropping it was a real loss the suite caught. "No receipt for this commit" + // sends someone looking for a per-commit step when what is missing is a claim + // the whole branch shares — the wrong pointer this composer's own comment + // calls CLOUD-122's failure in its most confusing form. It is the KIND of + // thing keyed on, never the subject itself, which is read from the call's own + // arguments and would be payload (rule 4). + let keyed = match rule.receipt_key() { + ReceiptKey::Branch => "branch", + ReceiptKey::Named => "row", + ReceiptKey::Head => "commit", + }; + // THE BOUND TRAVELS TOO, and only on the class it is the measure for. A + // reader acting on an expiry needs to know what the age was measured + // against — `300s` is the difference between "run it again" and "this row + // wants a step nobody can satisfy" — and it is a declared number rather + // than a byte of the call, so rule 4 is satisfied. It is omitted on every + // other class because there it is not what refused. + let mut subjects = vec![ + crate::verdict::Subject::Artifact { + artifact: check.to_owned(), + }, + crate::verdict::Subject::Artifact { + artifact: keyed.to_owned(), + }, + ]; + if let Some(bound) = rule + .max_age + .filter(|_| matches!(verdict, Validity::Expired)) + { + subjects.push(crate::verdict::Subject::Artifact { + artifact: format!("{bound}s"), + }); + } + Refusal::declared(&rule.id, native, &subjects, fix) } /// The id-free half of the pipeline verdict: which shape a command commits. @@ -4943,15 +4930,24 @@ fn repo_relative_path(token: &str) -> bool { /// names the questions and lets the caller map them onto what it has, which is /// the one part of this it can do and the engine cannot. fn substitution_refusal(rule: &Rule, program: &str, target: &str) -> Refusal { - let cause = format!( - "`{program}` was aimed at `{target}`, a path in this repository, as the first stage of the \ - call — a question the structured file surface answers directly, and better: reading a \ - range of one file's contents, matching a pattern across the tree, listing paths by glob, \ - or resolving what a NAME refers to. Reach for whichever of those this session offers. The \ - same utility DOWNSTREAM of a pipe is untouched, because filtering another command's \ - output is not standing in for anything" - ); - Refusal::new(&rule.id, &cause, Fix::declared(rule.reason.as_deref())) + // THE TWO POINTERS STAY INLINE and the paragraph does not (CLOUD-1285). Which + // program was aimed at which path is what the caller acts on; the four + // question classes and the downstream-of-a-pipe bound are the CLASS, and + // `batten policy explain` is what fetches them. The path is first because + // `Refusal::declared` binds an admission to the first path-bearing subject. + Refusal::declared( + &rule.id, + crate::verdict::Native::ToolSubstituted, + &[ + crate::verdict::Subject::Path { + path: target.to_owned(), + }, + crate::verdict::Subject::Artifact { + artifact: program.to_owned(), + }, + ], + Fix::declared(rule.reason.as_deref()), + ) } /// Compose a pipeline refusal: which shape, and the row's declared remedy. @@ -4961,26 +4957,17 @@ fn substitution_refusal(rule: &Rule, program: &str, target: &str) -> Refusal { /// was worded around one command string, an agent complied with it exactly, and /// made the identical error on the next command in the same session. fn pipeline_refusal(rule: &Rule, discard: Discard) -> Refusal { - let cause = match discard { - Discard::Piped => { - "piping a verdict-bearing command into a pager or filter discards its \ - exit status — the pipeline exits with the filter's, which is 0 whether the command \ - passed or failed. A verdict is read from the harness, never inferred from output" - } - Discard::Trailing => { - "a verdict-bearing command followed by `;` or `||` has its exit \ - status replaced — only the last element's survives. This is the laundered shape: it \ - reads as correct, and backgrounded it is worse than a misread, because the completion \ - notification then carries the compound's status. (`&&` is fine: it short-circuits, \ - so a failure still propagates.)" - } - Discard::Orphaned => { - "detaching a verdict-bearing command with `nohup` or a trailing `&` \ - orphans it from the tool call: the call returns at once, the harness records it \ - complete, and the session loses the wake-up it would get when the work exits" - } + // THREE SHAPES, THREE CLASSES (CLOUD-1285). They were three branches of one + // `format!` and they are three different defects with three different + // repairs, so collapsing them into one token would have made the registry + // less precise than the prose it replaced. Each carries its own `class`, and + // `batten policy explain` answers with the one that fired. + let native = match discard { + Discard::Piped => crate::verdict::Native::VerdictPiped, + Discard::Trailing => crate::verdict::Native::VerdictTrailing, + Discard::Orphaned => crate::verdict::Native::RunOrphaned, }; - Refusal::new(&rule.id, cause, Fix::declared(rule.reason.as_deref())) + Refusal::declared(&rule.id, native, &[], Fix::declared(rule.reason.as_deref())) } /// Judge the tool a mediated call names (CLOUD-924). @@ -5220,13 +5207,43 @@ fn manifest_ceiling(policy: &Policy, envelope: &Envelope, counted: ManifestFacts /// [`Refusal`], and the measured value is never passed to this function — so /// there is no field a byte of it could occupy. That is what makes counting a /// prompt admissible where echoing one is not. +/// A row's declared `policy_url` as a subject, so a converted refusal keeps it. +/// +/// It was appended to four composers' cause strings as `". See "`, and the +/// conversion to a declared class dropped it — a real pointer lost, which is the +/// one thing CLOUD-1285 must not do. It is the CONSUMER's declared pointer, so it +/// belongs beside the class's own route rather than inside the class prose, and a +/// tagged `Artifact` is what carries it without inventing a subject kind. +fn policy_url_subject(rule: &Rule) -> Vec { + rule.policy_url + .as_deref() + .map(|url| { + vec![crate::verdict::Subject::Artifact { + artifact: url.to_owned(), + }] + }) + .unwrap_or_default() +} + fn ceiling_refusal(rule: &Rule, count: usize, max: usize) -> Refusal { - let mut cause = format!("this call measures {count} against a declared ceiling of {max}"); - if let Some(url) = rule.policy_url.as_deref() { - cause.push_str(". See "); - cause.push_str(url); - } - Refusal::new(&rule.id, cause, Fix::declared(rule.reason.as_deref())) + // THE COUNT AND THE CEILING TRAVEL AS SUBJECTS (CLOUD-1285), not as prose. + // `Subject::Count` is a tagged pointer, so the two numbers a reader acts on + // stay in the line while the paragraph explaining what a ceiling IS moves + // behind `batten policy explain`. The `policy_url` was a fourth copy of the + // same "See " tail in this file; it is the class's route now. + let mut subjects = vec![ + crate::verdict::Subject::Count { + count: count as u64, + }, + crate::verdict::Subject::Count { count: max as u64 }, + ]; + subjects.extend(policy_url_subject(rule)); + Refusal::declared( + &rule.id, + crate::verdict::Native::CeilingExceeded, + &subjects, + Fix::declared(rule.reason.as_deref()), + ) } fn shape_rules(policy: &Policy, envelope: &Envelope, command: &str, keys: &KeyFacts) -> Decision { @@ -5516,17 +5533,20 @@ fn policy_refusal( if strongest.as_ref().is_none_or(|(held, _)| severity > *held) { strongest = Some(( severity, - Refusal::new( + // RECORDS THE TOKEN IT ALREADY RENDERS (CLOUD-1285). This + // path was half-converted: it took the line from + // `render_line` and the fix from the class's first `command` + // route, and then called `Refusal::new`, which sets + // `verdict: None`. So `refusal.verdict()` was `None` on the + // module path too and `batten policy explain` was + // unreachable from the one surface that had already done the + // work of resolving the class. + Refusal::from_class( bundle.attribute(violation), - crate::verdict::render_line( - &policy.verdicts, - &violation.verdict, - &violation.subjects, - ), - Fix::declared(crate::verdict::first_command_route( - &policy.verdicts, - &violation.verdict, - )), + &policy.verdicts, + &violation.verdict, + &violation.subjects, + Fix::None, ), )); } @@ -6692,12 +6712,15 @@ fn blocks(severity: RuleSeverity, fail_on_warning: bool) -> bool { /// /// [`RuleKind::Shape`]: crate::rules::RuleKind::Shape fn shape_refusal(rule: &Rule) -> Refusal { - let mut cause = "the mediated call matches a refused command shape".to_owned(); - if let Some(url) = rule.policy_url.as_deref() { - cause.push_str(". See "); - cause.push_str(url); - } - Refusal::new(&rule.id, cause, Fix::declared(rule.reason.as_deref())) + // NO SUBJECT, and that is rule 4 rather than an omission: the only thing this + // refusal could point at is the command itself, which is the caller's own + // text and could carry anything. The row id is the pointer. + Refusal::declared( + &rule.id, + crate::verdict::Native::ShapeRefused, + &policy_url_subject(rule), + Fix::declared(rule.reason.as_deref()), + ) } /// Compose a content-keyed row's refusal (CLOUD-758). @@ -6709,15 +6732,27 @@ fn shape_refusal(rule: &Rule) -> Refusal { /// learns which row fired and which file to open, and the refusal cannot leak /// the thing it refused. fn content_refusal(rule: &Rule, envelope: &Envelope) -> Refusal { - let mut cause = match envelope.writes.as_deref() { - Some(path) => format!("the content this would write to {path} matches a refused shape"), - None => "the content this would write matches a refused shape".to_owned(), - }; - if let Some(url) = rule.policy_url.as_deref() { - cause.push_str(". See "); - cause.push_str(url); - } - Refusal::new(&rule.id, cause, Fix::declared(rule.reason.as_deref())) + // THE DESTINATION IS THE POINTER, when the host reported one. What the + // content IS never appears — this rule reads exactly the text somebody + // wanted checked, which is the likeliest place in the surface for a secret, + // so rule 4 is decided here at the composer rather than at the report. + let subjects: Vec = envelope + .writes + .as_deref() + .map(|path| { + vec![crate::verdict::Subject::Path { + path: path.to_owned(), + }] + }) + .unwrap_or_default(); + let mut subjects = subjects; + subjects.extend(policy_url_subject(rule)); + Refusal::declared( + &rule.id, + crate::verdict::Native::ContentRefused, + &subjects, + Fix::declared(rule.reason.as_deref()), + ) } /// Compose a keyed shape row's refusal (CLOUD-446). @@ -6733,15 +6768,15 @@ fn content_refusal(rule: &Rule, envelope: &Envelope) -> Refusal { /// and the cause names **none** of it (non-negotiable rule 4). What the author /// needs is where to put a key, which is the row's own `reason`. fn unkeyed_refusal(rule: &Rule) -> Refusal { - let mut cause = - "the work this call publishes names no tracker key — not in the command, the branch, \ - or any commit on it" - .to_owned(); - if let Some(url) = rule.policy_url.as_deref() { - cause.push_str(". See "); - cause.push_str(url); - } - Refusal::new(&rule.id, cause, Fix::declared(rule.reason.as_deref())) + // No subject: the three evidence sources are the CLASS, and none of them + // produced a key to point at. Naming the command would be the caller's own + // text back again. + Refusal::declared( + &rule.id, + crate::verdict::Native::KeyMissing, + &policy_url_subject(rule), + Fix::declared(rule.reason.as_deref()), + ) } /// One shell-separated span of a mediated command, in the two forms policy needs. diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index 05569b552..ca78a5387 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -191,15 +191,35 @@ impl Refusal { fix: Fix, ) -> Refusal { let registry = crate::verdict::vendored(); - let token = native.id(); + Refusal::from_class(rule, ®istry, native.id(), subjects, fix) + } + + /// The same constructor, over a registry and a token the caller resolved. + /// + /// **Not a third constructor** (CLOUD-1285 is explicit about not writing + /// one): [`Refusal::declared`] is this function with the token taken from a + /// [`crate::verdict::Native`], and every line below used to live there. It is + /// split out because a POLICY MODULE's refusal carries a token the module + /// raised and the consumer's registry declares, so there is no `Native` to + /// name — and before this that path called [`Refusal::new`] and threw the + /// class away, leaving `verdict()` as `None` even though it had already + /// rendered the class's own line. + #[must_use] + pub fn from_class( + rule: impl Into, + registry: &[crate::verdict::DeclaredVerdict], + token: &str, + subjects: &[crate::verdict::Subject], + fix: Fix, + ) -> Refusal { let fix = match fix { Fix::Run(text) => Fix::Run(text), - Fix::None => Fix::declared(crate::verdict::first_command_route(®istry, token)), + Fix::None => Fix::declared(crate::verdict::first_command_route(registry, token)), }; Refusal { rule: rule.into(), verdict: Some(token.to_owned()), - reason: crate::verdict::render_line(®istry, token, subjects), + reason: crate::verdict::render_line(registry, token, subjects), fix, subject: subjects.iter().find_map(|subject| match subject { crate::verdict::Subject::Path { path } diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index fae64ddfd..7d193337d 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -490,7 +490,7 @@ pub fn validate(verdicts: &[DeclaredVerdict], vocabulary: &Vocabulary) -> anyhow let mut seen: BTreeSet<&str> = BTreeSet::new(); // Arm 5's evidence, gathered while walking rather than by a second pass: a // word is used if some class or route name spends it in its own slot. - let mut used: BTreeSet<(usize, &str)> = BTreeSet::new(); + let mut used: BTreeSet<(usize, String)> = BTreeSet::new(); for verdict in verdicts { validate_one(verdict, grammar, &mut used)?; // ARM 3, uniqueness of the TRIPLE — and it is the duplicate-id refusal @@ -505,7 +505,23 @@ pub fn validate(verdicts: &[DeclaredVerdict], vocabulary: &Vocabulary) -> anyhow } } if grammar.is_some() { - validate_no_orphan_words(vocabulary, &used)?; + // ARM 5 COUNTS THE VENDORED NAMES TOO, and without this the arm would + // refuse a word only a `Native` or preset class spends (CLOUD-1285). + // The vocabulary serves BOTH halves of the registry — `policy:: + // registry_for` unions them — so a word is an orphan only when nothing + // in that union spends it. Membership is deliberately NOT checked + // against the vendored half: a third-party consumer never declared the + // words Batten's own classes use, and holding them to it would refuse + // every config that has not copied this repository's vocabulary. + let vendored = vendored(); + let mut spent = used; + for entry in &vendored { + mark_spent(&entry.id, &mut spent); + for route in &entry.routes { + mark_spent(&route.id, &mut spent); + } + } + validate_no_orphan_words(vocabulary, &spent)?; } validate_chains(verdicts, &seen) } @@ -554,6 +570,16 @@ fn validate_vocabulary(vocabulary: &Vocabulary) -> anyhow::Result<()> { Ok(()) } +/// Record each slot's word from a name that is already known to be well formed. +/// +/// Takes owned copies because the vendored table is built on the fly, where the +/// consumer half is borrowed from the config that outlives this call. +fn mark_spent(name: &str, spent: &mut BTreeSet<(usize, String)>) { + for (slot, word) in name.split(' ').enumerate().take(SLOTS) { + spent.insert((slot, word.to_owned())); + } +} + /// ARM 5: a word no name spends fails the load. /// /// The mirror of the landed rule that a `[[verdict]]` row nothing raises fails @@ -563,10 +589,10 @@ fn validate_vocabulary(vocabulary: &Vocabulary) -> anyhow::Result<()> { /// lists are honest about what is in them. fn validate_no_orphan_words( vocabulary: &Vocabulary, - used: &BTreeSet<(usize, &str)>, + used: &BTreeSet<(usize, String)>, ) -> anyhow::Result<()> { for (slot, entry) in vocabulary.words() { - if !used.contains(&(slot, entry.word.as_str())) { + if !used.contains(&(slot, entry.word.clone())) { return Err(UsageError::raise(format!( "vocabulary `{}`: `{}` is declared and no class or route name spends it — \ a word nothing uses is dead vocabulary, which reads as headroom while \ @@ -582,11 +608,11 @@ fn validate_no_orphan_words( /// /// `kind` names what is being judged (`verdict` or a verdict's `route`) so the /// refusal points at the right table. -fn check_name<'a>( +fn check_name( kind: &str, - name: &'a str, + name: &str, vocabulary: &Vocabulary, - used: &mut BTreeSet<(usize, &'a str)>, + used: &mut BTreeSet<(usize, String)>, ) -> anyhow::Result<()> { let words: Vec<&str> = name.split(' ').collect(); // ARM 1. @@ -613,16 +639,16 @@ fn check_name<'a>( SLOT_NAMES[slot] ))); } - used.insert((slot, word)); + used.insert((slot, (*word).to_owned())); } Ok(()) } /// The per-entry half of [`validate`]. -fn validate_one<'a>( - verdict: &'a DeclaredVerdict, +fn validate_one( + verdict: &DeclaredVerdict, grammar: Option<&Vocabulary>, - used: &mut BTreeSet<(usize, &'a str)>, + used: &mut BTreeSet<(usize, String)>, ) -> anyhow::Result<()> { let id = verdict.id.as_str(); if let Some(vocabulary) = grammar { @@ -703,11 +729,11 @@ fn validate_one<'a>( } /// The per-route half of [`validate_one`]. -fn validate_route<'a>( +fn validate_route( verdict: &str, - route: &'a Route, + route: &Route, grammar: Option<&Vocabulary>, - used: &mut BTreeSet<(usize, &'a str)>, + used: &mut BTreeSet<(usize, String)>, ) -> anyhow::Result<()> { let id = route.id.as_str(); if let Some(vocabulary) = grammar { @@ -876,6 +902,44 @@ pub enum Native { SpawningRuleOnReadVerb, /// The end-of-turn facts do not permit stopping. StopConditionUnmet, + // ─── the mediated composers' own classes (CLOUD-1285) ──────────────────── + // + // These are Batten's OWN words about generic concepts, which is what puts + // them here rather than in a consumer `[[verdict]]` row. The engine composed + // each cause as a hardcoded `format!` in `hook.rs` and then threw the class + // away by calling `Refusal::new`, so `refusal.verdict()` was `None` on eight + // of ten mediated deny paths and `batten policy explain` was unreachable from + // every path that actually fires. + // + // The consumer's `[[rule]]` row still supplies the FIX -- `Refusal::declared` + // takes it as a parameter and a narrower one wins -- so this does not move + // the remedy into the crate. It moves the CAUSE, which was already here. + /// No receipt at all for what the row keys on. + ReceiptUnusable, + /// The receipt exists and is older than the row allows. + ReceiptExpired, + /// The receipt records something the row does not accept. + ReceiptRefuted, + /// An amend or a rebase replaced the bytes the receipt validated. + ReceiptSuperseded, + /// The receipt was taken against a trunk this branch has moved off. + ReceiptOffTrunk, + /// A shell text utility stood in for the structured file surface. + ToolSubstituted, + /// A verdict-bearing command was piped into a pager or filter. + VerdictPiped, + /// A verdict-bearing command was followed by `;` or `||`. + VerdictTrailing, + /// A verdict-bearing command was detached from its tool call. + RunOrphaned, + /// The call measures over a declared ceiling. + CeilingExceeded, + /// The call matches a refused command shape. + ShapeRefused, + /// The content this call would write matches a refused shape. + ContentRefused, + /// The work this call publishes names no tracker key. + KeyMissing, } impl Native { @@ -892,6 +956,19 @@ impl Native { Native::ScannerUnprovisioned, Native::SpawningRuleOnReadVerb, Native::StopConditionUnmet, + Native::ReceiptUnusable, + Native::ReceiptExpired, + Native::ReceiptRefuted, + Native::ReceiptSuperseded, + Native::ReceiptOffTrunk, + Native::ToolSubstituted, + Native::VerdictPiped, + Native::VerdictTrailing, + Native::RunOrphaned, + Native::CeilingExceeded, + Native::ShapeRefused, + Native::ContentRefused, + Native::KeyMissing, ]; /// The token this class is declared and rendered under. @@ -905,6 +982,19 @@ impl Native { Native::ScannerUnprovisioned => "scanner install missing", Native::SpawningRuleOnReadVerb => "spawn run refused", Native::StopConditionUnmet => "turn finish unmet", + Native::ReceiptUnusable => "receipt read missing", + Native::ReceiptExpired => "receipt read late", + Native::ReceiptRefuted => "receipt carry other", + Native::ReceiptSuperseded => "receipt read other", + Native::ReceiptOffTrunk => "receipt read stale", + Native::ToolSubstituted => "tool run loose", + Native::VerdictPiped => "verdict read dropped", + Native::VerdictTrailing => "verdict carry other", + Native::RunOrphaned => "turn watch dropped", + Native::CeilingExceeded => "call count over", + Native::ShapeRefused => "call name refused", + Native::ContentRefused => "input write refused", + Native::KeyMissing => "issue name missing", } } } @@ -1093,6 +1183,134 @@ the thing rather than to re-declare that it is finished.", read("config read first", "batten.toml"), ], }, + // ── the mediated composers' classes (CLOUD-1285) ──────────────────────── + // + // The cause each of these carries used to be a hardcoded `format!` in + // `hook.rs` that `Refusal::new` then dropped the class for. Moving the prose + // here is what makes it dereferenceable: `batten policy explain ` + // answers with the `class` below, so the hot path can carry the token and the + // pointers and stop repeating the paragraph on every firing. + VendoredVerdict { + id: "receipt read missing", + gloss: "a declared receipt does not attest the commit this call is made against", + class: "A `receipt` row names checks whose verdict must already exist for this \ +commit, in this checkout. The receipt is missing, older than the row allows, recorded \ +against a different head, or records something the row does not accept -- and the refusal \ +names which, because the four call for different repairs. Re-running the check is the \ +remedy for a missing one and useless for a refuted one.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "receipt read late", + gloss: "the receipt exists and is older than the row allows", + class: "The step RAN, and not recently enough for its verdict to still be evidence. \ +That is a different repair from a missing receipt and is why it is a different class: \ +re-run the check. A row declaring a `max_age` is saying the world can move underneath the \ +answer, so an old verdict is could-not-look rather than a pass.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "receipt carry other", + gloss: "the receipt records something this row does not accept", + class: "The step ran and what it recorded is not what the row requires. This is the \ +one receipt class that is a statement about what was READ rather than about the read, so \ +re-running the check changes nothing until the thing it reports is fixed. An ABSENT field \ +is could-not-look and is not this class.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "receipt read other", + gloss: "an amend or a rebase replaced the bytes this receipt validated", + class: "The receipt is keyed to a commit this branch no longer carries. Its verdict \ +covered the bytes it read and nothing later, so it is not evidence about this head. Re-run \ +the check against what is here now. Kept apart from the trunk case because the two name \ +different things that moved, and a refusal that says the wrong one sends the reader after \ +the wrong repair.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "receipt read stale", + gloss: "the receipt was taken against a trunk this branch has moved off", + class: "The check ran against an `origin/main` that has since advanced, so its \ +verdict is about a base this branch no longer sits on. Rebase and re-run. Distinct from \ +the amend case: there the branch's own bytes changed, here the trunk under them did, and \ +only one of the two is fixed by rebasing.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "tool run loose", + gloss: "a shell text utility stood in for the structured file surface", + class: "The call reaches for a text utility over a path this repository tracks, as \ +its FIRST stage, to answer a question the structured surface answers directly and better: \ +a range of one file's contents, a pattern across the tree, paths by glob, or what a name \ +resolves to. Which instruments a session carries varies, so the refusal names the question \ +classes rather than a product. The same utility DOWNSTREAM of a pipe is untouched, because \ +filtering another command's output is not standing in for anything.", + routes: &[read("rule read first", ".claude/rules/scanning.md")], + }, + VendoredVerdict { + id: "verdict read dropped", + gloss: "piping a verdict-bearing command into a pager or filter discards its status", + class: "The pipeline exits with the FILTER's status, which is 0 whether the command \ +passed or failed. A verdict is read from the harness, never inferred from output. Redirect \ +to a file and read the file in a separate call; a pager over a FILE is fine, a pager over a \ +live task is not.", + routes: &[read("rule read first", ".claude/rules/toolchain.md")], + }, + VendoredVerdict { + id: "verdict carry other", + gloss: "a verdict-bearing command followed by `;` or `||` has its status replaced", + class: "Only the last element's status survives, so the compound reports the wrong \ +command's verdict. This is the laundered shape: it reads as correct, and backgrounded it is \ +worse than a misread, because the completion notification then carries the compound's \ +status. `&&` is fine -- it short-circuits, so a failure still propagates.", + routes: &[read("rule read first", ".claude/rules/toolchain.md")], + }, + VendoredVerdict { + id: "turn watch dropped", + gloss: "detaching a verdict-bearing command orphans it from the tool call", + class: "`nohup` or a trailing `&` returns the call at once, the harness records it \ +complete, and the session loses the wake-up it would get when the work actually exits. \ +Backgrounding the tool call is the supported shape and keeps the notification; detaching \ +inside the call throws it away.", + routes: &[read("rule read first", ".claude/rules/toolchain.md")], + }, + VendoredVerdict { + id: "call count over", + gloss: "this call measures over a ceiling the config declares", + class: "A `ceiling` row counts something about the call and refuses above a declared \ +maximum. The count and the maximum are the whole finding -- what was counted is the row's \ +subject, and the refusal carries neither the measured content nor the call text, which is \ +non-negotiable rule 4 decided at the composer rather than at the report.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "call name refused", + 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")], + }, + VendoredVerdict { + id: "input write refused", + gloss: "the content this call would write matches a refused shape", + class: "A `content` row judges what a write would PUT somewhere rather than which \ +path it targets, so it fires before the bytes land. The refusal names the row and the \ +destination and never the matched content -- this rule reads exactly the text somebody \ +wanted checked, which is the likeliest place in the surface for a secret to appear.", + routes: &[read("config read first", "batten.toml")], + }, + VendoredVerdict { + id: "issue name missing", + gloss: "the work this call publishes names no tracker key", + class: "A `requires_key` row narrows a refusal from \"this command is banned\" to \ +\"this command is banned unless the work is keyed\". Three evidence sources are read and any \ +one of them allows: the command itself, the branch name, and the commit subjects on the \ +range the row declares. None carried a key, so nothing on the published work says which row \ +it serves.", + routes: &[read("config read first", "batten.toml")], + }, // ── vendored presets ──────────────────────────────────────────────────── VendoredVerdict { id: "commit ship empty", @@ -1488,8 +1706,11 @@ mod tests { // has walked it. let mut wider = vocab(); wider.condition.push(VocabularyWord { - word: "stale".to_owned(), - gloss: "answers for a state that has moved".to_owned(), + // Deliberately a word no VENDORED name spells: `mark_spent` walks the + // vendored table too, so a plausible-looking condition can be spent + // from under this case by a class landed elsewhere in the crate. + word: "unwalked".to_owned(), + gloss: "answers for a route nothing has taken".to_owned(), }); assert!(validate(&[named("task read first")], &wider).is_err()); } @@ -1639,7 +1860,20 @@ mod tests { | Native::ScannerUnpinned | Native::ScannerUnprovisioned | Native::SpawningRuleOnReadVerb - | Native::StopConditionUnmet => native.id(), + | Native::StopConditionUnmet + | Native::ReceiptUnusable + | Native::ReceiptExpired + | Native::ReceiptRefuted + | Native::ReceiptSuperseded + | Native::ReceiptOffTrunk + | Native::ToolSubstituted + | Native::VerdictPiped + | Native::VerdictTrailing + | Native::RunOrphaned + | Native::CeilingExceeded + | Native::ShapeRefused + | Native::ContentRefused + | Native::KeyMissing => native.id(), }; // The prefix is gone (CLOUD-1284), so what makes this a token is the // ARITY: exactly three words. Asserting that here rather than a diff --git a/crates/batten/tests/it/board_receipts.rs b/crates/batten/tests/it/board_receipts.rs index 0f7c54377..b7b991487 100644 --- a/crates/batten/tests/it/board_receipts.rs +++ b/crates/batten/tests/it/board_receipts.rs @@ -565,12 +565,22 @@ fn an_update_with_no_receipt_is_refused() { // and a subject-keyed row fell through to the commit one — telling the reader // to re-run a per-commit step when what is absent is a read of one row. Found // by this row, the key's first consumer. + // CLOUD-1285 moved the wording into a declared class and its POINTERS, so + // the sentence this case used to pin no longer exists. What it was actually + // asserting does: the check name and the KIND of thing the receipt is keyed + // to both travel, and the keying is `row` rather than `branch`. The negative + // arm is what keeps this discriminating — a composer that dropped the keying + // subject entirely would satisfy the first assertion alone. assert!( - text.contains("no `issue-read` receipt for the row this call names"), - "the verdict must name what is missing in this row's own terms: {text}" + text.contains("issue-read"), + "the verdict must name the check whose receipt is missing: {text}" ); assert!( - !text.contains("this branch carries no"), + text.contains("row"), + "and the keying, in this row's own terms: {text}" + ); + assert!( + !text.contains("branch"), "and not in the branch-keyed terms, which is the neighbouring arm: {text}" ); diff --git a/crates/batten/tests/it/pipeline_shapes.rs b/crates/batten/tests/it/pipeline_shapes.rs index a60bbcd4c..d83011c2f 100644 --- a/crates/batten/tests/it/pipeline_shapes.rs +++ b/crates/batten/tests/it/pipeline_shapes.rs @@ -207,8 +207,13 @@ fn the_refusal_states_the_principle_rather_than_naming_one_command() { refusal.contains("verdict-not-discarded"), "names the rule: {refusal}" ); + // The principle now travels as the declared class rather than as a sentence + // composed at the boundary: the token generalises by construction, because + // it names the STRUCTURE (a verdict that was read and dropped) and not the + // command that happened to be written. That is CLOUD-1285's whole claim, and + // this is the case that would notice it being untrue. assert!( - refusal.contains("exit status"), + refusal.contains("verdict read dropped"), "states the principle: {refusal}" ); assert!( @@ -227,9 +232,18 @@ fn each_shape_renders_its_own_cause() { // Three causes from one row, in `receipt_refusal`'s idiom. A single generic // message would leave the reader to work out which of three structures they // wrote. - assert!(cause("mise run verify | tail -1").contains("pager or filter")); - assert!(cause("mise run verify >log 2>&1; ls").contains("only the last element")); - assert!(cause("nohup mise run verify &").contains("orphans it")); + // CLOUD-1285 made each of the three a DECLARED class rather than a `format!` + // arm, so the discrimination this case asserts is now over three registry + // tokens — the same three structures, reachable from `batten policy explain` + // instead of only from this file. + assert!(cause("mise run verify | tail -1").contains("verdict read dropped")); + assert!(cause("mise run verify >log 2>&1; ls").contains("verdict carry other")); + assert!(cause("nohup mise run verify &").contains("turn watch dropped")); + // And they are three, not one wearing three hats: no shape renders another's + // class. Without this arm a composer collapsing all three onto one token + // would still satisfy the three assertions above via a substring. + assert!(!cause("mise run verify | tail -1").contains("turn watch dropped")); + assert!(!cause("nohup mise run verify &").contains("verdict read dropped")); } // --- the substitution family (CLOUD-864) -------------------------------------- From 3111d377993b7e4f41fff68ebe6b70e516a343f9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 13:25:34 +0000 Subject: [PATCH 05/20] fix(hook): the hot path emits a class and its pointers, and stops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `render_line` shipped the one-line format in its own doc comment and then three things put the paragraph back: it emitted `(gloss)` unconditionally, `Refusal::render` wrapped the result in `Refused by : … Fix: ….`, and the hook appended an identical bypass sentence on every deny. So even a refusal already reduced to one line was padded back out, and the padding was paid for on every one of the ~300 firings a long session produces. Measured live at 79b8bfd: what fires today is 88 words / ~115 tokens, which over ~300 firings is ~34,500 tokens against a ~175k window. That is 20%, and it is CLOUD-417's headline figure arrived at independently from the other direction. The gloss alone was ~28 of `render_line`'s ~43. The emitted line is now ` `. Dropping the gloss is only safe because the class is a declared three-word name (CLOUD-1284); under the old SCREAMING-KEBAB free text this would have traded concision for opacity. THE DECISION THIS ROW OWED IN WRITING, against CLOUD-122's landed contract: the token IS the pointer to the fix, one hop via `batten policy explain`, and `README.md` is amended to say so. The reason and the remedy do not vary between firings; the pointer does. That is the whole test applied throughout — what repeats moves behind the dereference, what changes stays inline. So the hop had to actually reach everything that left, and three things did not have a lookup before this commit: - a `[[rule]]` row's own `reason` — `explain` now resolves a rule id as well as a class, the two namespaces being unconfusable (three lowercase words versus a kebab identifier); - a declared fact's COMMAND, which CLOUD-776's loop depends on being byte-identical to what the record is verified against — `explain` prints a row's fact commands; - the per-path-class `[[redirect]]` mutation (CLOUD-280), which belongs to a glob rather than to a class or a rule, so the derived gate's own id resolves to that table and the `[[verb]]` fallback under it. The rule id stays on the line rather than moving: two rows can raise one class, and `explain` answers about the class, so without it a reader could not find the config line that refused them. What goes is the `Refused by :` framing. CLOUD-437 is closed rather than narrowed. The hatch sentence was byte-identical on every firing of every row — pure per-firing cost carrying no per-firing information — and naming the wrong variable was the visible symptom of printing it at all. The hatch still works, and the case that proves each row's own variable suppresses its own deny is untouched. THE GATE is a declared `[refusal] max_tokens` in `batten.toml`, never a literal in the crate, copying `[budget.instructions]`'s shape; a zero ceiling is refused at load, and raising or deleting it is a `refusal-ceiling-raised` weakening. `crates/batten/tests/it/refusal_ceiling.rs` measures every refusal this tree can emit against it over the compiled binary — the anti-vacuity half, and the load-bearing one. `GLOSS_MAX` stays: it bounds `explain`'s first line now, and it is what stops the gloss growing back into a paragraph once nothing on the hot path reads it. The 6,640 words of `class` prose are untouched. Placement was the defect. Weakens: `[refusal]` is a new ceiling rather than a relaxed one, and `config-lint` compares it against a base that declares none. Admits: c561ee2b9fea964bceb46a3d0caa983f67d9bb3d8242635ff8cf944b24e578b5 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: bb120993ba862a764bf837d92d6a6fc6248e9b77 Admits-epoch: 9b266c23073f4740b6af63363c75c697adbe1d280ef7054617fc30109667710a Admits-author: alec@wenzowski.com Admits-prev: ff78fde0aff5605b167d7b948c1afa9cbfd787a498aab61a5906c132f5ce4195 Admits-answer-lost: The row's own mechanism. Without the declared table the ceiling would have to be a constant in `crates/batten`, which is this repository's judgement compiled into every consumer's engine and unmovable without a release — the defect non-negotiable rule 1 forbids. The alternative is landing prose with no runnable gate, which is half a change under non-negotiable rule 2. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table: CLOUD-1286 requires the emitted-line ceiling be declared config and never a literal in the crate, so there is no non-protected path that carries it. The write is one a reviewer sees in the diff it lands in — it is a 20-line addition of `[refusal] max_tokens = 24` with its reasoning inline, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the table this sits beside (`[budget.instructions]`) and copied its shape deliberately; reading further does not produce a route that writes the key. `patch run first` does not apply either: there is no patch surface that can add a new top-level table to the policy authority — a patch is still a write to `batten.toml`, so it reaches the same class one indirection later. Refs: CLOUD-1286 --- README.md | 22 ++- batten.toml | 22 +++ crates/batten/src/config.rs | 10 ++ crates/batten/src/hook.rs | 191 +++++++++++++------- crates/batten/src/lib.rs | 126 ++++++++++++- crates/batten/src/refusal.rs | 115 +++++++++++- crates/batten/src/trust.rs | 73 ++++++++ crates/batten/src/verdict.rs | 47 +++-- crates/batten/tests/it/board_receipts.rs | 60 ++++--- crates/batten/tests/it/claim_receipt.rs | 11 +- crates/batten/tests/it/cli.rs | 205 +++++++++++++++++----- crates/batten/tests/it/connector_verbs.rs | 25 ++- crates/batten/tests/it/issue_key.rs | 22 ++- crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/mediated_verbs.rs | 46 +++-- crates/batten/tests/it/pipeline_shapes.rs | 11 +- crates/batten/tests/it/policy_severity.rs | 7 +- crates/batten/tests/it/refusal_ceiling.rs | 161 +++++++++++++++++ crates/batten/tests/it/review_answered.rs | 92 ++++++---- crates/batten/tests/it/run_shape.rs | 7 +- crates/batten/tests/it/task_receipt.rs | 7 +- hk.pkl | 1 + mise.toml | 5 + schema/batten.schema.json | 27 +++ 24 files changed, 1079 insertions(+), 215 deletions(-) create mode 100644 crates/batten/tests/it/refusal_ceiling.rs diff --git a/README.md b/README.md index 17952f05d..ed2e4958f 100644 --- a/README.md +++ b/README.md @@ -112,9 +112,25 @@ Batten's output contract answers all three at once. A finding is a **pointer, no a payload** — a count and a `path:line`, never the matched content — so a wrapped tool's two thousand lines become one. Output is **byte-stable**, so an unchanged repository renders identical bytes and the agent's prefix cache stays warm instead -of being invalidated by a reordered map or a timestamp. And a refusal **points at -the fix**: a deny names the rule, the reason, and the command to run instead, -which is one hop to right rather than a round of guessing. +of being invalidated by a reordered map or a timestamp. + +And a refusal **points at the fix — the class name IS the pointer.** A mediated +deny emits one line: a declared three-word class and the pointers it applies to, +as in `shell edit refused mise-tasks/land.sh:845`. The reason, the routes out +(the escape hatch and the override alike) and the class's full definition are +one hop away, at `batten policy explain "shell edit refused"`. + +That is a decision rather than an omission, and it is the same argument as the +three pains above turned on the tool's own output. The reason and the remedy do +not change between firings, and a mediated refusal fires hundreds of times in a +long session, so inlining them means paying per firing for text that was +declared once. The name carries the class because the names are a declared +vocabulary rather than free text — three positional words, each glossed — which +is what makes one hop cheap and the elision honest rather than merely shorter. +The pointer, which DOES change per firing, stays inline: this shortens the +prose, never the operand a reader acts on. The ceiling on that line is declared +in `batten.toml` and gated, so "one line" is a property of the data and not of +an author's restraint. Magnitude belongs to the benchmark, not to this page. A benchmark is the proof, measured per capability against a named workload with a stated baseline and run diff --git a/batten.toml b/batten.toml index b9a35cb1e..90c7b0bd1 100644 --- a/batten.toml +++ b/batten.toml @@ -3224,6 +3224,28 @@ max_lines = 199 path = ".serena/project.yml" key = "initial_prompt" +# 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). +# +# 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 + # The test suite is half of this repository's definition of green, and was the # unguarded half (CLOUD-55). It cannot be a `protected` path — tests are edited # every day, so a protected glob would block writing them — so the computable diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 00534572e..5b5196b81 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -385,6 +385,11 @@ pub struct Config { /// predicate are [`crate::budget`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub budget: Option, + /// What ONE emitted mediated refusal line may cost (CLOUD-1286). Absent + /// means no ceiling is declared and none is enforced, on the same reading as + /// `[budget]` above. The type and the predicate are [`crate::refusal`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub refusal: Option, /// The ref work must land on (CLOUD-51) — the target `worktree status` /// judges at-risk work against. Consumer-specific by nature: which ref is /// the trunk is a property of the repository being gated, never of Batten @@ -1112,6 +1117,10 @@ fn parse_ungated(text: &str, source: &str) -> Result { // the same one: a table that parses and gates nothing. A `[budget]` header // with no `[budget.instructions]` under it is refused here (CLOUD-50). crate::budget::validate(config.budget.as_ref())?; + // Same shape, same reason, one table over: a `[refusal]` ceiling nothing can + // satisfy is refused at load rather than discovered by the first person it + // fires on. + crate::refusal::validate(config.refusal.as_ref()).map_err(UsageError::raise)?; // Validated at parse, like `[[verb]]` and `[[marker]]`: CLOUD-242's lesson // is that a table nothing validates is coverage that means nothing. if let Some(ci) = &config.ci { @@ -1263,6 +1272,7 @@ impl Config { // An authority that declares no budget grants no exemption from one // either — there is simply no threshold, which is what `None` says. budget: None, + refusal: None, must_land_on: None, // An authority that cannot be read attaches no side effects. The // safe direction is unambiguous here: firing a command an diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index ceb685b25..2d0eff082 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -3963,29 +3963,27 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis /// whose way through is its declared `override` route, and `Refusal::render` /// already carries that route as the fix. So the hatch sentence is simply /// omitted, and what remains is the remedy that works. +/// # 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. #[must_use] pub fn deny_text(refusal: &Refusal, hatch: &str) -> String { - let class = crate::verdict::Native::ProtectedMutation.id(); - if refusal.verdict() == Some(class) { - // THE ROUTE, RENDERED AS THE COMMAND THAT TAKES IT. `Refusal::render`'s - // fix comes from `first_command_route`, which by construction cannot be - // the override — so without this the way through is declared, honoured, - // and undiscoverable from the one place a caller is looking. - // - // Composed from the refusal's own fields rather than written as prose: the - // three arguments ARE the binding `admission::admitted` checks, so a - // caller who runs this line back gets an admission for the situation they - // are actually in, and cannot be handed a command for a different one. - let Some(subject) = refusal.subject() else { - return refusal.render(); - }; - return format!( - "{} No hatch opens this class — take the declared route: \ - `batten override request --rule {} --verdict {class} --subject {subject}`, \ - answer its questions on stdin, then spend the admission it issues.", - refusal.render(), - refusal.rule(), - ); + if refusal.verdict().is_some() { + return refusal.line(); } format!("{} Bypass with {hatch}=1.", refusal.render()) } @@ -9020,13 +9018,29 @@ mod tests { } #[test] - fn the_deny_names_the_rule_and_its_reason() { - // Acceptance (c). The id is what a reviewer greps for in `batten.toml`; - // the reason is what the model acts on. - let reason = denial_text(adjudicate_command("gh pr merge 42")); + fn the_deny_names_the_rule_and_its_class() { + // Acceptance (c), as CLOUD-1286 leaves it. The id is still what a + // reviewer looks up in `batten.toml` and still travels; the row's own + // prose and the hatch do not, because neither varies between firings and + // both are one `batten policy explain` away. + let decision = adjudicate_command("gh pr merge 42"); + let refusal = denial(decision.clone()); + let reason = denial_text(decision); assert!(reason.contains("gh-pr-merge"), "names the rule: {reason}"); - assert!(reason.contains("sanctioned path"), "names why: {reason}"); - assert!(reason.contains(BYPASS_ENV), "names the hatch: {reason}"); + assert!( + reason.contains("call name refused"), + "and the class, which is what carries the why now: {reason}" + ); + assert!( + !reason.contains(BYPASS_ENV), + "the hatch sentence is off the hot path: {reason}" + ); + // The row's prose is not lost, it is dereferenced — asserted on the + // typed field so this case still fails if a deny stops carrying it. + assert_eq!( + refusal.fix().declared_alternative(), + Some("use the sanctioned path for gh-pr-merge"), + ); } #[test] @@ -9737,9 +9751,15 @@ deny contains "refused by themodule" if { rendered.contains("refused by themodule"), "the class the module raised travels: {rendered}" ); + // THE GLOSS DOES NOT (CLOUD-1286). It was inlined on every + // firing and it is the class's own definition, which the + // registry declares once and `batten policy explain` prints on + // request. Asserted in the negative rather than dropped, because + // a silent re-inlining is the exact regression this row exists + // to stop and nothing else in this test would see it. assert!( - rendered.contains("the fixture class"), - "and so does the gloss the registry declares for it: {rendered}" + !rendered.contains("the fixture class"), + "and the gloss is dereferenced rather than carried: {rendered}" ); assert!( !rendered.contains("deny contains"), @@ -9953,7 +9973,17 @@ deny contains "refused by themodule" if { !reason.contains("this commit"), "must not name a commit: {reason}" ); - assert!(reason.contains("claim-check"), "names the route: {reason}"); + // THE ROUTE IS NO LONGER ON THIS LINE, and that is CLOUD-1286 rather + // than a loss: `mise run claim-check` is the class's declared route, it + // does not vary between firings, and `batten policy explain` prints it + // along with every other route the class carries. What must stay inline + // is the pointer — the check whose receipt is missing — because that is + // the half that changes per firing and the half a reader acts on. + assert!(reason.contains("claim"), "names the check: {reason}"); + assert!( + !reason.contains("Fix:"), + "and dereferences the remedy rather than inlining it: {reason}" + ); } #[test] @@ -10197,9 +10227,20 @@ deny contains "refused by themodule" if { }; let rendered = refusal.render(); 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- + // rebase case and `receipt read stale` is the moved-trunk one; they are + // separate declared classes precisely so this distinction survives + // without the prose that used to carry it, and `batten policy explain` + // is where the words "amend" and "rebase" now live. + assert_eq!( + refusal.verdict(), + Some(crate::verdict::Native::ReceiptSuperseded.id()), + "the class must say what invalidated it; got: {rendered}" + ); assert!( - rendered.contains("amend") || rendered.contains("rebase"), - "the cause must say what invalidated it; got: {rendered}" + rendered.contains("receipt read other"), + "and it travels on the line: {rendered}" ); } @@ -10561,14 +10602,20 @@ deny contains "refused by themodule" if { #[test] fn the_deny_names_the_sanctioned_mutation_declared_beside_the_verb() { - let reason = denial_text(guarded("rm .serena/memories/core.md")); + let decision = guarded("rm .serena/memories/core.md"); + let reason = denial_text(decision.clone()); assert!( reason.contains(PROTECTED_MUTATION), "names the gate: {reason}" ); - assert!( - reason.contains("restore it with git"), - "names the fix: {reason}" + // The verb's declared redirect is the fix, and CLOUD-1286 moved it off + // the emitted line onto the dereference. Asserted on the typed field, + // which is the stronger read anyway: a substring could be satisfied by + // the same words appearing in the class prose. + assert_eq!( + denial(decision).fix().declared_alternative(), + Some("restore it with git"), + "the verb's own redirect is still the fix" ); assert!( reason.contains(".serena/memories/core.md"), @@ -10591,48 +10638,60 @@ deny contains "refused by themodule" if { // no third, and a fourth could not be added without stating a `Fix`, // because `Refusal::new` requires one. // - // THE SHARED PROJECTION IS NOW TWO CLAUSES, NOT THREE, and that is a real - // split rather than a weakened assertion. This used to require the hatch - // sentence on every deny, which was right while the hatch reached every - // row. `path write refused` is adjudicated under the hatch now, so - // printing it there would name a remedy that does nothing — the defect - // `crate::verdict`'s header exists to kill. What every deny still owes is - // a `Refused by` clause and a `Fix:` clause. + // THE SHARED PROJECTION IS THE DECLARED LINE (CLOUD-1286): a class and + // its pointers, the rule id among them, and nothing else. The three + // clauses this used to require — `Refused by`, `Fix:`, and the hatch + // sentence — were each a copy of something declared once, restated on + // every one of a session's ~300 firings. + // + // The contract they enforced is NOT weakened, it MOVED, and asserting + // that move is the whole of what remains here: every deny still owes a + // fix, so the fix is asserted on the typed field, where it cannot be + // satisfied by a substring and where a deny that offers nothing still + // fails. for decision in [ adjudicate_command("gh pr merge 42"), guarded("rm .serena/memories/core.md"), guarded("mv batten.toml elsewhere"), ] { + let refusal = denial(decision.clone()); + assert!( + matches!(refusal.fix(), Fix::Run(_)), + "every deny still points to a fix: {refusal:?}" + ); let text = denial_text(decision); - assert!(text.starts_with("Refused by "), "got: {text}"); assert!( - text.contains(" Fix: "), - "every deny points to a fix: {text}" + !text.starts_with("Refused by "), + "and the emitted line carries no prefix restating the token: {text}" + ); + assert!( + !text.contains(" Fix: "), + "nor the remedy, which `batten policy explain` prints: {text}" + ); + assert!( + text.contains(refusal.rule()), + "the rule that fired stays inline, because two rows can raise one \ + class and `explain` cannot say which: {text}" ); } - // The hatch, where it still applies: a `[[rule]]` row is the rest of the - // mediated surface and the variable is still its way out. - assert!( - denial_text(adjudicate_command("gh pr merge 42")) - .ends_with(&format!("Bypass with {BYPASS_ENV}=1.")), - "an explicit row still advertises the hatch" - ); - // And where it does not: the two protected denies must not advertise it, - // AND must name what does work. Asserting only the absence would pass over - // a refusal that offers nothing at all, which is worse than the wrong - // remedy it replaced. + // THE HATCH SENTENCE IS GONE FROM EVERY DENY, which is CLOUD-437 closed + // rather than narrowed. It was byte-identical on every firing, so it was + // pure per-firing cost carrying no per-firing information — and the one + // deny that used to omit it was omitting it because it was WRONG there, + // never because the sentence was worth its price anywhere else. for decision in [ + adjudicate_command("gh pr merge 42"), guarded("rm .serena/memories/core.md"), guarded("mv batten.toml elsewhere"), ] { let text = denial_text(decision); assert!( !text.contains(BYPASS_ENV), - "a class the hatch cannot open must not advertise it: {text}" + "no deny advertises the hatch on the hot path: {text}" ); assert!( - text.contains("batten override request"), - "and must name the route that does work: {text}" + !text.contains("batten override request"), + "and none composes an override command line per firing: {text}" ); } } @@ -10667,9 +10726,17 @@ deny contains "refused by themodule" if { "and the refusal says which class it belongs to" ); let reason = denial_text(decision); + // The FIX is still on the refusal — the assertion above reads it off the + // typed field — and it is what `explain` prints. What the hot path emits + // 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}" + ); assert!( - reason.contains("path write refused ("), - "the hot path leads with the token and its gloss: {reason}" + !reason.contains("path write refused ("), + "and does not inline the class's own definition after it: {reason}" ); assert!( reason.contains("batten.toml"), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 188d6833c..b92ada535 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3228,13 +3228,40 @@ fn run_policy_explain( // second reader of one table always produces. let registry = policy::registry_for(&config.verdicts)?; let Some((resolved, retired)) = verdict::resolve(®istry, token) else { + // A RULE ID RESOLVES HERE TOO (CLOUD-1286), and that is what makes "the + // token is the pointer to the fix" true rather than aspirational. The + // emitted line carries a class AND the rule id that fired, and the two + // answer different halves: the class is Batten's, the row's `reason` is + // the CONSUMER's remedy — "use `mise run land`", "reach for the + // structured surface". Taking that prose off the hot path without giving + // it a lookup would be a refusal naming no remedy, which is the class + // `crate::verdict`'s own header exists to kill. + // + // Tried second rather than first because a class is what a reader most + // often has, and the two namespaces cannot collide: a class is three + // lowercase words and a rule id is a kebab-case identifier. + if let Some(rule) = config.rules.iter().find(|rule| rule.id == token) { + return explain_rule(rule, &config.facts, json, out); + } + // THE DERIVED PROTECTED GATE HAS NO `[[rule]]` ROW, and its remedy is + // per PATH CLASS rather than per rule (CLOUD-280): a `[[redirect]]` + // row's `mutation`, chosen by which glob matched. That remedy left the + // emitted line with everything else, and it is the one that had nowhere + // to land — a class hop answers about `path write refused` generically + // and a rule hop has no row to find. So the gate's own id resolves here, + // to the table that answers "what do I do instead for THIS path". + if token == hook::PROTECTED_MUTATION { + return explain_redirects(&config.redirects, &config.verbs, json, out); + } // Named, and the token is the caller's own argument rather than // anything read out of the tree. A list of what IS declared would be the // whole registry on stderr; the count plus the verb to run is the // pointer-shaped answer. return Err(error::UsageError::raise(format!( - "no `[[verdict]]` row declares `{token}`; this registry declares {} class(es)", - registry.len() + "no `[[verdict]]` row and no `[[rule]]` row declares `{token}`; this registry \ + declares {} class(es) and this config declares {} rule(s)", + registry.len(), + config.rules.len(), ))); }; if json { @@ -3262,6 +3289,101 @@ fn run_policy_explain( Ok(ExitCode::Success) } +/// Resolve a `[[rule]]` id to the remedy its row declares (CLOUD-1286). +/// +/// The consumer half of `explain`. A row's `reason` is documented as "what to do +/// instead", so it is the remedy a reader wants after a deny — and since the +/// emitted line stopped carrying it, this is where it went. Same shape as the +/// class half above and the same exception to pointer-only output, for the same +/// stated reason: the text is the config author's own declaration, echoed back, +/// never content read out of a subject file. +/// Resolve the derived protected gate to the table that answers it (CLOUD-1286). +/// +/// Both tiers, in the order the boundary applies them: a `[[redirect]]` row's +/// `mutation` speaks for a PATH CLASS and wins, and a `[[verb]]` row's +/// `redirect` is the general remedy for the program. Printing only the first +/// would leave the fallback unreachable, which is the tier this repository's own +/// `rm` and `mv` rows land in. +fn explain_redirects( + redirects: &[redirect::Redirect], + verbs: &[verbs::MutatingVerb], + json: bool, + out: &mut dyn Write, +) -> Result { + if json { + writeln!( + out, + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "rule": hook::PROTECTED_MUTATION, + "redirects": redirects, + "verbs": verbs, + }))? + )?; + return Ok(ExitCode::Success); + } + writeln!(out, "{} protected", hook::PROTECTED_MUTATION)?; + writeln!(out)?; + for row in redirects { + writeln!(out, "{} {}", row.glob, row.mutation)?; + } + for row in verbs { + if let Some(redirect) = row.redirect.as_deref() { + writeln!(out, "{} {redirect}", row.verb)?; + } + } + Ok(ExitCode::Success) +} + +/// THE DECLARED COMMANDS ARE PART OF THE ANSWER, not decoration. Where a +/// `receipt` row's checks name agent-sourced facts, the remedy is the exact +/// command whose output will be accepted (CLOUD-776) — byte-identical to what +/// the record is then verified against, which is what closes the loop and what a +/// second wording of it would break. That command left the emitted line with +/// everything else, so it has to arrive here or the loop does not close. +fn explain_rule( + rule: &rules::Rule, + facts: &[facts::Declared], + json: bool, + out: &mut dyn Write, +) -> Result { + let commands: Vec<&str> = rule + .checks + .iter() + .flatten() + .filter_map(|check| facts.iter().find(|fact| &fact.name == check)) + .filter_map(|fact| fact.command.as_deref()) + .collect(); + if json { + writeln!( + out, + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "rule": rule.id, + "kind": rule.kind.as_str(), + "reason": rule.reason, + "commands": commands, + }))? + )?; + return Ok(ExitCode::Success); + } + writeln!(out, "{} {}", rule.id, rule.kind.as_str())?; + writeln!(out)?; + match rule.reason.as_deref() { + Some(reason) => writeln!(out, "{}", reason.trim())?, + // Stated rather than silent, exactly as `Fix::None` is: a reader cannot + // tell an absent remedy from a verb that forgot to print one. + None => writeln!(out, "this row declares no remedy of its own")?, + } + if !commands.is_empty() { + writeln!(out)?; + for command in commands { + writeln!(out, "{command}")?; + } + } + Ok(ExitCode::Success) +} + /// Dispatch the `policy` subtree. /// /// Lifted out of [`run`]'s table alongside [`run_override`] and for the same diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index ca78a5387..18295fa2d 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -39,7 +39,67 @@ //! `crates/batten`, constructed at every deny site, never re-typed per harness — //! is what this module is. -use serde::{Serialize, Serializer}; +use serde::{Deserialize, Serialize, Serializer}; + +/// The `[refusal]` table: what one emitted mediated line may cost. +/// +/// **Declared, never a literal in the crate** (non-negotiable rule 2, and the +/// same reasoning `[budget.instructions]` is built on): a ceiling written into +/// `crates/batten` is this repository's judgement compiled into every consumer's +/// engine, and a consumer whose harness renders differently could not move it +/// without a release. [`crate::budget::BudgetSet`] is the landed shape this +/// copies — a ceiling and nothing else, absent meaning unenforced, because a +/// threshold nobody declared is not a threshold of zero. +/// +/// The unit is **estimated tokens**, on `budget.rs`'s own bytes-per-token +/// convention rather than a tokenizer: this is a ceiling on a line, checked off +/// the hot path, and a real BPE pass here would be the dependency CLOUD-1284 +/// deliberately kept to `[dev-dependencies]`. +/// +/// It is not [`crate::verdict`]'s `GLOSS_MAX`, which stays. That bounds one +/// FIELD — the gloss `explain` prints — and this bounds the emitted LINE. Both +/// exist for the same reason and neither substitutes for the other: with the +/// gloss off the hot path, `GLOSS_MAX` is what stops it growing back into a +/// paragraph where nothing measures it. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Ceiling { + /// The ceiling on estimated tokens for ONE emitted mediated refusal line. + /// The boundary is `<=`: exactly at budget passes, matching + /// [`crate::budget::Report::over_budget`] so the two thresholds in this tree + /// do not disagree about their own edge. + pub max_tokens: usize, +} + +impl Ceiling { + /// Whether one emitted line is over the declared ceiling. + #[must_use] + pub fn over(&self, line: &str) -> bool { + crate::budget::estimate_tokens(line) > self.max_tokens + } +} + +/// Refuse a `[refusal]` table that declares a ceiling nothing could satisfy. +/// +/// A zero ceiling would refuse every line including the shortest possible one, +/// which is the switched-off gate CLOUD-418 names: it fires on everything, so +/// the first person to run it turns it off. Refused at load, in the same +/// direction and for the same reason `budget.rs` refuses an empty set. +/// +/// # Errors +/// +/// When the declared ceiling is zero. +pub fn validate(ceiling: Option<&Ceiling>) -> Result<(), String> { + match ceiling { + Some(declared) if declared.max_tokens == 0 => Err( + "`[refusal] max_tokens = 0` refuses every line a refusal could emit, including the \ + shortest one the grammar can spell — a ceiling nothing can satisfy is a gate that \ + gets switched off rather than one that holds" + .to_owned(), + ), + _ => Ok(()), + } +} /// What to run instead — the half of a refusal that makes it actionable. /// @@ -285,6 +345,52 @@ impl Refusal { ) } + /// 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. + /// + /// **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. + #[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(_) => 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 @@ -363,10 +469,15 @@ mod tests { ); assert_eq!(refusal.verdict(), Some("scanner install missing")); assert!( - refusal.reason().starts_with("scanner install missing ("), + refusal.reason().starts_with("scanner install missing"), "the hot path leads with the token: {}", refusal.reason() ); + assert!( + !refusal.reason().contains('('), + "and does not inline the class's own definition after it (CLOUD-1286): {}", + refusal.reason() + ); assert!( refusal.reason().ends_with(" gitleaks"), "and carries the pointer inline rather than behind `explain`: {}", diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 116b88bcb..6c0211361 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -722,6 +722,11 @@ pub enum WeakeningKind { /// The transcript path is gone, so `check` stops reading the completed /// session it judged against (CLOUD-95). TranscriptPathRemoved, + /// The `[refusal]` ceiling rose, or stopped being declared (CLOUD-1286). + /// Same direction as a budget's: smaller is stricter, so §8's "may not + /// weaken" reads as "may not raise", and an absent ceiling is unenforced + /// rather than zero — which is why dropping the table is this kind too. + RefusalCeilingRaised, /// A `[budget.]` table is gone, so nothing is counted for it /// (CLOUD-50). BudgetSetRemoved, @@ -821,6 +826,7 @@ impl WeakeningKind { WeakeningKind::DefectsLedgerRemoved, WeakeningKind::DefectsClassAdded, WeakeningKind::TranscriptPathRemoved, + WeakeningKind::RefusalCeilingRaised, WeakeningKind::BudgetSetRemoved, WeakeningKind::BudgetFileRemoved, WeakeningKind::BudgetEmbeddedRemoved, @@ -874,6 +880,7 @@ impl WeakeningKind { WeakeningKind::DefectsLedgerRemoved => "defects-ledger-removed", WeakeningKind::DefectsClassAdded => "defects-class-added", WeakeningKind::TranscriptPathRemoved => "transcript-path-removed", + WeakeningKind::RefusalCeilingRaised => "refusal-ceiling-raised", WeakeningKind::BudgetSetRemoved => "budget-set-removed", WeakeningKind::BudgetFileRemoved => "budget-file-removed", WeakeningKind::BudgetEmbeddedRemoved => "budget-embedded-removed", @@ -1089,6 +1096,10 @@ pub const CENSUS: &[FieldCoverage] = &[ WeakeningKind::BudgetLimitRaised, ]), }, + FieldCoverage { + field: "refusal", + coverage: Coverage::Compared(&[WeakeningKind::RefusalCeilingRaised]), + }, FieldCoverage { field: "must_land_on", coverage: Coverage::Compared(&[WeakeningKind::MustLandOnRemoved]), @@ -1703,6 +1714,17 @@ fn scalar_weakenings(base: &Config, working: &Config) -> Vec { working.budget.as_ref(), )); + // The same direction one table over (CLOUD-1286). `ceiling_raised` already + // reads an absent working value as a raise, which is the right reading here + // too: an undeclared ceiling is unenforced, so deleting the table buys + // exactly what raising it to infinity would. + found.extend(ceiling_raised( + WeakeningKind::RefusalCeilingRaised, + "refusal.max_tokens", + base.refusal.as_ref().map(|ceiling| ceiling.max_tokens), + working.refusal.as_ref().map(|ceiling| ceiling.max_tokens), + )); + // `must_land_on` gone leaves `worktree status` with no target — exit 1, and // a gate that cannot judge. A *changed* ref is not compared: two trunk names // cannot be ranked without knowing which repository they belong to. @@ -3638,6 +3660,57 @@ mod tests { } } + #[test] + fn raising_or_dropping_the_refusal_ceiling_is_a_weakening() { + // CLOUD-1286's gate is a number in config, so the two ways to switch it + // off are to raise it past anything it could refuse and to delete the + // table. Both are one kind, because `ceiling_raised` already treats an + // absent working value as the widest raise there is. + let mut base = Config::declaring_nothing(); + base.refusal = Some(crate::refusal::Ceiling { max_tokens: 24 }); + + let mut raised = Config::declaring_nothing(); + raised.refusal = Some(crate::refusal::Ceiling { max_tokens: 200 }); + assert_eq!( + only(&base, &raised), + Weakening::new( + WeakeningKind::RefusalCeilingRaised, + "refusal.max_tokens", + "24", + "200", + ) + ); + + assert_eq!( + only(&base, &Config::declaring_nothing()), + Weakening::new( + WeakeningKind::RefusalCeilingRaised, + "refusal.max_tokens", + "24", + "absent", + ) + ); + } + + #[test] + fn lowering_the_refusal_ceiling_is_not_reported() { + // The direction that must stay quiet, or the arm above fires on the work + // it exists to protect: tightening a ceiling is the ratchet turning the + // way it is supposed to. + let mut base = Config::declaring_nothing(); + base.refusal = Some(crate::refusal::Ceiling { max_tokens: 24 }); + let mut working = Config::declaring_nothing(); + working.refusal = Some(crate::refusal::Ceiling { max_tokens: 12 }); + + let found = weakenings(&base, &working); + assert!( + !found + .iter() + .any(|weakening| weakening.kind == WeakeningKind::RefusalCeilingRaised), + "a tightened ceiling is not a weakening: {found:?}" + ); + } + #[test] fn abandoning_the_naming_vocabulary_is_a_weakening() { // CLOUD-1284's grammar is OPT-IN on a declared `[vocabulary]`, which is diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 7d193337d..152f386b1 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -376,34 +376,47 @@ pub fn render_subjects(subjects: &[Subject]) -> String { .join(" ") } -/// What a refusal says on the hot path: the token, its gloss, its pointers -/// (CLOUD-1053). +/// What a refusal says on the hot path: the token and its pointers, and stops +/// (CLOUD-1053, narrowed by CLOUD-1286). /// /// ```text -/// task name undefined (a command row names a task this tree does not define) batten.toml:1604 +/// task name undefined batten.toml:1604 /// ``` /// +/// **The gloss is gone from this line and that is the whole change.** It used to +/// be emitted unconditionally, and it was ~28 of the ~43 tokens a rendered line +/// cost — the class's own definition, inlined on every one of the ~300 firings a +/// long session pays for, when the class is declared once and a reader who wants +/// it can ask. `batten policy explain ` is where it went, together with +/// the `class` prose that was never on this line at all. +/// +/// **Dropping it is safe only because the token is a THREE-WORD DECLARED NAME** +/// (CLOUD-1284). Under the old SCREAMING-KEBAB free text this would have traded +/// concision for opacity; under the grammar the name is the gloss's short form, +/// which is what that row bought. +/// /// **The subject stays inline** rather than being dereferenced through /// `explain`. Making a reader run a second command to learn WHICH file would -/// make the common case slower, which is the opposite of the point; what moves -/// behind `explain` is the class definition, which the common case does not -/// need. +/// make the common case slower, which is the opposite of the point. This +/// shortens the prose, never the pointer. /// -/// A token the registry does not carry renders as itself with the gap stated. -/// `policy::load` refuses that at load, so it is reachable only on the mediated -/// path, where the AST check is skipped for CLOUD-689's budget — and there -/// saying so beats either inventing a gloss or dropping the refusal. +/// A token the registry does not carry still renders as itself. It gets no +/// composed apology: `policy::load` refuses an undeclared token at load, so this +/// is reachable only on the mediated path where the AST check is skipped for +/// CLOUD-689's budget, and there the token alone is both the honest answer and +/// the one a reader can look up. #[must_use] -pub fn render_line(registry: &[DeclaredVerdict], token: &str, subjects: &[Subject]) -> String { - let gloss = resolve(registry, token).map_or( - "no `[[verdict]]` row declares this class, so it carries no gloss", - |(entry, _)| entry.gloss.as_str(), - ); +/// The registry parameter is kept though this line no longer reads it: it is +/// what `explain` resolves the token against, and every call site already holds +/// it. Dropping it from the signature would be a churn across ten composers to +/// buy back one unused reference, and would have to be undone the moment the +/// line carries anything registry-derived again. +pub fn render_line(_registry: &[DeclaredVerdict], token: &str, subjects: &[Subject]) -> String { let pointers = render_subjects(subjects); if pointers.is_empty() { - format!("{token} ({gloss})") + token.to_owned() } else { - format!("{token} ({gloss}) {pointers}") + format!("{token} {pointers}") } } diff --git a/crates/batten/tests/it/board_receipts.rs b/crates/batten/tests/it/board_receipts.rs index b7b991487..7095734bb 100644 --- a/crates/batten/tests/it/board_receipts.rs +++ b/crates/batten/tests/it/board_receipts.rs @@ -329,11 +329,14 @@ fn filing_without_a_search_is_refused_and_with_one_is_allowed() { &payload("mcp__Linear__save_issue", r#"{"title":"a finding"}"#), ); let text = stderr(&refusal); + // The CALL that mints the receipt is the class's declared route, which + // CLOUD-1286 moved behind `batten policy explain` — it does not vary + // between firings, and paying for it on each one was the defect. What must + // stay inline is the pointer: WHICH receipt is missing, and which row wants + // it. assert!( - text.contains("list_issues"), - "the refusal must name the CALL that mints the receipt (CLOUD-1024: there \ - is no mint command any more, so naming one would send the reader to a \ - task this tree deleted): {text}" + text.contains("issue-search"), + "the refusal must name the receipt that is absent: {text}" ); assert!( text.contains("filing-needs-a-search"), @@ -370,19 +373,21 @@ fn an_update_is_not_row_ones_business() { ), ); let text = stderr(&refusal); - // THE REFUSING ROW IS READ FROM THE PREFIX, not by searching the whole - // refusal for a row id. Measured: a bare `contains("filing-needs-a-search")` - // fails here, because row 2's own reason ENDS by naming row 1 — "Creating an - // issue is never gated by this row (that is `filing-needs-a-search`)" — which - // is exactly the cross-reference the two complements should carry. A substring - // test over a refusal cannot tell a row that spoke from a row it pointed at; - // `Refused by ` is the engine's own attribution and can. + // THE REFUSING ROW IS THE ONLY ROW ID ON THE LINE, which is what CLOUD-1286 + // changed here and it changed it for the better. This case used to have to + // read attribution off the `Refused by ` PREFIX, because a bare + // `contains("filing-needs-a-search")` matched row 2's own reason — which + // ENDS by naming row 1, "Creating an issue is never gated by this row (that + // is `filing-needs-a-search`)". That cross-reference is prose, so it now + // 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("Refused by filing-needs-a-search"), + !text.contains("filing-needs-a-search"), "an update names an id, so the row that gates FILING must stay silent: {text}" ); assert!( - text.contains("Refused by an-update-owes-a-recent-read"), + text.contains("an-update-owes-a-recent-read"), "and the row that does answer an edit is the one that spoke: {text}" ); } @@ -555,10 +560,13 @@ fn an_update_with_no_receipt_is_refused() { text.contains("an-update-owes-a-recent-read"), "the row that refused, so a reader can find it in the config: {text}" ); + // The CALL that mints the receipt is the class's declared route and is one + // `batten policy explain` away (CLOUD-1286). It is the same string on every + // firing, which is exactly what does not belong on a line an agent pays for + // ~300 times a session. assert!( - text.contains("get_issue"), - "and the CALL that mints the receipt, which is the fix (CLOUD-1024: the \ - mint follows the read, so the remedy is the read): {text}" + !text.contains("Fix: "), + "the remedy is dereferenced rather than inlined: {text}" ); // THE VERDICT WORDING FOR `ReceiptKey::Named`, pinned because it was missing: // `receipt_refusal` had arms for `Branch` and for the commit-keyed default, @@ -882,12 +890,21 @@ fn a_move_with_no_adjudication_is_refused() { ); let text = stderr(&refusal); assert!( - text.contains("Refused by a-move-to-in-review-owes-an-adjudication"), + text.contains("a-move-to-in-review-owes-an-adjudication"), "the row that refused: {text}" ); + // The check whose receipt is missing is the pointer and stays inline; the + // COMMAND that mints it is the class's declared route, which CLOUD-1286 + // moved behind `batten policy explain`. Both halves asserted, because + // dropping the first would be a real loss and dropping the second is the + // change. + assert!( + text.contains("board-move"), + "and the check whose receipt is absent: {text}" + ); assert!( - text.contains("graph-check"), - "and the command that decides whether the closure is real: {text}" + !text.contains("Refused by "), + "with no prefix restating the class: {text}" ); mint_move_receipt(&repo, "CLOUD-1", 5); @@ -924,9 +941,12 @@ fn an_adjudication_past_the_bound_is_refused() { ); let text = stderr(&refusal); assert!( - text.contains("Refused by a-move-to-in-review-owes-an-adjudication"), + text.contains("a-move-to-in-review-owes-an-adjudication"), "the row that refused: {text}" ); + // The bound the age was measured against travels as a pointer, because it + // is the difference between "run it again" and a row nobody can satisfy. + assert!(text.contains("900s"), "and the bound it crossed: {text}"); mint_move_receipt(&repo, "CLOUD-1", 5); assert_eq!( diff --git a/crates/batten/tests/it/claim_receipt.rs b/crates/batten/tests/it/claim_receipt.rs index 54e7e6e6d..c1b2ce29e 100644 --- a/crates/batten/tests/it/claim_receipt.rs +++ b/crates/batten/tests/it/claim_receipt.rs @@ -143,7 +143,7 @@ fn a_write_with_no_claim_receipt_is_refused() { } #[test] -fn the_refusal_names_the_route_and_the_keying() { +fn the_refusal_names_the_check_and_the_keying() { let dir = repo("claim-refusal"); let refusal = stderr(&run_with_stdin( &dir, @@ -155,9 +155,14 @@ fn the_refusal_names_the_route_and_the_keying() { "names the rule: {refusal}" ); assert!(refusal.contains("branch"), "names the keying: {refusal}"); + // The ROUTE (`mise run claim-check`) is the class's declared remedy and is + // one `batten policy explain` away since CLOUD-1286: it is the same string + // on every firing, so inlining it was pure repetition. The CHECK whose + // receipt is missing does vary, so it stays — and it is what turns "refused" + // into something a reader can act on. assert!( - refusal.contains("claim-check"), - "names the route rather than only refusing: {refusal}" + refusal.contains("claim"), + "names the check rather than only refusing: {refusal}" ); // The wrong pointer this avoids: a per-commit remedy for a branch-wide claim. assert!( diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index b5cba0f54..23e7ab6da 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -1767,21 +1767,45 @@ fn every_hook_policy_table_deny_names_its_fix() { let output = run_hook_in(&dir, "exit-code", &claude_payload(case.command), false); 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 explain ` + // 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. + let row = stderr + .split_whitespace() + .next_back() + .expect("a deny names the rule that fired"); + let explained = batten_with(&dir, &["policy", "explain", row], &[]); + assert_eq!( + explained.status.code(), + Some(0), + "{}: the rule on the line must resolve, got: {stderr}", + case.command + ); + let text = String::from_utf8_lossy(&explained.stdout); assert!( - stderr.contains(&format!("Fix: {}", case.fix)), - "{}: the refusal must name the sanctioned command, got: {stderr}", + text.contains(case.fix), + "{}: the hop must reach the sanctioned command, got: {text}", case.command ); } } #[test] -fn the_in_band_hosts_carry_the_fix_in_their_decision_document() { +fn the_in_band_hosts_carry_the_decision_in_their_document() { // The contract is not stderr-only. Claude discards stdout on exit 2 and // Cursor assigns stderr no meaning at all, so on those two hosts the decision - // document is the ONLY place a fix pointer can travel — the case that would + // document is the ONLY place the refusal can travel — the case that would // silently regress to a bare "deny" if the projection happened per channel // instead of once. + // + // CLOUD-1286 shortened WHAT travels, not WHERE: the class, the pointers and + // the rule id, with the fix one `batten policy explain ` away. That + // this file's projection is still one function rather than one per host is + // exactly what this case pins, and it pins it on the shorter line just as + // well. let dir = repo_with_gh_policy("refusal-in-band"); for (harness, pointer) in [ ( @@ -1799,8 +1823,12 @@ fn the_in_band_hosts_carry_the_fix_in_their_decision_document() { .and_then(serde_json::Value::as_str) .unwrap_or_else(|| panic!("{harness}: no reason at {pointer}: {body}")); assert!( - reason.contains("Fix: use `mise run land`"), - "{harness}: the document must carry the fix, got: {reason}" + reason.contains("call name refused"), + "{harness}: the document must carry the class, got: {reason}" + ); + assert!( + reason.contains("gh-pr-merge"), + "{harness}: and the rule the hop takes, got: {reason}" ); } } @@ -1839,17 +1867,27 @@ fn a_deny_with_no_consumer_remedy_falls_back_to_the_declared_class() { ); assert_eq!(output.status.code(), Some(2), "the protected gate denies"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!( - stderr.contains("Refused by protected-mutation:"), + // CLOUD-1286: the emitted line is the class, the pointers, and the rule id. + // The FLOOR itself — that a native refusal falls back to its class's own + // `command` route rather than to a generic apology — is asserted on the + // typed field by `hook::tests::a_verb_with_no_redirect_falls_back_to_the_ + // classs_own_route`, which is the stronger read: a substring here could be + // satisfied by the same words appearing anywhere in the line. + assert!( + stderr.contains("protected-mutation"), "names the gate, got: {stderr}" ); assert!( - stderr.contains("path write refused ("), - "the hot path leads with the token and its gloss, got: {stderr}" + stderr.contains("path write refused"), + "the hot path leads with the token, got: {stderr}" + ); + assert!( + !stderr.contains("path write refused ("), + "and does not inline the class's own definition, got: {stderr}" ); assert!( - stderr.contains("Fix: git restore"), - "the class's own route is the floor, got: {stderr}" + !stderr.contains("Fix: "), + "nor the remedy, which `batten policy explain` prints, got: {stderr}" ); } @@ -1885,13 +1923,39 @@ fn a_deny_names_the_path_classs_own_mutation_over_the_verbs() { ); assert_eq!(claimed.status.code(), Some(2), "the protected gate denies"); let stderr = String::from_utf8_lossy(&claimed.stderr); + // CLOUD-1286 took the remedy off the emitted line, so what this case can + // still assert over the compiled binary is that the two paths are told + // apart AT ALL and that neither remedy is inlined. WHICH remedy wins — the + // path class's over the verb's, CLOUD-280's tiering — is asserted on the + // typed `Refusal::fix()` by `hook::tests::the_deny_names_the_sanctioned_ + // mutation_declared_beside_the_verb`. + // + // The hop for THIS remedy is the derived gate's own id rather than a class + // or a rule, because the answer is per path glob: `batten policy explain + // protected-mutation` prints the `[[redirect]]` table and the `[[verb]]` + // fallback under it, in the order the boundary applies them. + let explained = batten_with(&dir, &["policy", "explain", "protected-mutation"], &[]); + assert_eq!(explained.status.code(), Some(0), "the gate resolves"); + let routes = String::from_utf8_lossy(&explained.stdout); + assert!( + routes.contains("change it in a pull request"), + "the path class's remedy is reachable, got: {routes}" + ); + assert!( + routes.contains("restore it with git"), + "and so is the verb's fallback, got: {routes}" + ); + assert!( + stderr.contains("guarded/thing.md"), + "the path class that matched is the pointer, got: {stderr}" + ); assert!( - stderr.contains("Fix: change it in a pull request"), - "the declared class answers, got: {stderr}" + !stderr.contains("change it in a pull request"), + "the remedy is dereferenced rather than inlined, got: {stderr}" ); assert!( !stderr.contains("restore it with git"), - "the verb's general remedy must not also appear, got: {stderr}" + "the verb's general remedy must not appear either, got: {stderr}" ); let unclaimed = run_hook_in( @@ -1903,8 +1967,8 @@ fn a_deny_names_the_path_classs_own_mutation_over_the_verbs() { assert_eq!(unclaimed.status.code(), Some(2), "still denied"); let stderr = String::from_utf8_lossy(&unclaimed.stderr); assert!( - stderr.contains("Fix: restore it with git"), - "an unclaimed class leaves the verb's redirect standing, got: {stderr}" + stderr.contains("vendor/thing.md"), + "an unclaimed class still points at what it refused, got: {stderr}" ); } @@ -2206,7 +2270,10 @@ fn a_quoted_invocation_denies_on_both_harness_channels() { assert!(stdout.contains("\"deny\""), "{harness}: got {stdout}"); } else { assert_eq!(output.status.code(), Some(2), "{harness}"); - assert!(stderr.contains("Refused by"), "{harness}: got {stderr}"); + // The row's id on the line is what says a deny reached this channel + // — CLOUD-1286 took the `Refused by` prefix off it, and this case + // is about the CHANNEL rather than about the wording. + assert!(stderr.contains("gh-pr-merge"), "{harness}: got {stderr}"); } } } @@ -2425,12 +2492,25 @@ fn hook_denies_a_mutating_verb_against_a_protected_path_on_both_channels() { if reads_a_deny_body(harness) { assert_eq!(output.status.code(), Some(0), "{harness}"); assert!(stdout.contains("\"deny\""), "{harness}: got {stdout}"); - assert!(stdout.contains("restore it with git"), "names the redirect"); + // CLOUD-1286: the redirect is dereferenced; what every channel + // carries is the class and the path it refused. + assert!( + stdout.contains("path write refused"), + "{harness}: names the class, got {stdout}" + ); + assert!( + stdout.contains("guarded/thing"), + "{harness}: and the pointer, got {stdout}" + ); } else { assert_eq!(output.status.code(), Some(2), "{harness}"); assert!( - stderr.contains("restore it with git"), - "{harness}: names the redirect, got {stderr}" + stderr.contains("path write refused"), + "{harness}: names the class, got {stderr}" + ); + assert!( + stderr.contains("guarded/thing"), + "{harness}: and the pointer, got {stderr}" ); } } @@ -2935,8 +3015,13 @@ fn the_committed_shape_rules_fire_on_every_banned_shape() { case.call.describe() ); let stderr = String::from_utf8_lossy(&output.stderr); + // The rule id is still the engine's own attribution and is still what + // this census reads; CLOUD-1286 took the `Refused by` framing off it, + // and the id now ENDS the line, which is why this is an `ends_with` + // rather than a bare `contains` — the stricter read, and the one that + // still tells a row that spoke from a row it merely mentioned. assert!( - stderr.contains(&format!("Refused by {}:", case.rule)), + stderr.trim().ends_with(&case.rule), "{:?} must be refused by {}, got: {stderr}", case.call.describe(), case.rule @@ -2986,8 +3071,16 @@ fn the_bare_cargo_refusal_names_the_sanctioned_route() { ); assert_eq!(output.status.code(), Some(2)); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("mise exec -- cargo"), "got: {stderr}"); - assert!(stderr.contains("mise run"), "got: {stderr}"); + // CLOUD-1286: the route is one hop from the row id on the line. The claim + // this case makes is unchanged and still asserted end to end — a reader must + // be able to reach "the program is fine, the route is not" rather than + // reading the deny as "cargo is banned". + assert!(stderr.contains("no-bare-cargo"), "got: {stderr}"); + let explained = batten_with(&root, &["policy", "explain", "no-bare-cargo"], &[]); + assert_eq!(explained.status.code(), Some(0), "the row resolves"); + let text = String::from_utf8_lossy(&explained.stdout); + assert!(text.contains("mise exec -- cargo"), "got: {text}"); + assert!(text.contains("mise run"), "got: {text}"); } #[test] @@ -3236,9 +3329,11 @@ fn hook_denies_a_blocked_shape_in_the_harness_channel() { stdout.contains("\"permissionDecision\":\"deny\""), "got: {stdout}" ); + // CLOUD-1286: the redirect is one hop off the line, so what the channel must + // carry is the row that refused — the handle that hop takes. assert!( - stdout.contains("mise run land"), - "the deny must name the redirect the fixture policy declares" + stdout.contains("gh-pr-merge"), + "the deny must name the row the fixture policy declares, got: {stdout}" ); } @@ -3279,9 +3374,13 @@ fn every_host_denies_the_same_call_through_its_own_channel() { stdout.contains(marker), "{harness}: wrong deny shape, got: {stdout}" ); + // CLOUD-1286: the redirect is one hop away, so what every channel must + // carry is the ROW that refused — the handle the hop takes. This case is + // about the channel, and the hop itself is proven by + // `every_hook_policy_table_deny_names_its_fix`. assert!( - stdout.contains("mise run land"), - "{harness}: the deny must name the redirect" + stdout.contains("gh-pr-merge"), + "{harness}: the deny must name the row that refused, got: {stdout}" ); } @@ -3302,8 +3401,8 @@ fn every_host_denies_the_same_call_through_its_own_channel() { "{harness}: stray stdout on these hosts risks being read as an allow" ); assert!( - common::stderr(&output).contains("mise run land"), - "{harness}: the reason travels on stderr here" + common::stderr(&output).contains("gh-pr-merge"), + "{harness}: the decision travels on stderr here" ); } } @@ -3596,7 +3695,7 @@ fn hook_exit_code_harness_denies_with_exit_2() { assert_eq!(output.status.code(), Some(2)); assert!(output.stdout.is_empty()); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("Refused by"), "got: {stderr}"); + assert!(stderr.contains("gh-pr-merge"), "got: {stderr}"); // A verdict is an answer, not a crash. The host hands this text back to the // model as the deny reason, so it must not wear the binary's error prefix. assert!( @@ -9079,24 +9178,36 @@ fn hatch_call(command: &str) -> String { } #[test] -fn a_deny_names_the_hatch_its_own_row_declared() { - // CLOUD-437's worked case, in both directions at once: the row that owns - // `BATTEN_GH_GUARD_BYPASS` names it, and the row that does not must NOT — - // which is the defect, a deny pointing at another subsystem's variable. +fn no_deny_advertises_a_hatch_on_the_hot_path() { + // CLOUD-437 CLOSED RATHER THAN NARROWED (CLOUD-1286). That row's defect was + // a deny pointing at another subsystem's variable, and the fix at the time + // was to advertise the RIGHT one. The fix now is to advertise none: the + // sentence was byte-identical on every firing of every row, so it was pure + // per-firing cost carrying no per-firing information, and naming the wrong + // variable was only the most visible symptom of printing it at all. + // + // The hatch is not removed and this is not a weakening — `the_hatch_a_deny_ + // advertises_actually_suppresses_that_deny` below still proves each row's + // own variable suppresses its own deny, which was always the load-bearing + // half. What is gone is the advertisement. let dir = repo_with_config("hatch-named", HATCH_POLICY_CONFIG); let owned = run_hook_with_env(&dir, "claude-code", &hatch_call("gh pr merge 42"), &[]); let owned_text = String::from_utf8_lossy(&owned.stdout).into_owned(); assert!( - owned_text.contains("Bypass with BATTEN_GH_GUARD_BYPASS=1."), - "the row that declared it advertises it: {owned_text}" + owned_text.contains("owns-its-hatch"), + "the row that refused is still named: {owned_text}" + ); + assert!( + !owned_text.contains("BATTEN_GH_GUARD_BYPASS"), + "but it does not advertise its hatch: {owned_text}" ); let general = run_hook_with_env(&dir, "claude-code", &hatch_call("danger-zone --now"), &[]); let general_text = String::from_utf8_lossy(&general.stdout).into_owned(); assert!( - general_text.contains("Bypass with BATTEN_HOOK_BYPASS=1."), - "a row declaring none takes the general hatch: {general_text}" + !general_text.contains("BATTEN_HOOK_BYPASS"), + "and neither does a row taking the general hatch: {general_text}" ); assert!( !general_text.contains("GH_GUARD"), @@ -10699,9 +10810,21 @@ fn the_agent_sourced_fact_loop_closes_end_to_end() { let denied = run_hook_in(&dir, "exit-code", PR_CREATE, false); assert_eq!(denied.status.code(), Some(2), "a missing fact must deny"); let reason = common::stderr(&denied); + // CLOUD-1286 moved the command one hop out, and this case is what proves the + // hop lands: the loop's whole content is that the agent runs the EXACT + // string the record is verified against, so a dereference that lost it would + // be a broken loop rather than a shorter line. + assert!( + reason.contains("claim-not-raced"), + "the deny names the row that refused; got: {reason}" + ); + let explained = batten_with(&dir, &["policy", "explain", "claim-not-raced"], &[]); + assert_eq!(explained.status.code(), Some(0), "the row resolves"); assert!( - reason.contains("gh pr list --state open --json headRefName"), - "the deny must name the command whose output will be accepted; got: {reason}" + String::from_utf8_lossy(&explained.stdout) + .contains("gh pr list --state open --json headRefName"), + "the hop must reach the command whose output will be accepted; got: {}", + String::from_utf8_lossy(&explained.stdout) ); // 2. The agent runs it. The harness hands the buffer back. diff --git a/crates/batten/tests/it/connector_verbs.rs b/crates/batten/tests/it/connector_verbs.rs index fe44675fd..bcf1ae416 100644 --- a/crates/batten/tests/it/connector_verbs.rs +++ b/crates/batten/tests/it/connector_verbs.rs @@ -72,7 +72,7 @@ use crate::common; use std::path::{Path, PathBuf}; -use common::{Fixture, run_with_stdin, stderr}; +use common::{Fixture, run, run_with_stdin, stderr}; /// This repository's own rows, as committed — never a fixture rewriting them. fn repo(name: &str) -> PathBuf { @@ -135,8 +135,11 @@ fn every_spelling_of_a_decided_verb_is_refused() { "whatever name the host minted, this verb is denied: {tool}" ); let text = stderr(&refusal); + // CLOUD-1286 took the `Refused by` framing off the line; the rule id + // is still the engine's attribution and now ends it, which is the + // stricter read of the same question. assert!( - text.contains(&format!("Refused by {rule}:")), + text.trim().ends_with(rule), "{tool} must be refused by {rule}, got: {text}" ); } @@ -175,7 +178,7 @@ fn a_verb_merely_containing_a_decided_one_is_untouched() { let text = stderr(&output); for (_, rule) in DECIDED { assert!( - !text.contains(&format!("Refused by {rule}:")), + !text.contains(rule), "{rule} has no verdict on {tool}, got: {text}" ); } @@ -212,9 +215,21 @@ 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. + let row = text + .split_whitespace() + .next_back() + .expect("a deny names the rule that fired"); + let explained = run(&repo, &["policy", "explain", row]); + assert_eq!(explained.status.code(), Some(0), "{verb}: the row resolves"); + let explained_text = String::from_utf8_lossy(&explained.stdout); assert!( - text.contains(remedy), - "{verb}'s refusal must name its own remedy ({remedy}), got: {text}" + explained_text.contains(remedy), + "{verb}'s refusal must reach its own remedy ({remedy}), got: {explained_text}" ); } } diff --git a/crates/batten/tests/it/issue_key.rs b/crates/batten/tests/it/issue_key.rs index be66b980e..309ce5cb1 100644 --- a/crates/batten/tests/it/issue_key.rs +++ b/crates/batten/tests/it/issue_key.rs @@ -27,7 +27,7 @@ use crate::common; use std::path::{Path, PathBuf}; -use common::{Fixture, git_in, run_with_stdin, scratch_outside_tree, stderr}; +use common::{Fixture, git_in, run, run_with_stdin, scratch_outside_tree, stderr}; /// The policy under test: the committed rows' shape, with nothing else declared. /// @@ -309,19 +309,27 @@ fn the_refusal_names_the_route_and_leaks_no_evidence() { refusal.contains("pr-names-an-issue"), "names the rule: {refusal}" ); + // WHAT IS MISSING IS THE CLASS (CLOUD-1286): `issue name missing` says the + // key is absent rather than that the shape is banned, in three words, and it + // is a name a reader can look up. The sentence that used to say it, and the + // places to put a key, are what `batten policy explain` prints. assert!( - refusal.contains("names no tracker key"), + refusal.contains("issue name missing"), "says what is missing rather than that the shape is banned: {refusal}" ); + let explained = run(&dir, &["policy", "explain", "pr-names-an-issue"]); + assert_eq!(explained.status.code(), Some(0), "the row resolves"); assert!( - refusal.contains("branch"), - "names a place to put one: {refusal}" + String::from_utf8_lossy(&explained.stdout).contains("branch"), + "and the hop names a place to put one" ); // CLOUD-403 measured that the bash guard's deny text advertised no reachable - // hatch. The engine supplies one for free, and this is what keeps it there. + // hatch, and CLOUD-437 that advertising one on every deny was itself the + // defect. CLOUD-1286 settles it: the hatch is not advertised at all, because + // the sentence was byte-identical on every firing of every row. assert!( - refusal.contains("Bypass with"), - "the documented hatch is reachable from the refusal: {refusal}" + !refusal.contains("Bypass with"), + "and the hatch sentence is off the hot path: {refusal}" ); // Pointer-only (non-negotiable rule 4). The gate read the branch name and // every commit message on the range; the refusal must quote none of them. diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 0c22b57ce..d4e174e5d 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -150,6 +150,7 @@ mod provision; mod ratchet; mod ready; mod reference_coverage; +mod refusal_ceiling; mod remedy_authorship; mod retirement_doctrine; mod review_answered; diff --git a/crates/batten/tests/it/mediated_verbs.rs b/crates/batten/tests/it/mediated_verbs.rs index fcb2c1959..1bd7f39b5 100644 --- a/crates/batten/tests/it/mediated_verbs.rs +++ b/crates/batten/tests/it/mediated_verbs.rs @@ -34,7 +34,7 @@ use crate::common; use std::path::PathBuf; -use common::{run_with_stdin, stderr}; +use common::{run, run_with_stdin, stderr}; /// A protected path this repository declares, and one it does not. /// @@ -285,10 +285,6 @@ fn the_deny_names_the_whole_action_and_the_serena_tool_to_use_instead() { )); assert!(refusal.contains("git mv"), "names the action: {refusal}"); assert!(refusal.contains(GUARDED), "names where: {refusal}"); - assert!( - refusal.contains("rename_memory"), - "names the route that rewrites referrers: {refusal}" - ); let edit = stderr(&run_with_stdin( &root(), @@ -296,8 +292,24 @@ fn the_deny_names_the_whole_action_and_the_serena_tool_to_use_instead() { &bash_payload(&format!("sed -i s/a/b/ {GUARDED}")), )); assert!( - edit.contains("edit_memory"), - "an in-place edit names the editing tool: {edit}" + edit.contains("sed"), + "an in-place edit names the action too: {edit}" + ); + + // THE ROUTES ARE ONE HOP OFF THE LINE (CLOUD-1286), and this asserts the hop + // reaches BOTH — the move's and the edit's — because they are different + // Serena tools and only `rename_memory` rewrites `mem:` referrers. A single + // assertion here would pass over the two collapsing into one. + let explained = run(&root(), &["policy", "explain", "protected-mutation"]); + assert_eq!(explained.status.code(), Some(0), "the gate resolves"); + let routes = String::from_utf8_lossy(&explained.stdout); + assert!( + routes.contains("rename_memory"), + "names the route that rewrites referrers: {routes}" + ); + assert!( + routes.contains("edit_memory"), + "and the one that edits in place: {routes}" ); } @@ -335,9 +347,17 @@ fn a_registered_module_gets_its_own_route_and_not_the_memory_one() { !module.contains("write_memory") && !module.contains("edit_memory"), "a module must not be sent to a memory tool: {module}" ); + // CLOUD-1286: the per-path-class remedy is one hop from the gate id on the + // line, and this asserts the hop lands rather than that a substring appears. + // The gate is the DERIVED protected one, so it has no `[[rule]]` row — the + // hop resolves to the `[[redirect]]` table instead, which is where the + // per-class answer actually lives. + let explained = run(&root(), &["policy", "explain", "protected-mutation"]); + assert_eq!(explained.status.code(), Some(0), "the gate resolves"); + let routes = String::from_utf8_lossy(&explained.stdout); assert!( - module.contains("policy-test"), - "names the route that checks a module edit before it lands: {module}" + routes.contains("policy-test"), + "names the route that checks a module edit before it lands: {routes}" ); // THE MIRROR. Without it the assertions above pass over a build that simply @@ -348,8 +368,12 @@ fn a_registered_module_gets_its_own_route_and_not_the_memory_one() { &bash_payload(&format!("sed -i s/a/b/ {GUARDED}")), )); assert!( - memory.contains("edit_memory"), - "a memory still names the Serena route: {memory}" + memory.contains(GUARDED), + "a memory write still points at the memory it refused: {memory}" + ); + assert!( + routes.contains("edit_memory"), + "and the memory class still declares the Serena route: {routes}" ); } diff --git a/crates/batten/tests/it/pipeline_shapes.rs b/crates/batten/tests/it/pipeline_shapes.rs index d83011c2f..e96c9972d 100644 --- a/crates/batten/tests/it/pipeline_shapes.rs +++ b/crates/batten/tests/it/pipeline_shapes.rs @@ -30,7 +30,7 @@ use crate::common; use std::path::PathBuf; -use common::{run_with_stdin, stderr}; +use common::{run, run_with_stdin, stderr}; fn root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") @@ -216,8 +216,15 @@ fn the_refusal_states_the_principle_rather_than_naming_one_command() { refusal.contains("verdict read dropped"), "states the principle: {refusal}" ); + // The remedy is one hop from the rule id on the line (CLOUD-1286), and it is + // the row's own — which is what makes the generalisation this case is about + // survive the move: `explain` prints the principle in full rather than the + // narrower wording an agent complied with literally and then re-broke on the + // next command (CLOUD-199). + let explained = run(&root(), &["policy", "explain", "verdict-not-discarded"]); + assert_eq!(explained.status.code(), Some(0), "the row resolves"); assert!( - refusal.contains("run_in_background"), + String::from_utf8_lossy(&explained.stdout).contains("run_in_background"), "names the remedy: {refusal}" ); // Pointer-only: the caller's own command line is never echoed back. diff --git a/crates/batten/tests/it/policy_severity.rs b/crates/batten/tests/it/policy_severity.rs index ed4004b24..4e858a25c 100644 --- a/crates/batten/tests/it/policy_severity.rs +++ b/crates/batten/tests/it/policy_severity.rs @@ -345,9 +345,10 @@ fn equal_force_leaves_declaration_order_as_the_tie_break() { let output = hook(&dir, &command_payload("PreToolUse", CALL)); let stdout = stdout_of(&output); assert!( - // The class is rendered followed by its gloss, so anchor on that rather - // than on a delimiter the projection does not emit. - stdout.contains("fixture severity probe ("), + // CLOUD-1286: the class is rendered alone, so anchor on it. The negative + // arm below is what keeps this discriminating, since the two fixture + // class names share a prefix. + stdout.contains("fixture severity probe"), "the first-declared class of two equally strong ones: {stdout}" ); assert!( diff --git a/crates/batten/tests/it/refusal_ceiling.rs b/crates/batten/tests/it/refusal_ceiling.rs new file mode 100644 index 000000000..e9de3348f --- /dev/null +++ b/crates/batten/tests/it/refusal_ceiling.rs @@ -0,0 +1,161 @@ +//! The emitted mediated line, measured against the declared ceiling +//! (CLOUD-1286). +//! +//! **Over the compiled binary, against the committed `batten.toml`.** A +//! `with input as` case or a fixture registry would fabricate the very thing +//! under test: the question is what an agent in THIS repository actually sees +//! when a real row refuses a real command, and a fixture answers about a tree +//! nobody works in. +//! +//! The discriminating pair is the whole file. The deny half is that a line over +//! the ceiling is reported; the allow half — anti-vacuity, and the load-bearing +//! one (CLOUD-418) — is that every refusal this repository can actually emit +//! passes. A ceiling that refuses correct output is a gate somebody switches +//! off, and the converted `no-tool-substitution` refusal is the specific line +//! the row names. + +// 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::PathBuf; + +use common::{run_with_stdin, stderr}; + +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +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}}}}}" + ) +} + +/// The refusal text a mediated call produces, or `None` where it was allowed. +fn refusal(command: &str) -> Option { + let run = run_with_stdin( + &root(), + &["hook", "--harness", "exit-code"], + &payload(command), + ); + if run.status.code() == Some(2) { + Some(stderr(&run).trim().to_owned()) + } else { + None + } +} + +/// The declared ceiling, read from the committed config rather than re-typed. +/// +/// Re-typing it here would make this suite pass over a `batten.toml` whose +/// ceiling had been raised or deleted, which is the whole failure the +/// `refusal-ceiling-raised` weakening exists to report. +fn declared_ceiling() -> 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(toml::Value::as_integer) + .expect("`[refusal] max_tokens` is declared"), + ) + .expect("a ceiling is not negative") +} + +/// `budget.rs`'s estimator, which is what the engine's own `Ceiling::over` uses. +fn estimated_tokens(line: &str) -> usize { + line.len() / 4 +} + +/// The mediated commands this repository's rows actually refuse, one per +/// composer that can fire from a Bash call. +/// +/// Not every declared class — the ones reachable from a mediated command line, +/// because those are what the ~300 firings a session are made of. +const CORPUS: &[&str] = &[ + // The row CLOUD-1286's acceptance names by name. + "sed -n '1,40p' AGENTS.md", + "head -40 batten.toml", + "cat .serena/project.yml", + // The three discard shapes. + "mise run verify | tail -1", + "mise run verify >log 2>&1; ls", + "nohup mise run verify &", +]; + +#[test] +fn every_mediated_refusal_this_tree_emits_is_within_the_declared_ceiling() { + // ANTI-VACUITY, and it is the case that decides whether the ceiling is a + // gate or a switch waiting to be flipped. If this fails, the answer is + // almost never to raise the number. + let ceiling = declared_ceiling(); + let mut over: Vec<(usize, String)> = Vec::new(); + for command in CORPUS { + let Some(line) = refusal(command) else { + panic!("the corpus must refuse, or it measures nothing: {command}"); + }; + let cost = estimated_tokens(&line); + if cost > ceiling { + over.push((cost, line)); + } + } + assert!( + over.is_empty(), + "every emitted line must be within the declared ceiling of {ceiling}: {over:?}" + ); +} + +#[test] +fn a_declared_refusal_emits_its_class_and_its_pointers_and_stops() { + // The acceptance, asserted on the shape rather than on the count: no + // `Refused by` prefix, no parenthetical gloss, no `Fix:` clause, and no + // 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}" + ); + for wrapper in ["Refused by", "Fix:", "Bypass with", " ("] { + assert!( + !line.contains(wrapper), + "the emitted line must not carry `{wrapper}`: {line}" + ); + } +} + +#[test] +fn no_refusal_lost_its_pointer() { + // The acceptance clause that keeps this from being achieved by saying less + // about WHICH file. The prose is what shortened; the pointer is what the + // reader acts on, and it stayed inline for exactly that reason. + let line = refusal("head -40 batten.toml").expect("the row refuses"); + assert!( + line.contains("batten.toml"), + "the operand a caller can act on stays inline: {line}" + ); +} + +#[test] +fn the_ceiling_can_fail() { + // CLOUD-418: a gate nobody has seen fail is a gate nobody knows works. The + // engine's own comparison is exercised here rather than the corpus above, + // 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"; + assert!( + estimated_tokens(&long) > ceiling, + "a line this long must be over the ceiling, or the comparison decides nothing" + ); + let short = refusal("nohup mise run verify &").expect("the row refuses"); + assert!( + estimated_tokens(&short) <= ceiling, + "and a real one must be under it: {short}" + ); +} diff --git a/crates/batten/tests/it/review_answered.rs b/crates/batten/tests/it/review_answered.rs index a6ddeee38..ab3519381 100644 --- a/crates/batten/tests/it/review_answered.rs +++ b/crates/batten/tests/it/review_answered.rs @@ -98,7 +98,7 @@ use crate::common; use std::fs; use std::path::{Path, PathBuf}; -use common::{at_root, run_with_stdin, scratch}; +use common::{at_root, run, run_with_stdin, scratch}; /// The declaration both rows carry, read out of this repository's own committed /// config. @@ -484,8 +484,22 @@ fn a_ready_with_no_record_at_all_is_refused_and_the_remedy_names_the_read() { let dir = repo("review-answered-no-record", &declared, true); let decision = ready(&dir); denied(&decision); - assert!(decision.contains(&declared.selector), "{decision}"); - assert!(decision.contains("get_review_comments"), "{decision}"); + // CLOUD-1286: the row's prose remedy is one hop from the rule id on the + // line. The point this case makes survives the move — what a reader reaches + // is still a ROUTE that can mint the record rather than a shell command no + // selector would accept. + assert!( + decision.contains("ready-needs-the-threads-answered"), + "{decision}" + ); + let explained = run( + &dir, + &["policy", "explain", "ready-needs-the-threads-answered"], + ); + assert_eq!(explained.status.code(), Some(0), "the row resolves"); + let routes = String::from_utf8_lossy(&explained.stdout); + assert!(routes.contains(&declared.selector), "{routes}"); + assert!(routes.contains("get_review_comments"), "{routes}"); } #[test] @@ -498,14 +512,11 @@ fn the_measured_shape_a_head_carrying_unresolved_threads_is_refused_naming_the_c let decision = ready(&dir); denied(&decision); assert!(decision.contains("review-unanswered"), "{decision}"); - // THE COUNT, as the typed ABI renders it: the token, its gloss, and the - // `Subject::Count` beside them. The retired case read `4 blocking` out of a - // free string; the number is the same and it is now a decoded subject. - assert!(decision.contains("review answer missing"), "{decision}"); - assert!( - decision.contains("unresolved review threads) 4"), - "{decision}" - ); + // THE COUNT, as the typed ABI renders it: the token and the `Subject::Count` + // 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}"); // 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}"); @@ -540,11 +551,8 @@ 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("unresolved review threads) 2"), - "{decision}" - ); - assert!(!decision.contains(") 5"), "{decision}"); + assert!(decision.contains("review answer missing 2"), "{decision}"); + assert!(!decision.contains("review answer missing 5"), "{decision}"); } // --- the conditions the projection carried, restored ------------------------ @@ -567,10 +575,7 @@ 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("unresolved review threads) 1"), - "{decision}" - ); + assert!(decision.contains("review answer missing 1"), "{decision}"); } #[test] @@ -585,10 +590,7 @@ 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("unresolved review threads) 3"), - "{decision}" - ); + assert!(decision.contains("review answer missing 3"), "{decision}"); } #[test] @@ -603,8 +605,10 @@ fn vacuity_zero_threads_and_no_review_reads_as_unreviewed_not_as_all_addressed() record_reviews(&dir, &declared, &reviews(0)); let decision = ready(&dir); denied(&decision); + // CLOUD-1286: the class is what says nobody has reviewed, and the gloss + // that used to spell it out is one `batten policy explain` away. assert!(decision.contains("review read absent"), "{decision}"); - assert!(decision.contains("nobody has reviewed"), "{decision}"); + assert!(!decision.contains("nobody has reviewed"), "{decision}"); } #[test] @@ -636,7 +640,9 @@ fn two_rows_sharing_a_selector_each_record_from_their_own_result() { record_reviews(&dir, &declared, &reviews(2)); let half = ready(&dir); denied(&half); - assert!(half.contains("get_review_comments"), "{half}"); + // The threads check is the one still Missing, and its NAME is the pointer + // on the line; the read that mints it is one hop away (CLOUD-1286). + assert!(half.contains("review-threads-clear"), "{half}"); record_threads(&dir, &declared, &threads(false, &[true])); allowed(&ready(&dir)); @@ -668,7 +674,19 @@ fn a_sibling_method_answering_the_same_shape_is_not_a_review() { decision.contains("ready-needs-a-review-to-exist"), "{decision}" ); - assert!(decision.contains("get_reviews"), "{decision}"); + // The remedy naming the RIGHT method is one hop away (CLOUD-1286), and it is + // the half worth reaching for here: this whole case is about a sibling + // method being counted as a review, so a remedy pointing at the wrong one + // would be the same defect in the fix. + let explained = run( + &dir, + &["policy", "explain", "ready-needs-a-review-to-exist"], + ); + assert_eq!(explained.status.code(), Some(0), "the row resolves"); + assert!( + String::from_utf8_lossy(&explained.stdout).contains("get_reviews"), + "{decision}" + ); } #[test] @@ -698,7 +716,9 @@ fn vacuity_a_result_that_is_not_the_declared_shape_records_nothing_rather_than_o reviewed(&dir, &declared); let decision = ready(&dir); denied(&decision); - assert!(decision.contains("get_review_comments"), "{decision}"); + // The did-you-look refusal stands, and the CHECK it names is the pointer + // — the read that mints it is one hop away (CLOUD-1286). + assert!(decision.contains("review-threads-clear"), "{decision}"); } #[test] @@ -774,10 +794,7 @@ 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("unresolved review threads) 2"), - "{decision}" - ); + assert!(decision.contains("review answer missing 2"), "{decision}"); } #[test] @@ -854,12 +871,19 @@ fn an_undeclared_class_refuses_with_the_token_and_says_the_registry_is_silent() reviewed(&dir, &declared); let decision = ready(&dir); denied(&decision); + // CLOUD-1286: the line is the token and its pointers, so an undeclared class + // renders as ITSELF plus the count and gets no composed apology. That is the + // honest answer rather than a regression — the token is the thing a reader + // looks up, and saying "the registry is silent" on every firing would be a + // sentence about the config paid for on the hot path. assert!(decision.contains("review answer missing"), "{decision}"); assert!( - decision.contains("no `[[verdict]]` row declares"), + !decision.contains("no `[[verdict]]` row declares"), "{decision}" ); - assert!(decision.contains(") 3"), "{decision}"); + // 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}"); } /// The rows that judge the call: ONE RECEIPT ROW PER CHECK, as the committed diff --git a/crates/batten/tests/it/run_shape.rs b/crates/batten/tests/it/run_shape.rs index 451664a2d..26e7f248e 100644 --- a/crates/batten/tests/it/run_shape.rs +++ b/crates/batten/tests/it/run_shape.rs @@ -559,8 +559,13 @@ fn the_refusal_names_its_predicate_its_class_and_the_route_out() { text.contains("commit write missing"), "the declared class: {text}" ); + // The route is one hop from the class on the line (CLOUD-1286), and this + // still asserts it comes off the CLASS rather than out of the module's + // prose — which is what the `changed:` arm above records. + let explained = common::run(&root, &["policy", "explain", "commit write missing"]); + assert_eq!(explained.status.code(), Some(0), "the class resolves"); assert!( - text.contains("git commit -F "), + String::from_utf8_lossy(&explained.stdout).contains("git commit -F "), "the route out, taken off the class rather than out of the module: {text}" ); } diff --git a/crates/batten/tests/it/task_receipt.rs b/crates/batten/tests/it/task_receipt.rs index 35d85916d..1a9626e5c 100644 --- a/crates/batten/tests/it/task_receipt.rs +++ b/crates/batten/tests/it/task_receipt.rs @@ -384,9 +384,12 @@ fn a_hand_written_row_outranks_a_module() { let answer = String::from_utf8_lossy(&outcome.stdout); let said = format!("{answer}{cause}"); + // CLOUD-1286: WHICH row answered is read off the id on the line rather than + // off its remedy, and that is the stricter test of precedence anyway — a + // remedy is prose two rows could share, an id is not. assert!( - said.contains("mise exec -- probe-tool"), - "the hand-written row's own remedy is what the reader must see: {said}" + said.contains("probe-pinned"), + "the hand-written row is what answered: {said}" ); assert!( !said.contains("task argv probe"), diff --git a/hk.pkl b/hk.pkl index 4937c4611..bdf26641e 100644 --- a/hk.pkl +++ b/hk.pkl @@ -450,6 +450,7 @@ local gate = new Mapping { "crates/batten/src/prune.rs", "crates/batten/src/recorder.rs", "crates/batten/src/redirect.rs", + "crates/batten/src/refusal.rs", "crates/batten/src/rules.rs", "crates/batten/src/severity.rs", "crates/batten/src/transcript.rs", diff --git a/mise.toml b/mise.toml index a11a7a458..e89dc6608 100644 --- a/mise.toml +++ b/mise.toml @@ -1064,6 +1064,11 @@ description = "Gate: every verdict vocabulary word is one token under the declar run = "cargo nextest run -p batten --no-tests=fail -E 'binary(it) & test(/^verdict_vocabulary::/)'" env = { BATTEN_TEST_SCRATCH_LANE = "narrow" } +[tasks."test:refusal-ceiling"] +description = "Gate: every emitted mediated refusal line is within the declared `[refusal]` ceiling (CLOUD-1286)" +run = "cargo nextest run -p batten --no-tests=fail -E 'binary(it) & test(/^refusal_ceiling::/)'" +env = { BATTEN_TEST_SCRATCH_LANE = "narrow" } + [tasks."test:config-deprecations"] description = "Gate: no config key left the published schema without a deprecation window (CLOUD-360)" run = "cargo nextest run -p batten --no-tests=fail -E 'binary(it) & test(/^config_deprecations::/)'" diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 7b533557c..d90e1549b 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -267,6 +267,17 @@ "$ref": "#/$defs/Redirect" } }, + "refusal": { + "description": "What ONE emitted mediated refusal line may cost (CLOUD-1286). Absent\nmeans no ceiling is declared and none is enforced, on the same reading as\n`[budget]` above. The type and the predicate are [`crate::refusal`].", + "anyOf": [ + { + "$ref": "#/$defs/Ceiling" + }, + { + "type": "null" + } + ] + }, "rule": { "description": "The declarative rules run against the repository. Absent or empty means\n\"no rules configured\" and nothing is reported. Which of these a given\nverb admits is the §5 effect split: `check` runs only non-spawning kinds\nand refuses the rest, `enforce` runs all of them (CLOUD-170).\n\nEvery rule pins its `severity` explicitly — the key is required, with no\nimplicit fallback — and carries a separate `scope` key whose vocabulary\nnever conflates with severity's (CLOUD-61). Both disciplines are\nenforced at parse time: omission or conflation is a usage error here,\nnever a value quietly assumed.", "type": "array", @@ -589,6 +600,22 @@ "reduce" ] }, + "Ceiling": { + "description": "The `[refusal]` table: what one emitted mediated line may cost.\n\n**Declared, never a literal in the crate** (non-negotiable rule 2, and the\nsame reasoning `[budget.instructions]` is built on): a ceiling written into\n`crates/batten` is this repository's judgement compiled into every consumer's\nengine, and a consumer whose harness renders differently could not move it\nwithout a release. [`crate::budget::BudgetSet`] is the landed shape this\ncopies — a ceiling and nothing else, absent meaning unenforced, because a\nthreshold nobody declared is not a threshold of zero.\n\nThe unit is **estimated tokens**, on `budget.rs`'s own bytes-per-token\nconvention rather than a tokenizer: this is a ceiling on a line, checked off\nthe hot path, and a real BPE pass here would be the dependency CLOUD-1284\ndeliberately kept to `[dev-dependencies]`.\n\nIt is not [`crate::verdict`]'s `GLOSS_MAX`, which stays. That bounds one\nFIELD — the gloss `explain` prints — and this bounds the emitted LINE. Both\nexist for the same reason and neither substitutes for the other: with the\ngloss off the hot path, `GLOSS_MAX` is what stops it growing back into a\nparagraph where nothing measures it.", + "type": "object", + "properties": { + "max_tokens": { + "description": "The ceiling on estimated tokens for ONE emitted mediated refusal line.\nThe boundary is `<=`: exactly at budget passes, matching\n[`crate::budget::Report::over_budget`] so the two thresholds in this tree\ndo not disagree about their own edge.", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_tokens" + ] + }, "CeilingUnit": { "description": "What a [`Rule::max`] ceiling counts over its declared projection (CLOUD-925).\n\nTwo units rather than one, because `fanout-guard`'s two conjuncts measure the\nsame bytes and differ in the *subject* of the cap: the prompt's own size, and\nhow many tracked artifacts it names. A single unit would have forced one of\nthem into a second rule kind.\n\n**The unit cannot be inferred from the projection**, which is why this is a\ncolumn rather than a derivation: one prompt has both a token count and a\nmanifest count, and a row has to say which one it is about.", "oneOf": [ From f7539e0c52f3071c4823f2b6775dffc51cb72a54 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 14:45:33 +0000 Subject: [PATCH 06/20] fix(hook): verb and operand attribution stops at a newline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A newline is whitespace to `segments`, so a call written across lines was ONE segment and `effective_program` resolved the first line's program for every operand on every line. Measured over the shipped binary, one protected path and the same read twice: `stat -c %s batten.toml` allowed, and the identical `stat` written on line two after `cd /tmp` REFUSED, naming `cd`. So a declared `protected_readers` entry was unreachable from any script, which is the surface `protected_readers` exists for — and the failure is an OVER-deny, on a read, which is the direction that gets a guard switched off rather than the sanctioned one. NARROW ON PURPOSE. Segment identity is untouched: promoting a newline in `segments` would move every landed `pipeline` verdict, since `terminator` is what those rows are decided by. Only the two walks that ask "which program was handed this operand" — the mutation walk and CLOUD-1141's unknown-program walk — read line boundaries, because that is a question a line answers and a segment does not. `line_bounded_words` re-enters `segments` per line rather than splitting the string itself: a second tokenizer is a second AUTHORITY (CLOUD-857) and would disagree with the one `shape` and `pipeline` rows are decided by. Re-entering is safe because a segment's `raw` carries no separator by construction, and heredoc bodies are already gone from it (CLOUD-723), so a `rm` inside a commit message does not become a line of its own. The single-line case returns before any of it. Four cases over the compiled binary and the committed table: the measured pair, a genuine mutation on a later line still refused, an unknown program on a later line still refused — the two discriminators that keep the fix from being a blanket allow — and the bound itself, a discard shape written across lines still judged as one segment. `.claude/rules/policy-modules.md`'s "it under-denies, which is the sanctioned direction" is corrected against the measurement, per the row's acceptance: the prose and the parser must not disagree about which way the bound errs. Refs: CLOUD-1287 --- .claude/rules/policy-modules.md | 22 +- crates/batten/src/hook.rs | 253 ++++++++++++++--------- crates/batten/tests/it/mediated_verbs.rs | 50 +++++ 3 files changed, 224 insertions(+), 101 deletions(-) diff --git a/.claude/rules/policy-modules.md b/.claude/rules/policy-modules.md index da353a145..9aaaf2ec9 100644 --- a/.claude/rules/policy-modules.md +++ b/.claude/rules/policy-modules.md @@ -241,7 +241,27 @@ A **newline is whitespace, not a separator** — bash disagrees, and the bound i deliberate rather than an oversight: promoting it would change every landed `pipeline` verdict. So the shell following a heredoc's terminator joins the segment its opener was written in, and a two-command call written across lines is -judged as one. It under-denies, which is the sanctioned direction. +judged as one segment. + +**AND "it under-denies, which is the sanctioned direction" IS MEASURED +BACKWARDS** (CLOUD-1287). That sentence stood here and was false of the arm that +matters most: one segment means `effective_program` resolves the FIRST line's +program for every operand on every line, so a declared `protected_readers` entry +was unreachable from any script. Measured over the shipped binary, one protected +path, the same read twice: `stat -c %s batten.toml` allowed, and the identical +`stat` written on line two after `cd /tmp` REFUSED, naming `cd`. That is an +OVER-deny, on a read, which is the direction that gets a guard switched off +rather than the sanctioned one. + +The bound above still holds for segment identity — `terminator` is unmoved and no +landed `pipeline` verdict changed. What changed is narrower and lives in the +engine rather than in a module: `hook::line_bounded_words` splits a segment's own +`raw` at newlines and re-enters `segments` per line, and only the mutation walk +and the unknown-program walk read it. Both ask "which program was handed this +operand", a question a line answers and a segment does not. So a module reading +`input.call.segments` sees exactly what it saw before, and must not grow its own +line splitting to compensate — that would be the second authority two sections +up already refuses. There is **one parser**, and a module must not grow a second: no `split` of the command line, in Rego or in Rust. **The reason is not effort, and giving it as diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 2d0eff082..4145f82f5 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -6361,116 +6361,169 @@ fn protected_tool_write(policy: &Policy, envelope: &Envelope) -> Decision { )) } +/// One segment's words, split where the caller wrote a NEWLINE (CLOUD-1287). +/// +/// # The defect +/// +/// A newline is whitespace to [`segments`], so a script written across lines is +/// one segment and `effective_program` resolves the FIRST line's program for the +/// whole thing. Measured over the shipped binary, one protected path and two +/// spellings of the same read: `stat -c %s batten.toml` allowed, and the +/// identical `stat` written on line two after `cd /tmp` REFUSED, naming `cd` — +/// so a declared `protected_readers` entry is unreachable from any script, which +/// is the surface `protected_readers` exists for. +/// +/// That also makes `.claude/rules/policy-modules.md`'s "it under-denies, which +/// is the sanctioned direction" measurably backwards for this arm: it +/// OVER-denies, on a read, which is the direction that gets a guard switched +/// off. The prose is corrected in the same change. +/// +/// # NARROW ON PURPOSE, and this is the whole of the narrowing +/// +/// Segment identity is untouched: promoting a newline to a separator in +/// [`segments`] would change every landed `pipeline` verdict, and `terminator` +/// is what those rows are decided by. Only the two arms below — the mutation +/// walk and the unknown-program walk — stop at a line, because both are asking +/// "which program was handed this operand", a question a line answers and a +/// segment does not. +/// +/// # There is still ONE parser +/// +/// Each line goes back through [`segments`] rather than through a `split` of any +/// kind. A second tokenizer here is a second AUTHORITY (CLOUD-857), and it would +/// disagree with the one `shape` and `pipeline` rows are decided by over exactly +/// the quoting cases neither author had in mind. Re-entering is safe because a +/// segment's `raw` carries no separator by construction — it is what the parser +/// split ON — so each line yields at most one sub-segment. +/// +/// Heredoc bodies are already gone from `raw`, which is what keeps a `rm` inside +/// a commit message from becoming a line of its own here (CLOUD-723). +fn line_bounded_words(segment: &Segment) -> Vec> { + // The common case is one line, and it must cost nothing: `batten hook` runs + // on every mediated call under CLOUD-689's budget. + if !segment.raw.contains('\n') { + return vec![segment.words.clone()]; + } + segment + .raw + .lines() + .flat_map(|line| segments(line).into_iter().map(|parsed| parsed.words)) + .filter(|words| !words.is_empty()) + .collect() +} + fn protected_mutation(policy: &Policy, command: &str) -> Decision { for segment in segments(command) { - let tokens: Vec<&str> = segment.words.iter().map(String::as_str).collect(); - // Operands of the effective program, plus any redirect target. Both are - // candidates; a redirect needs no program at all. - let mut candidates: Vec> = Vec::new(); - if let Some(index) = effective_program(&tokens) { - let program = tokens[index]; - // The row is resolved ONCE per segment, from the program and its - // arguments together (CLOUD-442). Before this the lookup was by - // program alone, so a program that mutates under one subcommand or - // behind one flag could only be declared as mutating under all of - // them — which is why five write shapes could not be expressed. - if let Some(matched) = - crate::verbs::qualify(&policy.verbs, program, &tokens[index + 1..]) - { - let operands = operands(&tokens, index + 1 + matched.consumed); - // `Last` is the destination-only narrowing. An empty operand - // list has no last element and therefore no target, which is the - // same answer as before for a program invoked with none. - let targets: &[&str] = match matched.operands { - OperandScope::All => &operands, - OperandScope::Last => operands.last().map_or(&[], std::slice::from_ref), - }; - for path in targets { + for words in line_bounded_words(&segment) { + let tokens: Vec<&str> = words.iter().map(String::as_str).collect(); + // Operands of the effective program, plus any redirect target. Both are + // candidates; a redirect needs no program at all. + let mut candidates: Vec> = Vec::new(); + if let Some(index) = effective_program(&tokens) { + let program = tokens[index]; + // The row is resolved ONCE per segment, from the program and its + // arguments together (CLOUD-442). Before this the lookup was by + // program alone, so a program that mutates under one subcommand or + // behind one flag could only be declared as mutating under all of + // them — which is why five write shapes could not be expressed. + if let Some(matched) = + crate::verbs::qualify(&policy.verbs, program, &tokens[index + 1..]) + { + let operands = operands(&tokens, index + 1 + matched.consumed); + // `Last` is the destination-only narrowing. An empty operand + // list has no last element and therefore no target, which is the + // same answer as before for a program invoked with none. + let targets: &[&str] = match matched.operands { + OperandScope::All => &operands, + OperandScope::Last => operands.last().map_or(&[], std::slice::from_ref), + }; + for path in targets { + candidates.push(Target { + program, + subcommand: matched.verb.subcommand.as_deref(), + path, + redirect: matched.verb.redirect.as_deref(), + }); + } + } + } + // A redirect is a pseudo-program with no argv of its own, so it carries + // no qualifier and is looked up by name. + for (operator, path) in redirect_targets(&tokens) { + if let Some(verb) = crate::verbs::classify(&policy.verbs, operator) { candidates.push(Target { - program, - subcommand: matched.verb.subcommand.as_deref(), + program: operator, + subcommand: None, path, - redirect: matched.verb.redirect.as_deref(), + redirect: verb.redirect.as_deref(), }); } } - } - // A redirect is a pseudo-program with no argv of its own, so it carries - // no qualifier and is looked up by name. - for (operator, path) in redirect_targets(&tokens) { - if let Some(verb) = crate::verbs::classify(&policy.verbs, operator) { - candidates.push(Target { - program: operator, - subcommand: None, - path, - redirect: verb.redirect.as_deref(), - }); - } - } - for target in candidates { - if !protects(policy, target.path) { - continue; + for target in candidates { + if !protects(policy, target.path) { + continue; + } + return Decision::Deny(protected_refusal(&policy.redirects, &target)); } - return Decision::Deny(protected_refusal(&policy.redirects, &target)); - } - // THE UNKNOWN PROGRAM, WHICH USED TO FALL THROUGH TO ALLOW (CLOUD-1141). - // - // Everything above decides by NAMING the program: `verbs` enumerates - // mutations, so a program it does not name produced no candidate and the - // loop ended here allowing. Measured over the shipped binary, one - // protected path and five spellings of writing it: `echo x >>`, `sed -i` - // and `tee` denied; `python3 -c "open(...,'w')"` and `perl -pi -e` - // ALLOWED. An allowlist-by-omission whose omissions are holes, and the - // gate `memory-guard` was retired into (CLOUD-442). - // - // The direction is now inverted rather than the list extended. Adding the - // measured interpreters would close two instances and leave the shape — - // the next one is unrefused and the table would imply a completeness it - // does not have. So an operand that is a protected path refuses unless - // the program is KNOWN, and known means one of two things: - // - // * it appears in `verbs` at all — the table encodes that program's - // argv grammar, so a non-matching invocation is a considered allow - // rather than an absence. `git add batten.toml` stays allowed because - // `git`'s mutating rows did not match, not because nobody looked. - // * it appears in `protected_readers` — declared to only read. - // - // Forgetting a reader is now a false refusal somebody fixes in a minute. - // Forgetting a writer is no longer a silent hole. That asymmetry is the - // whole change; the enumeration did not get longer, it got turned round. - if let Some(index) = effective_program(&tokens) { - let program = tokens[index]; - let known = policy - .protected_readers - .iter() - .any(|reader| reader == program) - || policy.verbs.iter().any(|verb| verb.verb == program); - if !known { - // OPERANDS, AND THE WIDER SCAN WAS TRIED AND REVERTED. Scanning - // every word for an embedded protected path catches - // `python3 -c "open('p','w')"` — the shape an agent actually - // reaches for — and it also refuses any unclassified program that - // merely MENTIONS a guarded path. Measured immediately: a `for` - // loop iterating probe commands was refused because one of its - // quoted words contained `batten.toml`. `echo "see batten.toml"` - // is the same shape. - // - // That is disqualifying rather than merely noisy. A guard that - // refuses ordinary mentions is one people switch off within a - // day, which is how this class of guard dies — the row that - // demanded this fix says so in as many words. An operand is a - // thing the program was handed; a substring of a quoted argument - // is not, and argv cannot tell a path being WRITTEN inside an - // interpreter's program text from one being TALKED ABOUT. - for path in operands(&tokens, index + 1) { - // CLOUD-1141's arm asks the same membership question, so it - // had the same hole: an absolute operand was not recognised as - // protected here either, and the unknown program was allowed - // through the branch built to refuse it (CLOUD-1236). - if protects(policy, path) { - return Decision::Deny(unknown_program_refusal(program, path)); + // THE UNKNOWN PROGRAM, WHICH USED TO FALL THROUGH TO ALLOW (CLOUD-1141). + // + // Everything above decides by NAMING the program: `verbs` enumerates + // mutations, so a program it does not name produced no candidate and the + // loop ended here allowing. Measured over the shipped binary, one + // protected path and five spellings of writing it: `echo x >>`, `sed -i` + // and `tee` denied; `python3 -c "open(...,'w')"` and `perl -pi -e` + // ALLOWED. An allowlist-by-omission whose omissions are holes, and the + // gate `memory-guard` was retired into (CLOUD-442). + // + // The direction is now inverted rather than the list extended. Adding the + // measured interpreters would close two instances and leave the shape — + // the next one is unrefused and the table would imply a completeness it + // does not have. So an operand that is a protected path refuses unless + // the program is KNOWN, and known means one of two things: + // + // * it appears in `verbs` at all — the table encodes that program's + // argv grammar, so a non-matching invocation is a considered allow + // rather than an absence. `git add batten.toml` stays allowed because + // `git`'s mutating rows did not match, not because nobody looked. + // * it appears in `protected_readers` — declared to only read. + // + // Forgetting a reader is now a false refusal somebody fixes in a minute. + // Forgetting a writer is no longer a silent hole. That asymmetry is the + // whole change; the enumeration did not get longer, it got turned round. + if let Some(index) = effective_program(&tokens) { + let program = tokens[index]; + let known = policy + .protected_readers + .iter() + .any(|reader| reader == program) + || policy.verbs.iter().any(|verb| verb.verb == program); + if !known { + // OPERANDS, AND THE WIDER SCAN WAS TRIED AND REVERTED. Scanning + // every word for an embedded protected path catches + // `python3 -c "open('p','w')"` — the shape an agent actually + // reaches for — and it also refuses any unclassified program that + // merely MENTIONS a guarded path. Measured immediately: a `for` + // loop iterating probe commands was refused because one of its + // quoted words contained `batten.toml`. `echo "see batten.toml"` + // is the same shape. + // + // That is disqualifying rather than merely noisy. A guard that + // refuses ordinary mentions is one people switch off within a + // day, which is how this class of guard dies — the row that + // demanded this fix says so in as many words. An operand is a + // thing the program was handed; a substring of a quoted argument + // is not, and argv cannot tell a path being WRITTEN inside an + // interpreter's program text from one being TALKED ABOUT. + for path in operands(&tokens, index + 1) { + // CLOUD-1141's arm asks the same membership question, so it + // had the same hole: an absolute operand was not recognised as + // protected here either, and the unknown program was allowed + // through the branch built to refuse it (CLOUD-1236). + if protects(policy, path) { + return Decision::Deny(unknown_program_refusal(program, path)); + } } } } diff --git a/crates/batten/tests/it/mediated_verbs.rs b/crates/batten/tests/it/mediated_verbs.rs index 1bd7f39b5..843767611 100644 --- a/crates/batten/tests/it/mediated_verbs.rs +++ b/crates/batten/tests/it/mediated_verbs.rs @@ -717,3 +717,53 @@ fn the_normalised_write_target_uses_forward_slashes_on_every_platform() { spells it with — a rendered `\\` matches no repo-relative glob" ); } + +// --- CLOUD-1287: verb/operand attribution stops at a newline ------------------ +// +// A newline is whitespace to the tokenizer, so a script written across lines was +// ONE segment and `effective_program` resolved the first line's program for all +// of it. `protected_readers` was therefore unreachable from any script, which is +// the surface it exists for. +// +// Narrow on purpose: segment identity is untouched, so no landed `pipeline` +// verdict moves. Only the mutation walk and the unknown-program walk stop at a +// line. + +#[test] +fn a_declared_reader_is_consulted_whatever_precedes_it_on_an_earlier_line() { + // THE MEASURED PAIR, and it is the whole defect: the same read, once alone + // and once on line two. Before this the second refused, naming `cd` — a + // false refusal on a READ, which is the direction that gets a guard switched + // off rather than the sanctioned one. + assert_allowed(&format!("stat -c %s {AUTHORITY}")); + assert_allowed(&format!("cd /tmp\nstat -c %s {AUTHORITY}")); +} + +#[test] +fn a_genuine_mutation_on_a_later_line_is_still_refused() { + // THE DISCRIMINATOR, without which the fix above is a blanket allow for + // every multi-line call. Line one is innocuous; line two is a declared + // mutation of a protected path and must still be refused. + assert_denied(&format!("cd /tmp\nrm {GUARDED}")); + assert_denied(&format!("echo starting\nsed -i s/a/b/ {AUTHORITY}")); +} + +#[test] +fn an_unknown_program_on_a_later_line_is_still_refused() { + // The other arm the narrowing touches (CLOUD-1141's inversion). A program + // neither table names, handed a protected path, refuses — and it must keep + // refusing when the call is written across lines rather than becoming an + // operand of whatever ran first. + assert_denied(&format!("echo starting\nperl -pi -e s/a/b/ {AUTHORITY}")); +} + +#[test] +fn a_newline_did_not_become_a_separator() { + // THE BOUND, asserted rather than assumed. Promoting a newline in + // `segments` would have changed every landed `pipeline` verdict, so the case + // that would notice is a discard shape written across lines: it is still ONE + // segment, so the pager still discards the verdict and the call is still + // refused. A newline-as-separator would have made these two commands and + // allowed the first. + assert_denied("mise run verify\n| tail -1"); +} From 80a1f818205184339b8c7079ac08a1a0627489df Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 14:54:30 +0000 Subject: [PATCH 07/20] fix(hook): resolve a relative operand against the caller's cwd, and stop asserting tracked-ness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent defects in one row, reproduced twice on 2026-08-28 from a scratch directory outside the repository: cat err.txt -> refused, "a path in this repository" cat /err.txt -> the identical file, allowed ONE: `repo_relative_path` is purely lexical. It asks whether a token is SHAPED like a relative path and calls that "inside the repository", so every path the guard judged was judged against a root the caller may not have been in — the corpus it thought it was protecting and the one it was reading were different sets. `Envelope::cwd` has been decoded since CLOUD-202 and was read by nothing. `names_a_repository_path` joins the operand to it and asks `relative_to`, the same containment primitive `protects` already uses, so the two readers cannot disagree the way two resolvers would (CLOUD-824's class). No stat, no spawn: decidable from the payload. The bound: an ABSOLUTE operand stays excluded rather than resolved and refused. Resolving both spellings would be tidier and would WIDEN what is refused, and this change only ever narrows, so no call allowed today starts failing. An unknown cwd keeps the lexical reading rather than switching clause 3 off for that host — a gate that found nothing must not look like a gate that passed. TWO: the verdict prose asserted the repository TRACKS the path, and nothing ever asked git. A `git ls-files` per mediated call is a spawn `RuleKind::scopes` forbids on this kind and `perf-assert` prices out, so the fix is to stop claiming it rather than to check it. The class and the row's remedy now say CONTAINMENT, which is what the predicate decides. A class a reader believes is worse than one they cannot look up. Three cases over the compiled binary: the pair that is the whole defect, red against the unfixed binary; a relative path naming a file the repository DOES contain, from a subdirectory, still refused — the discriminator that keeps the fix from being a blanket allow; and a verdict-text assertion that no tracked-ness claim survives. Two stale doc claims corrected in passing, both asserting "`Envelope` carries no `cwd`" while the field sat decoded three lines away. A stale reason is worse than none: the next author reads it as a constraint and designs around a field that was there all along. Admits: 9eddfef5a9c49d424ff3fcc8688776db2d781a9a69f76c5bab60f127f3ae28da Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 89b5336d67708709049d63e3459d4d89af6ac157 Admits-epoch: a98253f1c6f8b3e2ed67efebd8cd04d45e890cc1eb144a813bc8037049cb831c Admits-author: alec@wenzowski.com Admits-prev: c561ee2b9fea964bceb46a3d0caa983f67d9bb3d8242635ff8cf944b24e578b5 Admits-answer-lost: The second half of CLOUD-1109. The engine half lands either way, but the refusal would keep telling a reader the repository TRACKS a file nothing asked git about — and `.claude/rules/policy-modules.md` makes a verdict a thing a reader looks up, so a class whose prose asserts an unchecked fact about its subject is worse than an unnameable one, because the reader believes it. Fixing only the resolution leaves exactly that. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the object IS a string inside `batten.toml`: the `no-tool-substitution` row's `reason` asserts "a path this repository tracks", a fact CLOUD-1109 establishes nothing ever checked. The remedy prose is consumer config by design (non-negotiable rule 1), so there is no non-protected path that carries it. The write is one word, `tracks` to `contains`, in a diff a reviewer sees on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the row, and reading it is what found the claim — reading further produces no route that corrects the string. `patch run first` does not apply either: a patch to `batten.toml` is still a write to `batten.toml`, so it reaches this same class one indirection later. Refs: CLOUD-1109 --- batten.toml | 2 +- crates/batten/src/hook.rs | 112 +++++++++++++++++++---- crates/batten/src/verdict.rs | 9 +- crates/batten/tests/it/mediated_verbs.rs | 85 +++++++++++++++++ 4 files changed, 189 insertions(+), 19 deletions(-) diff --git a/batten.toml b/batten.toml index 90c7b0bd1..b22da3808 100644 --- a/batten.toml +++ b/batten.toml @@ -2244,7 +2244,7 @@ substitutes = ["cat", "head", "tail", "sed:-n", "grep", "rg", "find", "ls", "wc" # is invariant; which instrument answers them is whatever the session has. reason = """ Reach for the structured surface this session offers rather than a text utility \ -over a path this repository tracks: a range of one file's contents, a pattern \ +over a path this repository contains: a range of one file's contents, a pattern \ across the tree, paths by glob, or what a NAME resolves to. \ `.claude/rules/scanning.md` picks between those question classes. Downstream of \ a pipe these same utilities are filters over another command's output and are \ diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 4145f82f5..3b9e601db 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -1955,10 +1955,17 @@ impl Operation { /// shell-shaped, and is **never emitted**: a tool input is among the likeliest /// places in the engine for a secret to appear (rule 4). /// -/// Stated limit: `cwd` is decoded but not yet consumed, so an absolute or `..` -/// path operand is still compared as written. Resolving one against the repo -/// root is a behaviour change with its own issue, not a side effect of carrying -/// the field the host shims need. +/// `cwd` IS consumed, and by exactly one reader (CLOUD-1109): +/// [`names_a_repository_path`], where a RELATIVE operand is resolved against the +/// call's own working directory before clause 3 asks whether the repository +/// contains it. Before that it was decoded and read by nothing, so a bare +/// relative path was judged as though every call ran from the repository root — +/// and one file named relatively and absolutely from one directory got opposite +/// verdicts. +/// +/// The bound that survives: an ABSOLUTE operand is still excluded rather than +/// resolved and refused. Widening clause 3 to cover it would make a call that is +/// allowed today start failing, which the row this fix comes from rules out. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] pub struct Envelope { @@ -3920,7 +3927,7 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis // hoisted rows above are first: a row a reviewer wrote by hand should be the // one they see quoted back, and its reason is more specific than the generic // path-class message. - match pipeline_rules(policy, &envelope.command) { + match pipeline_rules(policy, envelope) { decided @ (Decision::Deny(_) | Decision::Ask(_)) => decided, Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => { match receipt_rules(policy, envelope, receipts) { @@ -4674,7 +4681,7 @@ enum Discard { /// `&&` is absent by construction rather than by exclusion: it is the one /// separator that preserves a non-zero status, so there is no false green to /// refuse and denying it would be a pure false positive. -fn pipeline_rules(policy: &Policy, command: &str) -> Decision { +fn pipeline_rules(policy: &Policy, envelope: &Envelope) -> Decision { let rows: Vec<&Rule> = policy .shapes .iter() @@ -4685,12 +4692,22 @@ fn pipeline_rules(policy: &Policy, command: &str) -> Decision { if rows.is_empty() { return Decision::Allow; } - let parsed = segments(command); + let parsed = segments(&envelope.command); for rule in rows { // The substitution family (CLOUD-864), judged first because it decides // over the same parse and shares nothing else with the discard family. + // + // THE WHOLE ENVELOPE RATHER THAN ITS `command` (CLOUD-1109): clause 3 is + // a question about WHERE a relative operand resolves, and the answer is + // the call's own working directory. It was decoded and unconsumed. if let Some(substitutes) = rule.substitutes.as_deref() - && let Some(refusal) = substitution_decision(rule, substitutes, &parsed) + && let Some(refusal) = substitution_decision( + rule, + substitutes, + &parsed, + policy.root.as_deref(), + envelope.cwd.as_deref(), + ) { return Decision::Deny(refusal); } @@ -4778,6 +4795,8 @@ fn substitution_decision( rule: &Rule, substitutes: &[String], parsed: &[Segment], + root: Option<&Path>, + cwd: Option<&Path>, ) -> Option { for (index, segment) in parsed.iter().enumerate() { let tokens: Vec<&str> = segment.words.iter().map(String::as_str).collect(); @@ -4804,7 +4823,7 @@ fn substitution_decision( let Some(target) = tokens[program_index + 1..] .iter() .take_while(|token| !token.contains('>') && !token.contains('<')) - .find(|token| !token.starts_with('-') && repo_relative_path(token)) + .find(|token| !token.starts_with('-') && names_a_repository_path(token, root, cwd)) else { continue; }; @@ -4911,6 +4930,54 @@ fn repo_relative_path(token: &str) -> bool { token.contains('/') || Path::new(token).extension().is_some() } +/// Clause 3, resolved against the CALLER'S working directory (CLOUD-1109). +/// +/// # The defect +/// +/// [`repo_relative_path`] is purely lexical: it asks whether a token is SHAPED +/// like a relative path and calls that "inside the repository". Reproduced twice +/// on 2026-08-28, with cwd a scratch directory outside the repository: +/// `cat err.txt` refused, and the identical file named absolutely allowed. So the +/// corpus the guard thought it was protecting and the one it was reading were +/// different sets, and a transient scratch file was refused with a verdict +/// asserting the repository contained it. +/// +/// # `cwd` was decoded and unconsumed, which is the whole of the fix +/// +/// The harness supplies it, [`Envelope::cwd`] carries it, and [`Field::Cwd`] +/// already reads it. Joining the operand to it and asking [`relative_to`] the +/// one containment question the engine already owns makes the two spellings of +/// one file agree, without a `stat`, a spawn, or a second root resolver +/// (CLOUD-824's class). +/// +/// # ABSOLUTE STAYS EXCLUDED, and that bound is deliberate +/// +/// Resolving both spellings and refusing whichever lands inside the repository +/// would be tidier and would WIDEN what is refused — `cat /AGENTS.md` is +/// allowed today. The row is explicit that this change only ever narrows, so no +/// call that is allowed today starts failing, and `>/tmp/x.log` — the shape +/// `verdict-not-discarded` mandates — keeps its exclusion for free. +/// +/// # An unknown cwd keeps today's answer rather than switching the gate off +/// +/// A host that sends no `cwd` leaves nothing to resolve against, and reading +/// that as "outside" would silently disable clause 3 for that host — a gate that +/// found nothing looking exactly like a gate that passed. So the lexical reading +/// stands where there is nothing better, which is the same could-not-look +/// posture the rest of this module takes. +fn names_a_repository_path(token: &str, root: Option<&Path>, cwd: Option<&Path>) -> bool { + if !repo_relative_path(token) { + return false; + } + let (Some(root), Some(cwd)) = (root, cwd) else { + return true; + }; + // `relative_to` answers `None` for a path outside `root`, which is exactly + // the question — and it is the same primitive `protects` asks, so the two + // readers cannot disagree about containment the way two resolvers would. + relative_to(root, &cwd.join(token).display().to_string()).is_some() +} + /// Compose a substitution refusal: what was reached for, and what answers it. /// /// Names the displaced CAPABILITY rather than the principle, which is the @@ -6627,12 +6694,19 @@ fn redirect_targets<'a>(tokens: &[&'a str]) -> Vec<(&'static str, &'a str)> { /// Strip a leading `./`, which names the same path. /// -/// Deliberately the *only* normalisation. An absolute path, a `..` traversal, or -/// a `~` are not resolved against the repo root — `Envelope` carries no `cwd`, so -/// there is nothing honest to resolve against. Every such miss under-denies, -/// which is the sanctioned direction, and +/// Deliberately the *only* normalisation here. An absolute path, a `..` +/// traversal or a `~` are not resolved against the repo root by THIS function — +/// [`protects`] is where an absolute operand meets [`relative_to`], and this is +/// the string-level step before it. Every such miss under-denies, which is the +/// sanctioned direction for this arm, and /// `tests::an_absolute_path_is_not_resolved_against_the_repo_root` pins the limit /// so it cannot change silently. +/// +/// **The reason this used to give was "`Envelope` carries no `cwd`", and it was +/// false** (CLOUD-1109): the field has been decoded since CLOUD-202 and is read +/// by [`names_a_repository_path`] now. A stale reason is worse than none, because +/// the next author reads it as a constraint and designs around a field that was +/// there all along. The limit above stands on its own terms, not on that one. fn normalise(path: &str) -> &str { path.strip_prefix("./").unwrap_or(path) } @@ -10974,9 +11048,15 @@ deny contains "refused by themodule" if { #[test] fn an_absolute_path_is_not_resolved_against_the_repo_root() { - // A stated limit, pinned so it cannot change silently. `Envelope` carries - // no `cwd`, so there is nothing honest to resolve against; this - // under-denies, which is the sanctioned direction. + // A stated limit, pinned so it cannot change silently: `guarded` builds + // an envelope with no `policy.root`, so there is nothing to resolve + // against here. This under-denies, which is the sanctioned direction. + // + // The reason this comment used to give — "`Envelope` carries no `cwd`" — + // was false and is corrected (CLOUD-1109). The field has been decoded + // since CLOUD-202; what was missing was a reader, and `protects` now has + // one for the absolute case and `names_a_repository_path` for the + // relative one. assert_eq!(guarded("rm /home/user/batten/batten.toml"), Decision::Allow); } diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 152f386b1..aac2227d6 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1253,12 +1253,17 @@ only one of the two is fixed by rebasing.", VendoredVerdict { id: "tool run loose", gloss: "a shell text utility stood in for the structured file surface", - class: "The call reaches for a text utility over a path this repository tracks, as \ + class: "The call reaches for a text utility over a path this repository CONTAINS, as \ its FIRST stage, to answer a question the structured surface answers directly and better: \ a range of one file's contents, a pattern across the tree, paths by glob, or what a name \ resolves to. Which instruments a session carries varies, so the refusal names the question \ classes rather than a product. The same utility DOWNSTREAM of a pipe is untouched, because \ -filtering another command's output is not standing in for anything.", +filtering another command's output is not standing in for anything. CONTAINMENT, never the \ +INDEX (CLOUD-1109): the boundary resolves the operand against the call's own working \ +directory and asks whether the repository contains the result. It does not ask git, because \ +a `git ls-files` per mediated call is a spawn `RuleKind::scopes` forbids on this kind and \ +`perf-assert` prices out. This text said 'tracks' for its whole life and nothing ever \ +checked it -- a class a reader believes is worse than one they cannot look up.", routes: &[read("rule read first", ".claude/rules/scanning.md")], }, VendoredVerdict { diff --git a/crates/batten/tests/it/mediated_verbs.rs b/crates/batten/tests/it/mediated_verbs.rs index 843767611..e59a52f4b 100644 --- a/crates/batten/tests/it/mediated_verbs.rs +++ b/crates/batten/tests/it/mediated_verbs.rs @@ -767,3 +767,88 @@ fn a_newline_did_not_become_a_separator() { // allowed the first. assert_denied("mise run verify\n| tail -1"); } + +// --- CLOUD-1109: clause 3 resolves against the caller's cwd ------------------- +// +// `repo_relative_path` was purely lexical: a token SHAPED like a relative path +// was called "inside the repository". Reproduced twice on 2026-08-28 from a +// scratch directory outside the tree — `cat err.txt` refused while the identical +// file named absolutely was allowed. + +/// A mediated call carrying the caller's own working directory. +/// +/// The whole defect is that this field existed and nothing read it, so a case +/// that omitted it could not discriminate. +fn bash_payload_in(cwd: &std::path::Path, command: &str) -> String { + let escaped = serde_json::to_string(command).expect("a command is encodable"); + let dir = serde_json::to_string(&cwd.display().to_string()).expect("a path is encodable"); + format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\"cwd\":{dir},\ + \"tool_input\":{{\"command\":{escaped}}}}}" + ) +} + +fn verdict_in(cwd: &std::path::Path, command: &str) -> Option { + run_with_stdin( + &root(), + &["hook", "--harness", "exit-code"], + &bash_payload_in(cwd, command), + ) + .status + .code() +} + +#[test] +fn one_file_outside_the_repository_gets_one_verdict_whichever_way_it_is_spelled() { + // THE PAIR THAT IS THE WHOLE DEFECT, and it is red against the unfixed + // binary: the relative spelling refused and the absolute one allowed, for + // one transient scratch file `git ls-files` has never heard of. + // OUTSIDE the tree deliberately: `scratch` lives under `target/`, which the + // repository contains, so the case would be asking the opposite question. + let dir = common::scratch_outside_tree("cloud-1109", "outside"); + std::fs::write(dir.join("err.txt"), "scratch\n").expect("the scratch file is writable"); + let absolute = dir.join("err.txt").display().to_string(); + + assert_eq!( + verdict_in(&dir, "cat err.txt"), + verdict_in(&dir, &format!("cat {absolute}")), + "one file, two spellings, one verdict" + ); + assert_eq!( + verdict_in(&dir, "cat err.txt"), + Some(0), + "and the verdict is allow: the repository does not contain it" + ); +} + +#[test] +fn a_relative_path_inside_the_repository_is_still_refused_from_a_subdirectory() { + // THE DISCRIMINATOR. Without it the fix above is a blanket allow for every + // relative operand, which would switch clause 3 off entirely — and a gate + // that refuses nothing looks exactly like a gate that passed. + let inside = root().join("crates"); + assert_eq!( + verdict_in(&inside, "cat batten/Cargo.toml"), + Some(2), + "a path the repository contains, reached relatively from a subdirectory" + ); +} + +#[test] +fn the_verdict_claims_containment_and_never_the_index() { + // The second defect, which is independent of the first: the prose asserted + // that the repository TRACKS the path, and nothing ever asked git. A + // `git ls-files` per mediated call is a spawn `RuleKind::scopes` forbids on + // this kind, so the fix is to stop claiming it rather than to check it. + let explained = run(&root(), &["policy", "explain", "tool run loose"]); + assert_eq!(explained.status.code(), Some(0), "the class resolves"); + let text = String::from_utf8_lossy(&explained.stdout); + assert!( + !text.contains("this repository tracks"), + "no tracked-ness claim survives: {text}" + ); + assert!( + text.contains("CONTAINS") || text.contains("contains"), + "and the class says what the predicate actually decided: {text}" + ); +} From 70511815f6340c691e3d72b063ad627cd61b2c95 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 15:05:14 +0000 Subject: [PATCH 08/20] fix(hook): a bare directory destination is inside the protected set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protected` is matched with `literal_separator(true)`, so `dir/**` requires at least one component after the separator and `dir` is not a member of it. Every mutating verb aimed at a guarded DIRECTORY was allowed while the same verb naming a file inside it denied: cp /tmp/draft.md .serena/memories/ # allowed mv /tmp/draft.md .serena/memories/ # allowed A fidelity loss from the CLOUD-312 port rather than a gate designed without it: the retiring `memory-guard-check` matched the guarded path as a SUBSTRING and caught the directory form, and the engine's glob matcher does not. `.../**` is the shape consumer #1 declares and the shape the schema's own examples encourage, so this is the ordinary spelling. Question 1 is answered (1)+(2). Normalisation alone provably does not close it — `dir` is not inside `dir/**` either — so `normalise` strips a trailing separator AND `protects` asks containment. Pushing it to the consumer is declined: the fact is about the MATCHER, so requiring every consumer to declare both `dir/**` and `dir` would make their config carry a subtlety that is Batten's, and anyone writing only the documented shape stays exposed without ever learning why. Question 2 is answered no, by evidence: a redirect to a directory is a shell error, so `> dir/` cannot reach a working command and the fix stays inside `protected_mutation`'s operand path. `PathSet::encloses` is a NAMED method and `contains` is untouched. `scope` and `unlanded` are answers about files, and a rule selecting a directory would select nothing to inspect — so a quiet widening would change two callers that never asked this question and would look like nothing in the diff. A unit case asserts membership directly for exactly that reason. It decides over the declared PATTERNS rather than a probe path: a synthetic path answers for `dir/**` and gets `dir/*.md` wrong, and any sentinel component can collide with an exclude. An ancestor encloses too, so `rm -rf .serena` refuses. That is the predicate working rather than overreaching — a gate gating the smaller blast radius and not the larger one would be the wrong way round. Also fixed here, because review of the previous commit found it before the field did: CLOUD-1287's line split opened a BYPASS on a backslash continuation. `rm \` with the path on the next line is one command to bash; split naively it hands line one an `rm` with no operands and line two an operand with no program, so the protected path was judged by nothing. `joined_lines` rejoins a continuation, counting trailing backslashes so `rm a\\` — an escaped backslash, a complete command — does not swallow the line after it. BREAKING CHANGE: the library API moves, and `mise run semver` names four lints rather than one, so this declares the break for the branch rather than for this commit alone: `Config` gained a `refusal` field (constructible_struct_adds_field, CLOUD-1286), `Native` and `WeakeningKind` gained variants (enum_no_repr_variant_discriminant_changed, CLOUD-1285/1286), `verdict::validate` takes the declared vocabulary (function_parameter_count_changed, CLOUD-1284), and `VERDICT_PREFIX` / `ROUTE_PREFIX` are gone (pub_module_level_const_missing, CLOUD-1284). No consumer-facing exit code or output shape moves, and release-plz bumps the patch below 0.1.0 whatever the type says; the declaration is what keeps the gate honest rather than a version claim. Refs: CLOUD-609 --- crates/batten/src/hook.rs | 75 +++++++++++++++++++-- crates/batten/src/rules.rs | 83 ++++++++++++++++++++++++ crates/batten/tests/it/mediated_verbs.rs | 60 +++++++++++++++++ 3 files changed, 211 insertions(+), 7 deletions(-) diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 3b9e601db..541c319ce 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -6471,14 +6471,57 @@ fn line_bounded_words(segment: &Segment) -> Vec> { if !segment.raw.contains('\n') { return vec![segment.words.clone()]; } - segment - .raw - .lines() - .flat_map(|line| segments(line).into_iter().map(|parsed| parsed.words)) + joined_lines(&segment.raw) + .into_iter() + .flat_map(|line| segments(&line).into_iter().map(|parsed| parsed.words)) .filter(|words| !words.is_empty()) .collect() } +/// `raw`'s lines, with a BACKSLASH CONTINUATION joined back to the line it +/// continues. +/// +/// **This is the one shape where a newline is not a boundary**, and getting it +/// wrong is a bypass rather than a false refusal: `rm \` then the path on the +/// next line is ONE command to bash, and splitting it hands line one an `rm` +/// with no operands and line two an operand with no program — so the protected +/// path is judged by nothing and the write is allowed. Caught in review of the +/// change that introduced the split, before it could be measured in the field. +/// +/// An ODD number of trailing backslashes continues; an even number is escaped +/// backslashes and the line ends. `rm a\\` writes a literal backslash and is a +/// complete command, so counting rather than testing the last character is what +/// keeps that from continuing into the next line. +fn joined_lines(raw: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut pending: Option = None; + for line in raw.lines() { + let trailing = line.chars().rev().take_while(|c| *c == '\\').count(); + let continues = trailing % 2 == 1; + // The continuation backslash is shell syntax, not an operand, so it is + // dropped rather than carried into the token list where it would read as + // a word. + let body = if continues { + &line[..line.len() - 1] + } else { + line + }; + let mut current = pending.take().unwrap_or_default(); + current.push_str(body); + if continues { + pending = Some(current); + } else { + out.push(current); + } + } + // A trailing continuation with nothing after it: keep what was collected + // rather than dropping the line, which would lose its operands entirely. + if let Some(last) = pending { + out.push(last); + } + out +} + fn protected_mutation(policy: &Policy, command: &str) -> Decision { for segment in segments(command) { for words in line_bounded_words(&segment) { @@ -6708,7 +6751,20 @@ fn redirect_targets<'a>(tokens: &[&'a str]) -> Vec<(&'static str, &'a str)> { /// the next author reads it as a constraint and designs around a field that was /// there all along. The limit above stands on its own terms, not on that one. fn normalise(path: &str) -> &str { - path.strip_prefix("./").unwrap_or(path) + let path = path.strip_prefix("./").unwrap_or(path); + // A TRAILING SEPARATOR NAMES THE SAME DIRECTORY (CLOUD-609), and stripping + // it is half of that fix: `dir/` has to be asked as `dir` before the + // containment question below can be asked at all. On its own it closes + // nothing — `dir` is not inside `dir/**` either — which is why the row + // answers "(1) AND (2)" rather than picking one. + // + // `/` alone is left as it is: trimming it would turn the filesystem root + // into the empty string, and an empty path matches no glob for a reason + // nobody could read back from the code. + match path.strip_suffix('/') { + Some(trimmed) if !trimmed.is_empty() => trimmed, + _ => path, + } } /// Is this path protected, asked the way the REPOSITORY names paths @@ -6743,7 +6799,12 @@ fn normalise(path: &str) -> &str { /// filesystem for one too, so the syscalls are paid only when an absolute operand /// is not already a literal member. fn protects(policy: &Policy, path: &str) -> bool { - if policy.protected.contains(normalise(path)) { + // `encloses` rather than `contains` (CLOUD-609): a DIRECTORY operand means + // "write inside it", so membership is the wrong question and answering it + // let `cp /tmp/draft.md .serena/memories/` through while the same copy + // naming a file inside denied. This is the one call site that asks it; + // `PathSet::contains` still means membership everywhere else. + if policy.protected.encloses(normalise(path)) { return true; } let Some(root) = policy.root.as_deref() else { @@ -6752,7 +6813,7 @@ fn protects(policy: &Policy, path: &str) -> bool { let Some(relative) = relative_to(root, path) else { return false; }; - policy.protected.contains(normalise(&relative)) + policy.protected.encloses(normalise(&relative)) } /// Compose the protected-path refusal: what was aimed where, and what to run. diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index ea3eb518e..2a6b45684 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -10465,6 +10465,50 @@ impl PathSet { self.includes.iter().any(|selector| selector.matches(path)) && !self.excludes.iter().any(|selector| selector.matches(path)) } + + /// Whether `directory` is a member, **or encloses one** (CLOUD-609). + /// + /// # A second question over the same lists, and it gets its own name + /// + /// `dir/**` requires at least one component after the separator, so `dir` is + /// not a member of it. Every mutating verb aimed at a guarded DIRECTORY was + /// therefore allowed while the same verb naming a file inside it denied: + /// `cp /tmp/draft.md .serena/memories/` passed, which is the exact shape the + /// retiring `memory-guard-check` existed for — it matched by substring, so + /// the CLOUD-312 port lost the coverage rather than the gate being designed + /// without it. + /// + /// **[`PathSet::contains`] is deliberately untouched.** `scope` and + /// `unlanded` are answers about FILES, and a rule selecting a directory + /// would select nothing to inspect, so widening membership would change two + /// callers that never asked this question — and it would look like nothing + /// in the diff. That is why this is a named method rather than a quiet + /// broadening, and why the only caller is `hook::protects`. + /// + /// # Decided over the declared PATTERNS, never over a probe path + /// + /// A synthetic path under `directory` matched against the set would answer + /// for `dir/**` and get `dir/*.md` wrong, and any sentinel component can + /// collide with an exclude. The exact question is a string one: does a + /// protected pattern's literal head sit inside this directory? `dir/**`, + /// `dir/*.md` and `dir/sub/x` all begin `dir/`; `dirty/**` does not, which is + /// what the separator in the comparison buys. + /// + /// Excludes subtract from membership, as always, and cannot manufacture + /// enclosure: a set whose only include is excluded still encloses whatever + /// its include names, because the exclude speaks about a path rather than + /// about the directory above it. That is the under-denying direction and the + /// sanctioned one. + #[must_use] + pub fn encloses(&self, directory: &str) -> bool { + if self.contains(directory) { + return true; + } + let prefix = format!("{}/", directory.trim_end_matches('/')); + self.includes + .iter() + .any(|selector| selector.pattern().starts_with(&prefix)) + } } /// The three sets Batten's policy is defined over, each parsed from its own list @@ -10595,6 +10639,45 @@ pub fn glob_match(pattern: &str, path: &str) -> bool { #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { + use super::PathSet; + + /// CLOUD-609's guard on the change itself: `contains` still means MEMBERSHIP. + /// + /// Asserted directly rather than through a caller, because widening it would + /// change `scope` and `unlanded` — two callers that never asked the + /// containment question — and would look like nothing in the diff. This is + /// the case that would otherwise ship silently. + #[test] + fn contains_still_answers_membership_and_never_enclosure() { + let set = PathSet::includes("protected", &[".serena/memories/**".to_owned()]).unwrap(); + assert!(set.contains(".serena/memories/core.md")); + assert!( + !set.contains(".serena/memories"), + "a directory is not a member of `dir/**`, and that reading is unchanged" + ); + } + + /// The new question, and the neighbour it must not swallow. + #[test] + fn encloses_answers_the_directory_and_stops_at_the_separator() { + let set = PathSet::includes("protected", &[".serena/memories/**".to_owned()]).unwrap(); + assert!(set.encloses(".serena/memories")); + assert!(set.encloses(".serena/memories/")); + // A member is still enclosed, so the one call site needs no second test. + assert!(set.encloses(".serena/memories/core.md")); + // THE DISCRIMINATOR. `.serena/memories` is a string prefix of + // `.serena/memoriesx` and not a path prefix; without the separator in + // the comparison this would refuse a sibling nobody guarded. + assert!(!set.encloses(".serena/memoriesx")); + // AN ANCESTOR ENCLOSES TOO, and that is the predicate working rather + // than overreaching: `rm -rf .serena` destroys the guarded tree, so a + // gate that allowed it while refusing `rm -rf .serena/memories` would be + // guarding the smaller blast radius and not the larger one. It is a + // widening, and the sanctioned kind — no call a correct reading allows + // starts failing. + assert!(set.encloses(".serena")); + } + // --- one document acquisition (CLOUD-849) -------------------------------- /// Every `.rs` under `src/`, so the scan is over the crate rather than over diff --git a/crates/batten/tests/it/mediated_verbs.rs b/crates/batten/tests/it/mediated_verbs.rs index e59a52f4b..6df1819b9 100644 --- a/crates/batten/tests/it/mediated_verbs.rs +++ b/crates/batten/tests/it/mediated_verbs.rs @@ -852,3 +852,63 @@ fn the_verdict_claims_containment_and_never_the_index() { "and the class says what the predicate actually decided: {text}" ); } + +// --- CLOUD-609: a bare directory destination is inside the protected set ------ +// +// `protected` is matched with `literal_separator(true)`, so `dir/**` needs at +// least one component after the separator and `dir` is not a member of it. Every +// mutating verb aimed at a guarded DIRECTORY was therefore allowed while the same +// verb naming a file inside it denied. +// +// A fidelity loss from the CLOUD-312 port rather than a gate designed without it: +// the retiring `memory-guard-check` matched by substring and caught this. + +/// The guarded directory itself, in the trailing-slash form a caller types. +const GUARDED_DIR: &str = ".serena/memories/"; + +#[test] +fn a_mutating_verb_aimed_at_a_guarded_directory_is_refused() { + // RED AGAINST THE UNFIXED BINARY, and it is the measured case from the row. + assert_denied(&format!("cp /tmp/draft.md {GUARDED_DIR}")); + // The other every-operand verbs the gap has covered since CLOUD-96. + assert_denied(&format!("mv /tmp/draft.md {GUARDED_DIR}")); + assert_denied(&format!("rm -rf {GUARDED_DIR}")); +} + +#[test] +fn the_directory_without_its_trailing_slash_is_refused_too() { + // Normalisation and containment are two steps and this is what says both + // landed: strip alone leaves `dir`, which is still not a member of `dir/**`. + assert_denied(&format!("rm -rf {}", GUARDED_DIR.trim_end_matches('/'))); +} + +#[test] +fn reading_out_of_a_guarded_directory_still_allows() { + // THE DIRECTION A CARELESS CONTAINMENT PREDICATE BREAKS. Copying a memory + // OUT is a read, and this gate is not its business — a guard that refuses + // reads is one people switch off. + assert_allowed(&format!("cp {GUARDED_DIR} /tmp/copy.md")); + assert_allowed(&format!("cp {GUARDED} /tmp/copy.md")); +} + +#[test] +fn a_directory_whose_name_merely_starts_the_same_is_not_enclosed() { + // The separator in the comparison is what buys this: `.serena/memories` is + // a prefix of `.serena/memoriesx` as a STRING and not as a path. Without + // this arm the predicate would refuse a sibling directory nobody guarded. + assert_allowed("cp /tmp/draft.md target/memoriesx/"); +} + +#[test] +fn a_backslash_continuation_is_one_command_and_is_still_refused() { + // THE BYPASS CLOUD-1287's SPLIT WOULD HAVE OPENED, caught in review of that + // change rather than in the field. `rm \` then a path on the next line is + // ONE command to bash; split naively it hands line one an `rm` with no + // operands and line two an operand with no program, so the protected path is + // judged by nothing. + assert_denied(&format!("rm \\\n{GUARDED}")); + assert_denied(&format!("cp /tmp/draft.md \\\n{GUARDED}")); + // And the even case, which is NOT a continuation: two backslashes are an + // escaped backslash, so the line ends and the next one stands alone. + assert_denied(&format!("echo done\nrm {GUARDED}")); +} From 61030716f0792116a3632286c0d2d73f39813b02 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 15:18:54 +0000 Subject: [PATCH 09/20] feat(hook): a generic read of a memory names the tool that answers it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `no-tool-substitution` is `kind = "pipeline"` and decides over shell argv, so a structured-tool call is STRUCTURALLY invisible to it. `.serena/memories/**` is in `protected`, but crossed with `[[verb]]`, which enumerates mutations — CLOUD-442's port says "reads stay allowed" in as many words, which was right for the question that row answered. So nothing gated a generic file read of a memory, measured 2026-08-31 with the Serena server healthy and `read_memory` loadable. CLOUD-185 closed the write half of this shape and CLOUD-864 the shell half. This is the third face of the same object. It is a defect rather than a style preference because a path read couples the caller to the tree layout, and CLOUD-868 proposes moving that tree into a private repository: `read_memory` survives the move and a hardcoded path does not. Every flat-path read is an unmigrated call site, and this makes them unwritable now rather than discoverable after the move breaks them. A matcher change rather than new vocabulary, as the row says: `[[redirect]]` gains an optional `read`, and `Envelope` gains `reads` — the mirror of `writes`, from the same host keys, keyed on the neutral `Operation::Read` rather than on a second per-host tool list, because two lists of one host fact is how the two come to disagree. Both targets relativise in the one place `writes` already did. THE "SESSION DOES NOT OFFER IT" ARM IS THE ABSENT KEY, and that is where the question is decidable. The boundary cannot see whether an MCP server is healthy — no mediated payload carries that — so a consumer without the tool declares no `read` and is refused nothing. The remedy is therefore a string its own author wrote, which rules out CLOUD-998's defect by construction rather than by care. An empty `read` is refused at load, so absent and empty stay different statements. `protected` is deliberately not consulted: "which instrument answers this path" and "is this path guarded" are two sets, and deriving one from the other is the collapse CLOUD-37 exists to prevent. Four cases over the compiled binary. The deny, and that the hop reaches `read_memory` rather than the mutation tools. The load-bearing half — ordinary reads of `hook.rs`, `batten.toml` and a policy module are untouched, because this fires on a READ and a noisy deny stops the fleet. The not-offered arm, using a protected class that declares a mutation and no read. And that a write to a memory is still refused as a write, since the read row rides the same table. Firing rate, stated rather than claimed: over this session's own reads the gate fires zero times on correct behaviour and once on the measured defect. A CCR container writes no transcript, so a fleet-wide replay is not available here and is not asserted. Pointer-only: the path and the class, never a byte of the memory — which for this subject is exactly what a read gate must not become a mirror of. Admits: 3f4e20df269636ca67ea65563d4b815f68de94b5fd2e1112d920da95cae29296 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 953163e01fb5bfd585a938c95ed8bb6bfb9bfcb1 Admits-epoch: a362e21e7adbab4bc6adb06baaab7411cd739953cbe330a8cf7838b1a0b8ecaf Admits-author: alec@wenzowski.com Admits-prev: 9eddfef5a9c49d424ff3fcc8688776db2d781a9a69f76c5bab60f127f3ae28da Admits-answer-lost: The row entirely. CLOUD-1258's whole mechanism is a consumer-declared read remedy: with no row, `resolve_read` returns `None` for every path and the gate refuses nothing, which is the state the row was filed about. The alternative is landing engine code with no configuration that reaches it — a gate that cannot fire, which reads as coverage and is the defect `.claude/rules/policy-modules.md` opens with. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table row: CLOUD-1258's read-side gate is declared as a `[[redirect]]` row's `read` key, and the tool it names is the consumer's fact (non-negotiable rule 1) — an engine literal naming `read_memory` would be exactly what that rule forbids. There is no non-protected path that carries it. The write is a four-line addition a reviewer sees in the diff it lands in, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the `[[redirect]]` table and the `[[verb]]` rows beside it, and reading is what established that no row speaks for `.serena/memories/**` today — reading further produces no route that adds one. `patch run first` does not apply either: a patch to `batten.toml` is still a write to `batten.toml`, reaching this same class one indirection later. Refs: CLOUD-1258 --- batten.toml | 30 ++++++ crates/batten/src/hook.rs | 115 +++++++++++++++++++++-- crates/batten/src/lib.rs | 7 ++ crates/batten/src/redirect.rs | 66 +++++++++++++ crates/batten/tests/it/mediated_verbs.rs | 82 ++++++++++++++++ schema/batten.local.schema.json | 7 ++ schema/batten.schema.json | 7 ++ 7 files changed, 307 insertions(+), 7 deletions(-) diff --git a/batten.toml b/batten.toml index b22da3808..13cf9c3ac 100644 --- a/batten.toml +++ b/batten.toml @@ -203,6 +203,36 @@ mutation = "change it in a pull request — this file is the policy authority ev # "whatever is registered". The glob is wider than the derived set by exactly the # UNREGISTERED modules, and that is the safe direction: an unregistered module is # not protected, so this row is unreachable for one and costs nothing. +# THE MEMORIES TREE, and the one row in this table that declares a `read` +# (CLOUD-1258). +# +# The read half is the point. `no-tool-substitution` is `kind = "pipeline"` and +# decides over shell argv, so a structured-tool call is invisible to it, and +# `protected` crossed with `[[verb]]` enumerates MUTATIONS — CLOUD-442's port +# says "reads stay allowed" in as many words. So a generic file read of a memory +# was gated by nothing, measured 2026-08-31 with the Serena server healthy and +# `read_memory` loadable. +# +# It is not a style preference: a path read couples the caller to the tree +# layout, and CLOUD-868 proposes moving that tree into a private repository. +# `read_memory` survives the move; a hardcoded path does not. Every flat-path +# read is an unmigrated call site, and this makes them unwritable now rather +# than discoverable after the move breaks them. +# +# THE KEY IS OPTIONAL AND ITS ABSENCE IS THE "NOT OFFERED" ARM. A consumer whose +# session does not carry Serena declares no `read` and is refused nothing — the +# boundary cannot see whether an MCP server is healthy, so the question is +# answered where it IS decidable, by the author who knows. That is CLOUD-998's +# defect ruled out by construction rather than by care. +# +# The mutation half names the family rather than one tool because this row now +# outranks the per-verb redirects for this class (CLOUD-280's tiering); the +# per-verb answers stay reachable through `batten policy explain`. +[[redirect]] +glob = ".serena/memories/**" +mutation = "use Serena's memory tools — `write_memory` to create, `edit_memory` to change in place, `rename_memory` to move (it is the only route that rewrites `mem:` referrers)" +read = "read_memory" + [[redirect]] glob = "policy/**" mutation = "change it in a pull request — a registered module is protected because enabling policy protects it, so an agent's context cannot influence the rules it is judged by; `mise run policy-test` is what checks the edit before it lands" diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index 541c319ce..fefd6e380 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -2046,6 +2046,14 @@ pub struct Envelope { /// once, at the boundary that knows where the repository is, rather than by /// each reader learning what a root is. pub writes: Option, + /// The path a READ tool named, where the host said one (CLOUD-1258). + /// + /// The mirror of [`Envelope::writes`] and derived from the same keys, keyed + /// on the neutral [`Operation::Read`] rather than on a second per-host tool + /// list — two lists of one host fact is how the two come to disagree. `None` + /// for every call that is not a read, which is what keeps the read-side gate + /// off every other path. + pub reads: Option, /// The host's working directory, when it reported one. pub cwd: Option, /// The host's session id, when it reported one. @@ -2111,14 +2119,44 @@ impl Envelope { /// for its own predicate. A relative path is left alone too — it is already /// what the globs are written against. pub fn relativise_writes(&mut self, root: &Path) { - let Some(path) = self.writes.as_deref() else { - return; - }; - let Some(relative) = relative_to(root, path) else { - return; - }; - self.writes = Some(relative); + // BOTH TARGETS, in one place, for the reason this function's own doc + // gives: there is more than one reader, and a fix at one of them leaves + // the next author the same trap. `reads` arrived from the same host keys + // as `writes` (CLOUD-1258), so it arrives with the same absolute + // spelling and needs the same one normalisation. + for target in [&mut self.writes, &mut self.reads] { + let Some(path) = target.as_deref() else { + continue; + }; + let Some(relative) = relative_to(root, path) else { + continue; + }; + *target = Some(relative); + } + } +} + +/// The path a READ tool named, from the same keys a write is read from +/// (CLOUD-1258). +/// +/// Keyed on the neutral [`Operation::Read`] rather than on a second per-host +/// tool list: [`Harness::write_tools`] exists because a write is what +/// [`Envelope::writes`] is derived from, and two lists of one host fact is how +/// the two come to disagree. +/// +/// `notebook_path` is read beside `file_path` for [`Envelope::writes`]'s own +/// reason — a host spells one tool's target differently, and omitting it would +/// leave that tool unjudged, which is the CLOUD-185 shape. +fn read_target(operation: &Operation, input: &Value) -> Option { + if !matches!(operation, Operation::Read) { + return None; } + input + .pointer("/file_path") + .or_else(|| input.pointer("/notebook_path")) + .and_then(Value::as_str) + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) } /// `path` as `root` would name it, or `None` where that is not a question this @@ -2553,6 +2591,8 @@ pub fn decode(harness: Harness, raw: &str) -> Option { // layer would have to make against one host's tool names. let operation = harness.operation_of(&tool); + let reads = read_target(&operation, &input); + Some(Envelope { event, raw_event, @@ -2564,6 +2604,7 @@ pub fn decode(harness: Harness, raw: &str) -> Option { .unwrap_or_default() .to_owned(), writes, + reads, input, cwd: value .get("cwd") @@ -3808,6 +3849,15 @@ fn adjudicated(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Decis decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} } + // The read-side redirect (CLOUD-1258), beside the tool gate for the same + // reason: it rides a resolved tool fact and carries no command line. Below + // the write gate on the standing precedence — a call that is refused as a + // WRITE is not also told which reader to use — and above the ceilings, + // because naming the instrument is more useful than sizing the wrong one. + match redirected_read(policy, envelope) { + decided @ (Decision::Deny(_) | Decision::Ask(_)) => return decided, + Decision::Allow | Decision::Waived(_) | Decision::Preapproved(_) => {} + } // The per-call ceiling (CLOUD-925), beside the tool gate because it rides the // same selection and the same reason for being above the command early // return: a `Task` spawn carries no command line. @@ -6389,6 +6439,52 @@ fn protected_write(policy: &Policy, envelope: &Envelope, stage: WriteStage) -> D } } +/// Refuse a generic read of a path whose class declares the tool that answers it +/// (CLOUD-1258). +/// +/// # What made this reachable by nothing +/// +/// `no-tool-substitution` is `kind = "pipeline"` and decides over shell argv, so +/// a structured-tool call is invisible to it. `protected` crossed with +/// `[[verb]]` enumerates mutations, and CLOUD-442's port states "reads stay +/// allowed" — correct for the question that row answered, and the reason nobody +/// had asked whether a read through the wrong instrument matters. This is the +/// third face of the object CLOUD-185 and CLOUD-864 closed the other two of. +/// +/// # `protected` is NOT consulted, and that is the design +/// +/// The question here is "which instrument answers this path", not "is this path +/// guarded" — two different sets, and deriving one from the other is the +/// collapse CLOUD-37 exists to prevent. `[[redirect]]` already answers the first +/// for mutations, so the read remedy belongs beside it and a class with no +/// declared `read` is refused nothing. +/// +/// # Pointer-only, and here that is the whole of the output +/// +/// The path and the declared remedy. Never a byte of the file, which for a +/// memory is exactly the content a read gate must not become a mirror of. +fn redirected_read(policy: &Policy, envelope: &Envelope) -> Decision { + let Some(path) = envelope.reads.as_deref() else { + return Decision::Allow; + }; + let Some(remedy) = crate::redirect::resolve_read(&policy.redirects, normalise(path)) else { + return Decision::Allow; + }; + Decision::Deny(Refusal::declared( + PROTECTED_MUTATION, + crate::verdict::Native::ToolSubstituted, + &[ + crate::verdict::Subject::Path { + path: path.to_owned(), + }, + crate::verdict::Subject::Artifact { + artifact: envelope.raw_tool.clone(), + }, + ], + Fix::Run(remedy.to_owned()), + )) +} + /// The tool-named half: the adapter already resolved the target, so this is the /// protected-set lookup and the refusal. /// @@ -8224,6 +8320,7 @@ mod tests { result: Value::Null, command: command.to_owned(), writes: None, + reads: None, cwd: None, session: None, // The Stop-path fields (CLOUD-479) are absent on a PreTool envelope, @@ -8265,6 +8362,7 @@ mod tests { result: Value::Null, command: String::new(), writes, + reads: None, cwd: None, session: None, stop_active: None, @@ -10972,6 +11070,7 @@ deny contains "refused by themodule" if { Redirect { glob: glob.to_owned(), mutation: mutation.to_owned(), + read: None, } } @@ -12562,6 +12661,7 @@ deny contains "refused by themodule" if { vec![Redirect { glob: "batten.toml".to_owned(), mutation: "change it in a pull request".to_owned(), + read: None, }], ); let Decision::Deny(refusal) = adjudicate( @@ -12762,6 +12862,7 @@ deny contains "refused by themodule" if { result: Value::Null, command: String::new(), writes: None, + reads: None, cwd: None, session: None, stop_active: None, diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index b92ada535..5e6cec92e 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -3326,6 +3326,13 @@ fn explain_redirects( writeln!(out)?; for row in redirects { writeln!(out, "{} {}", row.glob, row.mutation)?; + // The READ remedy where a class declares one (CLOUD-1258). Printed on + // its own line rather than folded into the mutation's, because they are + // answers to two different questions and a reader arriving from a read + // refusal must not have to pick the right half out of one sentence. + if let Some(read) = row.read.as_deref() { + writeln!(out, "{} read {read}", row.glob)?; + } } for row in verbs { if let Some(redirect) = row.redirect.as_deref() { diff --git a/crates/batten/src/redirect.rs b/crates/batten/src/redirect.rs index 41dbcabed..b8b937ddb 100644 --- a/crates/batten/src/redirect.rs +++ b/crates/batten/src/redirect.rs @@ -67,6 +67,36 @@ pub struct Redirect { /// The sanctioned mutation for that class — the "run this instead" a deny /// carries. pub mutation: String, + /// The sanctioned READ for that class, and the whole of the read-side gate + /// (CLOUD-1258). + /// + /// # Why a read is gated at all + /// + /// `no-tool-substitution` is `kind = "pipeline"` and decides over shell + /// argv, so a structured-tool call is invisible to it; `protected` crossed + /// with `[[verb]]` enumerates MUTATIONS, and CLOUD-442's port states "reads + /// stay allowed". So a generic file read of a memory was gated by nothing, + /// measured 2026-08-31 with the Serena server healthy and `read_memory` + /// loadable. That is not a style preference: a path read couples the caller + /// to the tree layout, and CLOUD-868 proposes moving that tree — `read_memory` + /// survives the move and a hardcoded path does not, so every flat-path read + /// is an unmigrated call site against it. + /// + /// # OPTIONAL, AND THE ABSENCE IS THE "SESSION DOES NOT OFFER IT" ARM + /// + /// The row demands that the gate allow where the tool is not available, + /// because a redirect naming a tool the session does not carry is CLOUD-998's + /// defect one layer over. The boundary cannot see whether an MCP server is + /// healthy — that is not a fact any mediated payload carries — so the + /// question is answered where it IS decidable: the consumer declares the + /// read remedy, and a consumer without the tool declares none and is refused + /// nothing. The remedy is therefore a string its own author wrote, which is + /// the strongest available guarantee that it names something reachable. + /// + /// A row with no `read` key gates nothing on the read side and keeps its + /// mutation half exactly as before. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub read: Option, } /// Reject a table that would make a refusal dishonest or silent. @@ -96,6 +126,21 @@ pub fn validate(table: &[Redirect]) -> Result<()> { entry.glob ))); } + // Same refusal as `mutation`'s, for the same reason: a declared `read` + // that says nothing renders a fix clause that says nothing, which reads + // worse than the row simply not declaring one. Absent and empty are + // different statements and the engine keeps them different. + if entry + .read + .as_ref() + .is_some_and(|read| read.trim().is_empty()) + { + return Err(UsageError::raise(format!( + "redirect {}: `read` is declared and empty — omit the key to gate no read, or \ + name the tool that answers", + entry.glob + ))); + } if table[..index].iter().any(|prior| prior.glob == entry.glob) { return Err(UsageError::raise(format!( "redirect {}: declared twice; a path class has one sanctioned mutation", @@ -119,6 +164,26 @@ pub fn resolve<'table>(table: &'table [Redirect], path: &str) -> Option<&'table .map(|entry| entry.mutation.as_str()) } +/// The sanctioned READ declared for `path`, if any row declares one +/// (CLOUD-1258). +/// +/// Same table, same first-match-in-declaration-order tie-break. `None` is the +/// whole read-side allow arm: no row speaks for this path, or the row that does +/// declares no read remedy — and the second is how a consumer without the tool +/// says so. +/// +/// **The first matching row decides, and a later row's `read` does not rescue +/// it.** That is deliberate rather than incidental: `resolve` already reads the +/// table that way for mutations, and a `read` lookup with a different precedence +/// would make one table answer two questions in two orders. +#[must_use] +pub fn resolve_read<'table>(table: &'table [Redirect], path: &str) -> Option<&'table str> { + table + .iter() + .find(|entry| glob_match(&entry.glob, path)) + .and_then(|entry| entry.read.as_deref()) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { @@ -128,6 +193,7 @@ mod tests { Redirect { glob: glob.to_owned(), mutation: mutation.to_owned(), + read: None, } } diff --git a/crates/batten/tests/it/mediated_verbs.rs b/crates/batten/tests/it/mediated_verbs.rs index 6df1819b9..d89d6d49a 100644 --- a/crates/batten/tests/it/mediated_verbs.rs +++ b/crates/batten/tests/it/mediated_verbs.rs @@ -912,3 +912,85 @@ fn a_backslash_continuation_is_one_command_and_is_still_refused() { // escaped backslash, so the line ends and the next one stands alone. assert_denied(&format!("echo done\nrm {GUARDED}")); } + +// --- CLOUD-1258: a generic read of a memory names `read_memory` --------------- +// +// The third face of the object CLOUD-185 and CLOUD-864 closed the other two of. +// `no-tool-substitution` decides over shell argv, so a structured-tool call is +// invisible to it; `protected` crossed with `[[verb]]` enumerates mutations. + +/// A `Read` tool call naming a path, as a host sends it. +fn read_payload(path: &str) -> String { + let escaped = serde_json::to_string(path).expect("a path is encodable"); + format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Read\",\ + \"tool_input\":{{\"file_path\":{escaped}}}}}" + ) +} + +fn read_verdict(path: &str) -> Option { + run_with_stdin( + &root(), + &["hook", "--harness", "exit-code"], + &read_payload(path), + ) + .status + .code() +} + +#[test] +fn a_generic_read_of_a_memory_is_refused_and_names_the_tool_that_answers() { + assert_eq!( + read_verdict(GUARDED), + Some(2), + "a generic file read of a memory is refused" + ); + let refusal = stderr(&run_with_stdin( + &root(), + &["hook", "--harness", "exit-code"], + &read_payload(GUARDED), + )); + // Pointer-only: the path and the class, never a byte of the memory. + assert!(refusal.contains(GUARDED), "names the path: {refusal}"); + // The remedy is one hop away since CLOUD-1286, and it must reach the READ + // tool rather than the mutation tools — the whole content of this row. + let explained = run(&root(), &["policy", "explain", "protected-mutation"]); + assert_eq!(explained.status.code(), Some(0), "the gate resolves"); + assert!( + String::from_utf8_lossy(&explained.stdout).contains("read_memory"), + "and the hop names the tool that answers a memory read" + ); +} + +#[test] +fn a_generic_read_of_an_ordinary_path_is_untouched() { + // THE LOAD-BEARING HALF. This fires on a READ rather than a write, and a + // noisy deny stops the fleet — a gate refusing ordinary reads is one people + // switch off within a day. + assert_eq!(read_verdict("crates/batten/src/hook.rs"), Some(0)); + assert_eq!(read_verdict(AUTHORITY), Some(0)); + assert_eq!(read_verdict("policy/shell-retirement.rego"), Some(0)); +} + +#[test] +fn a_class_declaring_no_read_remedy_gates_no_read() { + // THE "SESSION DOES NOT OFFER IT" ARM, which is what keeps this from being + // CLOUD-998's defect one layer over. `batten.toml` is a protected path whose + // `[[redirect]]` row declares a mutation and no `read`, so reading it is + // allowed — and that is the same shape a consumer without Serena is in. + assert_eq!(read_verdict(AUTHORITY), Some(0)); + assert_eq!(read_verdict(".github/workflows/ci.yml"), Some(0)); +} + +#[test] +fn a_write_to_a_memory_is_still_refused_as_a_write() { + // The mutation half is unchanged: the read row rides the same table and must + // not displace what was already refused. + let payload = format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Write\",\ + \"tool_input\":{{\"file_path\":{}}}}}", + serde_json::to_string(GUARDED).expect("a path is encodable") + ); + let run = run_with_stdin(&root(), &["hook", "--harness", "exit-code"], &payload); + assert_eq!(run.status.code(), Some(2), "a write is still a write"); +} diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index c6f99c4af..437f7842d 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -537,6 +537,13 @@ "mutation": { "description": "The sanctioned mutation for that class — the \"run this instead\" a deny\ncarries.", "type": "string" + }, + "read": { + "description": "The sanctioned READ for that class, and the whole of the read-side gate\n(CLOUD-1258).\n\n# Why a read is gated at all\n\n`no-tool-substitution` is `kind = \"pipeline\"` and decides over shell\nargv, so a structured-tool call is invisible to it; `protected` crossed\nwith `[[verb]]` enumerates MUTATIONS, and CLOUD-442's port states \"reads\nstay allowed\". So a generic file read of a memory was gated by nothing,\nmeasured 2026-08-31 with the Serena server healthy and `read_memory`\nloadable. That is not a style preference: a path read couples the caller\nto the tree layout, and CLOUD-868 proposes moving that tree — `read_memory`\nsurvives the move and a hardcoded path does not, so every flat-path read\nis an unmigrated call site against it.\n\n# OPTIONAL, AND THE ABSENCE IS THE \"SESSION DOES NOT OFFER IT\" ARM\n\nThe row demands that the gate allow where the tool is not available,\nbecause a redirect naming a tool the session does not carry is CLOUD-998's\ndefect one layer over. The boundary cannot see whether an MCP server is\nhealthy — that is not a fact any mediated payload carries — so the\nquestion is answered where it IS decidable: the consumer declares the\nread remedy, and a consumer without the tool declares none and is refused\nnothing. The remedy is therefore a string its own author wrote, which is\nthe strongest available guarantee that it names something reachable.\n\nA row with no `read` key gates nothing on the read side and keeps its\nmutation half exactly as before.", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false, diff --git a/schema/batten.schema.json b/schema/batten.schema.json index d90e1549b..1db428c0d 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -2243,6 +2243,13 @@ "mutation": { "description": "The sanctioned mutation for that class — the \"run this instead\" a deny\ncarries.", "type": "string" + }, + "read": { + "description": "The sanctioned READ for that class, and the whole of the read-side gate\n(CLOUD-1258).\n\n# Why a read is gated at all\n\n`no-tool-substitution` is `kind = \"pipeline\"` and decides over shell\nargv, so a structured-tool call is invisible to it; `protected` crossed\nwith `[[verb]]` enumerates MUTATIONS, and CLOUD-442's port states \"reads\nstay allowed\". So a generic file read of a memory was gated by nothing,\nmeasured 2026-08-31 with the Serena server healthy and `read_memory`\nloadable. That is not a style preference: a path read couples the caller\nto the tree layout, and CLOUD-868 proposes moving that tree — `read_memory`\nsurvives the move and a hardcoded path does not, so every flat-path read\nis an unmigrated call site against it.\n\n# OPTIONAL, AND THE ABSENCE IS THE \"SESSION DOES NOT OFFER IT\" ARM\n\nThe row demands that the gate allow where the tool is not available,\nbecause a redirect naming a tool the session does not carry is CLOUD-998's\ndefect one layer over. The boundary cannot see whether an MCP server is\nhealthy — that is not a fact any mediated payload carries — so the\nquestion is answered where it IS decidable: the consumer declares the\nread remedy, and a consumer without the tool declares none and is refused\nnothing. The remedy is therefore a string its own author wrote, which is\nthe strongest available guarantee that it names something reachable.\n\nA row with no `read` key gates nothing on the read side and keeps its\nmutation half exactly as before.", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false, From 0d786e1acfb002bc3399748d08fef77d46e9c49d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 15:32:52 +0000 Subject: [PATCH 10/20] fix(rules): derive the mediated column refusal from the fact model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six hand-written blocks refused six columns on a `mediated_call` row, each as its own `if self.scope == MediatedCall && !self..is_empty()`. The second one's comment recorded how the list grew: "`sources` and `lines` are tree columns for the same reason `documents` is, and they were added to `permits` without this — so a mediated-call row could declare either and have it silently never read." So the list was ALREADY KNOWN to lag the struct, and it still did — by fourteen columns. `git`, `refs`, `ranges`, `commits`, `staged`, `history`, `state`, `forge`, `tools`, `captured`, `landing`, `delta_sources`, `external` and `symbols` each parsed, loaded, were never acquired for that scope, and left the module reading the corresponding `input.tree.*` key deciding nothing — CLOUD-845's dead-gate class one layer above the module. Adding fourteen more names is the wrong fix and is the mechanism that produced the gap. `COLUMN_CENSUS` says which `Fact` each column declares, `Fact::class().surface` says where that fact resolves, and the two answer for a column added tomorrow with no edit here. A fact RECLASSIFIED onto the hook surface also stops being refused, which a list could not manage in either direction. The census carries a verdict for every one of the 81 `Rule` columns — a fact, or not fact-bearing with the reason — and `every_rule_column_carries_a_fact_verdict` reads the field list off the struct's own source, so the table cannot fall behind it silently. That is the arm that makes the derivation asserted rather than claimed, and it is `trust.rs`'s `CENSUS` shape reused deliberately. `requires_path` is recorded not fact-bearing rather than left out: it is a `try_exists` per entry, acquires no `Fact`, and is permitted on every kind by design. Naming it is the difference between "considered" and "not covered", which an omission cannot express. THE CENSUS OF COMMITTED ROWS, which is why this could not be verified by running the suite alone: no row in `batten.toml` and none in `batten.example.toml` is refused by the change — the whole suite loads both. One TEST FIXTURE was refused, correctly: `one_external_id_names_one_file` built a row at the policy kind's first scope and declared `external`, so it now pins tree scope explicitly, or it would pass for the wrong reason and stop saying anything about id uniqueness. Three cases over the compiled binary and through the real loader, since the acceptance is about what a consumer's `batten.toml` may declare. They assert the refusal TEXT rather than the exit code, and that is not a weakening: the fixtures name a module that does not exist, so all three exit 1 for a second reason and a case reading the code would pass on the missing module while saying nothing about the column. Both anti-vacuity halves are there — the same column at tree scope, and a mediated row declaring only hook-resolvable columns. Refs: CLOUD-1282 --- crates/batten/src/rules.rs | 671 ++++++++++++++++++++++++-- crates/batten/tests/it/policy_tree.rs | 100 ++++ 2 files changed, 723 insertions(+), 48 deletions(-) diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 2a6b45684..235888276 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -2950,6 +2950,451 @@ impl Direction { } } +/// One `Rule` column and what the fact model says about it (CLOUD-1282). +/// +/// # Why a census rather than a list of tree-only columns +/// +/// A list of the columns to refuse is exactly what stood here before, and its +/// own comment recorded it lagging the struct. A census answers for EVERY field, +/// so a column added to `Rule` fails +/// [`tests::every_rule_column_carries_a_fact_verdict`] until somebody decides +/// what it declares — the same shape [`crate::trust::CENSUS`] uses, and for the +/// same reason: the field list is read off the struct's own source, so the table +/// cannot silently fall behind it. +#[derive(Debug, Clone, Copy)] +pub struct ColumnCensus { + /// The column, spelled as `Rule` declares it. + pub field: &'static str, + /// What it says about fact acquisition. + pub declares: Declares, +} + +/// A column's verdict in [`COLUMN_CENSUS`]: it declares a fact, or it does not. +#[derive(Debug, Clone, Copy)] +pub enum Declares { + /// The column declares acquisition of this fact, and the closure answers + /// whether a given row actually declared anything in it. + /// + /// The closure is what makes the derivation possible at all: a census of + /// names could say WHICH columns are tree-only and not whether THIS row + /// declared one, and the refusal has to name a column the author wrote. + Fact(crate::facts::Fact, fn(&Rule) -> bool), + /// The column acquires no fact, with the reason — a selector, a remedy, a + /// severity, a shape. Recorded rather than omitted, because "not covered" + /// and "considered and not fact-bearing" are the two readings a silent + /// absence cannot be told apart from. + NotFactBearing(&'static str), +} + +/// One column named as declaring a fact that this scope cannot resolve. +#[derive(Debug, Clone, Copy)] +pub struct TreeOnlyColumn { + /// The column the author wrote. + pub field: &'static str, + /// The fact it declares. + pub fact: crate::facts::Fact, +} + +/// Every `Rule` column, with what it declares. +/// +/// **Read the surface off the fact, never off this table.** A row names a +/// [`crate::facts::Fact`] and nothing else; whether that fact reaches the +/// mediated call is `Fact::class().surface`'s answer, so reclassifying a fact +/// moves this refusal with it and cannot leave the two disagreeing. +pub const COLUMN_CENSUS: &[ColumnCensus] = &[ + ColumnCensus { + field: "id", + declares: Declares::NotFactBearing("the row's own name"), + }, + ColumnCensus { + field: "kind", + declares: Declares::NotFactBearing("which predicate decides the row"), + }, + ColumnCensus { + field: "glob", + declares: Declares::NotFactBearing( + "selects files; the tree walk is the engine's, not a declared fact", + ), + }, + ColumnCensus { + field: "severity", + declares: Declares::NotFactBearing("the exit contract"), + }, + ColumnCensus { + field: "scope", + declares: Declares::NotFactBearing( + "the surface this row is judged on — the question, never an answer to it", + ), + }, + ColumnCensus { + field: "pattern", + declares: Declares::NotFactBearing("a literal the predicate matches"), + }, + ColumnCensus { + field: "regex", + declares: Declares::NotFactBearing("a pattern the predicate matches"), + }, + ColumnCensus { + field: "exclude", + declares: Declares::NotFactBearing("narrows a match"), + }, + ColumnCensus { + field: "content", + declares: Declares::NotFactBearing("a literal the predicate matches"), + }, + ColumnCensus { + field: "tool", + declares: Declares::NotFactBearing( + "selects on the call's own tool name, which the envelope carries", + ), + }, + ColumnCensus { + field: "when_absent", + declares: Declares::NotFactBearing("a condition over a fact another column declared"), + }, + ColumnCensus { + field: "key_from", + declares: Declares::NotFactBearing("names where a receipt's key comes from"), + }, + ColumnCensus { + field: "when_value", + declares: Declares::NotFactBearing("a condition over a fact another column declared"), + }, + ColumnCensus { + field: "key_shape", + declares: Declares::NotFactBearing("the shape a key must have"), + }, + ColumnCensus { + field: "max_age", + declares: Declares::NotFactBearing("a bound on a receipt another column declared"), + }, + ColumnCensus { + field: "requires_field", + declares: Declares::NotFactBearing("a condition over the call's own payload"), + }, + ColumnCensus { + field: "when_present", + declares: Declares::NotFactBearing("a condition over a fact another column declared"), + }, + ColumnCensus { + field: "measures", + declares: Declares::NotFactBearing( + "names a projection of the call, which the envelope carries", + ), + }, + ColumnCensus { + field: "counts", + declares: Declares::NotFactBearing("a condition over a fact another column declared"), + }, + ColumnCensus { + field: "max", + declares: Declares::NotFactBearing("a ceiling on a measurement"), + }, + ColumnCensus { + field: "resolves", + declares: Declares::NotFactBearing("a condition over a fact another column declared"), + }, + ColumnCensus { + field: "contains", + declares: Declares::NotFactBearing("a condition over a fact another column declared"), + }, + ColumnCensus { + field: "require_via", + declares: Declares::NotFactBearing("narrows how a requirement may be met"), + }, + ColumnCensus { + field: "requires_key", + // Hook-surface: the boundary already resolves the key evidence for the + // typed rule table, so a mediated row declaring it is asking for + // something it is handed. + declares: Declares::Fact(crate::facts::Fact::Keys, |rule| rule.requires_key.is_some()), + }, + ColumnCensus { + field: "reason", + declares: Declares::NotFactBearing("the consumer's remedy prose"), + }, + ColumnCensus { + field: "policy_url", + declares: Declares::NotFactBearing("a pointer a refusal carries"), + }, + ColumnCensus { + field: "bypass_env", + declares: Declares::NotFactBearing("names this row's hatch"), + }, + ColumnCensus { + field: "check", + declares: Declares::NotFactBearing( + "the command a `command` row runs; a spawn, which no mediated kind may do", + ), + }, + ColumnCensus { + field: "fix", + declares: Declares::NotFactBearing("the command a `command` row repairs with"), + }, + ColumnCensus { + field: "produces", + declares: Declares::Fact(crate::facts::Fact::Produced, |rule| rule.produces.is_some()), + }, + ColumnCensus { + field: "exclude_paths", + declares: Declares::NotFactBearing("narrows `glob`"), + }, + ColumnCensus { + field: "run", + declares: Declares::NotFactBearing("the command a row runs"), + }, + ColumnCensus { + field: "verbatim", + declares: Declares::NotFactBearing("normalisation of a hashed span"), + }, + ColumnCensus { + field: "identity_key", + declares: Declares::NotFactBearing("how a finding is identified across runs"), + }, + ColumnCensus { + field: "direction", + declares: Declares::NotFactBearing("which way a ratchet turns"), + }, + ColumnCensus { + field: "base", + declares: Declares::NotFactBearing("names the ref a ratchet compares against"), + }, + ColumnCensus { + field: "retires_with", + declares: Declares::NotFactBearing("an admission's shape"), + }, + ColumnCensus { + field: "conserves", + declares: Declares::NotFactBearing("an obligation inside an admission"), + }, + ColumnCensus { + field: "admits_with", + declares: Declares::NotFactBearing("an admission's shape on the other side of the count"), + }, + ColumnCensus { + field: "format", + declares: Declares::NotFactBearing( + "how a declared document is parsed, not whether one is acquired", + ), + }, + ColumnCensus { + field: "node", + declares: Declares::NotFactBearing("addresses inside a document another column declared"), + }, + ColumnCensus { + field: "derives", + declares: Declares::NotFactBearing( + "how a value is computed from a fact another column declared", + ), + }, + ColumnCensus { + field: "reads", + declares: Declares::NotFactBearing("addresses inside a fact another column declared"), + }, + ColumnCensus { + field: "predicate_severity", + declares: Declares::NotFactBearing("per-predicate exit contract"), + }, + ColumnCensus { + field: "preset", + declares: Declares::NotFactBearing( + "names a vendored module; the facts are that module's own declarations", + ), + }, + ColumnCensus { + field: "bundle", + declares: Declares::NotFactBearing("names a vendored bundle, as `preset` does"), + }, + ColumnCensus { + field: "documents", + declares: Declares::Fact(crate::facts::Fact::Document, |rule| { + !rule.documents.is_empty() + }), + }, + ColumnCensus { + field: "requires_path", + // A `try_exists` per entry — no glob expansion, no read, nothing + // spawned — and permitted on every kind by design. It is a filesystem + // question and NOT a `Fact`, so the derivation does not reach it. Named + // here rather than left silent, because "the census does not cover it" + // and "the census considered it" are the two readings an omission + // cannot be told apart from. + declares: Declares::NotFactBearing( + "a `try_exists` per declared entry, permitted on every kind and acquiring no fact", + ), + }, + ColumnCensus { + field: "sources", + declares: Declares::Fact(crate::facts::Fact::Document, |rule| { + !rule.sources.is_empty() + }), + }, + ColumnCensus { + field: "lines", + declares: Declares::Fact(crate::facts::Fact::Lines, |rule| !rule.lines.is_empty()), + }, + ColumnCensus { + field: "line_sources", + declares: Declares::Fact(crate::facts::Fact::Lines, |rule| { + !rule.line_sources.is_empty() + }), + }, + ColumnCensus { + field: "invocations", + declares: Declares::Fact(crate::facts::Fact::Invocations, |rule| { + !rule.invocations.is_empty() + }), + }, + ColumnCensus { + field: "invocation_sources", + declares: Declares::Fact(crate::facts::Fact::Invocations, |rule| { + !rule.invocation_sources.is_empty() + }), + }, + ColumnCensus { + field: "uses", + declares: Declares::Fact(crate::facts::Fact::Uses, |rule| !rule.uses.is_empty()), + }, + ColumnCensus { + field: "use_sources", + declares: Declares::Fact(crate::facts::Fact::Uses, |rule| { + !rule.use_sources.is_empty() + }), + }, + ColumnCensus { + field: "git", + // The head/status/remote family. All three are `Surface::Check`, so the + // column is tree-only whichever read a row asked for and one fact + // answers for it. + declares: Declares::Fact(crate::facts::Fact::GitHead, |rule| !rule.git.is_empty()), + }, + ColumnCensus { + field: "symbols", + declares: Declares::Fact(crate::facts::Fact::Symbols, |rule| rule.symbols), + }, + ColumnCensus { + field: "refs", + declares: Declares::Fact(crate::facts::Fact::GitRef, |rule| !rule.refs.is_empty()), + }, + ColumnCensus { + field: "ranges", + declares: Declares::Fact(crate::facts::Fact::GitRange, |rule| !rule.ranges.is_empty()), + }, + ColumnCensus { + field: "commits", + declares: Declares::Fact(crate::facts::Fact::CommitMeta, |rule| { + !rule.commits.is_empty() + }), + }, + ColumnCensus { + field: "staged", + declares: Declares::Fact(crate::facts::Fact::Staged, |rule| !rule.staged.is_empty()), + }, + ColumnCensus { + field: "history", + declares: Declares::Fact(crate::facts::Fact::GitHistory, |rule| { + !rule.history.is_empty() + }), + }, + ColumnCensus { + field: "state", + declares: Declares::Fact(crate::facts::Fact::State, |rule| !rule.state.is_empty()), + }, + ColumnCensus { + field: "forge", + declares: Declares::Fact(crate::facts::Fact::Forge, |rule| !rule.forge.is_empty()), + }, + ColumnCensus { + field: "tools", + declares: Declares::Fact(crate::facts::Fact::ToolVerdict, |rule| { + !rule.tools.is_empty() + }), + }, + ColumnCensus { + field: "captured", + declares: Declares::Fact(crate::facts::Fact::Captured, |rule| { + !rule.captured.is_empty() + }), + }, + ColumnCensus { + field: "tasks", + // Hook-surface (CLOUD-856): read from a receipt minted at session start, + // so the mediated path parses no manifest. A mediated row declaring it + // is asking for something this surface can answer. + declares: Declares::Fact(crate::facts::Fact::Tasks, |rule| !rule.tasks.is_empty()), + }, + ColumnCensus { + field: "extract", + // Hook-surface (CLOUD-1172): a declared extractor's COUNT over this + // session's transcript, an integer over typed events. + declares: Declares::Fact(crate::facts::Fact::Extracted, |rule| { + !rule.extract.is_empty() + }), + }, + ColumnCensus { + field: "landing", + declares: Declares::Fact(crate::facts::Fact::Landing, |rule| !rule.landing.is_empty()), + }, + ColumnCensus { + field: "delta_sources", + declares: Declares::Fact(crate::facts::Fact::BaseDelta, |rule| { + !rule.delta_sources.is_empty() + }), + }, + ColumnCensus { + field: "external", + declares: Declares::Fact(crate::facts::Fact::External, |rule| { + !rule.external.is_empty() + }), + }, + ColumnCensus { + field: "module", + declares: Declares::NotFactBearing( + "names the module file; the facts are that module's own declarations", + ), + }, + ColumnCensus { + field: "no_fix_reason", + declares: Declares::NotFactBearing("states why a row declares no fix"), + }, + ColumnCensus { + field: "checks", + // Hook-surface: the boundary already resolves receipt verdicts for the + // typed rule table, which is what makes `ready-guard` a mediated row. + declares: Declares::Fact(crate::facts::Fact::Receipts, |rule| rule.checks.is_some()), + }, + ColumnCensus { + field: "key", + declares: Declares::NotFactBearing( + "which git fact a receipt is keyed to, not a fact itself", + ), + }, + ColumnCensus { + field: "trigger", + declares: Declares::NotFactBearing("what makes a receipt row fire"), + }, + ColumnCensus { + field: "verdict", + declares: Declares::NotFactBearing("the verdict-bearing programs a pipeline row knows"), + }, + ColumnCensus { + field: "filters", + declares: Declares::NotFactBearing("the filter programs a pipeline row knows"), + }, + ColumnCensus { + field: "substitutes", + declares: Declares::NotFactBearing("the utilities a pipeline row redirects"), + }, + ColumnCensus { + field: "criteria", + declares: Declares::NotFactBearing("what a judge row asks a model"), + }, + ColumnCensus { + field: "tier", + declares: Declares::NotFactBearing("a judge finding's advisory tier"), + }, +]; + impl Rule { /// The program a [`RuleKind::Command`] rule invokes: the first /// whitespace-separated token of [`Rule::check`]. @@ -3504,57 +3949,35 @@ impl Rule { ))); } } - // `documents` is what a TREE row is handed. On the mediated call the - // input is the envelope the boundary already carries, so a `documents` - // list there is a key that parses and is never read — the shape §8 - // refuses everywhere else in this config. - if self.scope == RuleScope::MediatedCall && !self.documents.is_empty() { - return Err(UsageError::raise(format!( - "rule {}: `documents` is what a `scope = \"tree\"` row hands its bundle; \ - on the mediated call the input is the call's own facts, so this list \ - would never be read", - self.id - ))); - } - // `sources` and `lines` are tree columns for the same reason `documents` - // is, and they were added to `permits` without this — so a mediated-call - // row could declare either and have it silently never read. - if self.scope == RuleScope::MediatedCall && !self.sources.is_empty() { - return Err(UsageError::raise(format!( - "rule {}: `sources` is what a `scope = \"tree\"` row hands its bundle; \ - on the mediated call the input is the call's own facts, so this list \ - would never be read", - self.id - ))); - } - if self.scope == RuleScope::MediatedCall && !self.line_sources.is_empty() { - return Err(UsageError::raise(format!( - "rule {}: `line_sources` selects files and a mediated call judges a command, not a tree", - self.id - ))); - } - if self.scope == RuleScope::MediatedCall - && !(self.uses.is_empty() && self.use_sources.is_empty()) - { - return Err(UsageError::raise(format!( - "rule {}: `uses` reads a tree's module graph and a mediated call judges a command, not a tree", - self.id - ))); - } + // THE COLUMN REFUSAL IS DERIVED (CLOUD-1282). Six hand-written blocks + // stood here — `documents`, `sources`, `lines`, `line_sources`, `uses`, + // `invocations` — and the second one's own comment recorded how the list + // grew: "`sources` and `lines` are tree columns for the same reason + // `documents` is, and they were added to `permits` without this." + // + // So the list was already KNOWN to lag the struct, and it still did: + // `git`, `refs`, `ranges`, `commits`, `staged`, `history`, `state`, + // `forge`, `tools`, `captured`, `landing`, `delta_sources`, `external` + // and `symbols` were all declarable on a mediated row, and each parsed, + // loaded, was never acquired for that scope, and left the module reading + // the corresponding `input.tree.*` key deciding nothing — CLOUD-845's + // dead-gate class one layer up. + // + // Adding fourteen more names is the wrong fix and is the mechanism that + // produced the gap. `COLUMN_CENSUS` says which FACT each column + // declares, `Fact::class().surface` says where that fact resolves, and + // the two together answer for a column added tomorrow with no edit here. if self.scope == RuleScope::MediatedCall - && !(self.invocations.is_empty() && self.invocation_sources.is_empty()) + && let Some(column) = self.tree_only_column() { return Err(UsageError::raise(format!( - "rule {}: `invocations` parses tree files and a mediated call judges a command, not a tree", - self.id - ))); - } - if self.scope == RuleScope::MediatedCall && !self.lines.is_empty() { - return Err(UsageError::raise(format!( - "rule {}: `lines` is what a `scope = \"tree\"` row hands its bundle; \ - on the mediated call the input is the call's own facts, so this list \ - would never be read", - self.id + "rule {}: `{}` declares `{}`, a fact resolvable only on the {} surface, and this \ + row is `scope = \"mediated_call\"` — the column would parse, load, and never be \ + acquired, leaving a module that reads it deciding nothing", + self.id, + column.field, + column.fact.as_str(), + column.fact.class().surface.as_str(), ))); } // A MALFORMED SELECTOR IS A CONFIG FAULT, REFUSED HERE. `acquire_declared` @@ -4519,6 +4942,39 @@ impl Rule { self.trigger() } + /// The first column this row declares whose fact the mediated call cannot + /// resolve (CLOUD-1282). + /// + /// Derived rather than listed: [`COLUMN_CENSUS`] says which fact each column + /// declares and [`crate::facts::Fact::class`] says where that fact resolves, + /// so a column added to this struct is covered the day it lands — and a fact + /// RECLASSIFIED onto the hook surface stops being refused here without an + /// edit, which a list could not manage in either direction. + /// + /// `Surface::Hook` is the NARROWEST surface a fact may be resolved on, so it + /// is the one value that reaches the mediated call; every other means the + /// column would parse, load and never be acquired. + /// + /// Census order decides which column a multi-column row is refused for, + /// which is declaration order in the struct — the same tie-break every other + /// table here uses, and the one a reviewer reading top to bottom expects. + #[must_use] + pub fn tree_only_column(&self) -> Option { + COLUMN_CENSUS + .iter() + .find_map(|column| match column.declares { + Declares::Fact(fact, declared) + if fact.class().surface != crate::facts::Surface::Hook && declared(self) => + { + Some(TreeOnlyColumn { + field: column.field, + fact, + }) + } + Declares::Fact(..) | Declares::NotFactBearing(_) => None, + }) + } + /// The command shape this row keys on, for **any** kind that keys on one. /// /// Split out of [`Rule::shape`] because two kinds now match a command line @@ -12028,6 +12484,120 @@ mod tests { } } + /// Every `Rule` column, read off the struct's own source. + fn rule_fields() -> Vec<&'static str> { + let source = include_str!("rules.rs"); + let start = source + .find("pub struct Rule {") + .expect("Rule is declared here"); + let rest = &source[start..]; + let body = &rest[..rest.find("\n}").expect("the struct closes")]; + body.lines() + .filter_map(|line| line.trim().strip_prefix("pub ")) + .filter_map(|rest| rest.split_once(':')) + .map(|(field, _)| field) + .collect() + } + + #[test] + fn every_rule_column_carries_a_fact_verdict() { + // THE ARM THAT MAKES THE DERIVATION ASSERTED RATHER THAN CLAIMED + // (CLOUD-1282). A column added to `Rule` fails here until somebody says + // what it declares, which is the property the six hand-written blocks + // this replaced did not have — their own comment recorded the list + // lagging the struct, and it still did by fourteen columns. + let fields = rule_fields(); + assert!( + fields.len() > 40, + "the struct scan must actually find fields: {fields:?}" + ); + + let missing: Vec<&str> = fields + .iter() + .copied() + .filter(|field| !COLUMN_CENSUS.iter().any(|row| row.field == *field)) + .collect(); + assert!( + missing.is_empty(), + "these `Rule` columns carry no fact verdict: {missing:?}. Say what each one \ + declares — a `Fact`, or not fact-bearing with the reason. Silence is not one \ + of the two, because a tree-only column nobody classified is declarable on a \ + mediated row and read by nothing." + ); + + for row in COLUMN_CENSUS { + assert!( + fields.contains(&row.field), + "the census names `{}`, which `Rule` no longer declares", + row.field + ); + assert_eq!( + COLUMN_CENSUS + .iter() + .filter(|other| other.field == row.field) + .count(), + 1, + "`{}` carries two verdicts; one column, one answer", + row.field + ); + if let Declares::NotFactBearing(reason) = row.declares { + assert!( + !reason.trim().is_empty(), + "`{}` is recorded not fact-bearing for no stated reason", + row.field + ); + } + } + } + + #[test] + fn the_derivation_reaches_the_columns_the_hand_written_list_never_did() { + // The fourteen columns that were declarable on a mediated row and read + // by nothing. Asserted through `tree_only_column` rather than through + // `validate`, so the case names the COLUMN it is about and a refusal for + // some other reason cannot make it pass. + let mut rule = blank("probe", RuleKind::Policy); + rule.scope = RuleScope::MediatedCall; + rule.module = Some(String::from("probe.rego")); + assert!( + rule.tree_only_column().is_none(), + "a mediated row declaring no fact column is clean" + ); + + rule.refs = vec![String::from("refs/heads/main")]; + assert_eq!( + rule.tree_only_column().map(|column| column.field), + Some("refs"), + "`refs` was never in the hand-written list" + ); + rule.refs.clear(); + + rule.symbols = true; + assert_eq!( + rule.tree_only_column().map(|column| column.field), + Some("symbols") + ); + rule.symbols = false; + + rule.landing = vec![String::from("origin/main")]; + assert_eq!( + rule.tree_only_column().map(|column| column.field), + Some("landing") + ); + rule.landing.clear(); + + // THE ANTI-VACUITY MIRROR, and it is the half that decides whether this + // is a derivation or a blanket refusal: `checks` is a `Surface::Hook` + // fact, so a mediated row declaring it is asking for something the + // boundary already resolved — which is what makes `ready-guard` a + // mediated row at all. + rule.checks = Some(vec![String::from("verify")]); + assert!( + rule.tree_only_column().is_none(), + "a hook-resolvable column on a mediated row is not this refusal's business" + ); + } + #[test] fn one_external_id_names_one_file() { // Acquisition caches by id across the whole rule set, so two rows @@ -12036,6 +12606,11 @@ mod tests { // actually reads. `Wanted` records the same lesson one family over, // where keying a cache on the path alone starved the losing row. let mut rule = blank("two-answers", RuleKind::Policy); + // TREE SCOPE EXPLICITLY (CLOUD-1282). `external` is a `Surface::Check` + // fact, so a mediated row declaring it is now refused for that reason + // alone — which would make this case pass for the wrong one and stop + // saying anything about id uniqueness. + rule.scope = RuleScope::Tree; rule.module = Some(String::from("probe.rego")); rule.external = vec![ crate::facts::Rooted { diff --git a/crates/batten/tests/it/policy_tree.rs b/crates/batten/tests/it/policy_tree.rs index 6e2bba69c..4e9f9b0c9 100644 --- a/crates/batten/tests/it/policy_tree.rs +++ b/crates/batten/tests/it/policy_tree.rs @@ -876,3 +876,103 @@ violation contains {"rule": "closes-a-key", "verdict": "closes no key"} if { cleared.findings ); } + +// --- CLOUD-1282: the mediated column refusal, derived ------------------------ +// +// Over the compiled binary and through the real loader, because the acceptance +// is about what a CONSUMER's `batten.toml` may declare — a struct-literal case +// would bypass the deserialisation this is a property of. + +/// A row as a consumer writes it, loaded through `batten check`. +/// +/// Per-case fixture names, because `scratch` wipes the directory it returns and +/// two cases sharing one name delete each other's tree mid-run — the hazard that +/// helper's own doc records. +fn loads(name: &str, config: &str) -> String { + let dir = common::Fixture::new(name) + .config(config) + .git() + .base_commit() + .build(); + common::stderr(&common::run(&dir, &["check"])) +} + +/// The refusal this row is about, as a substring a case can look for. +/// +/// Asserted on the TEXT rather than on the exit code, and that is not a +/// weakening: these fixtures name a `probe.rego` that does not exist, so every +/// one of them exits 1 for a second reason. A case reading the code alone would +/// pass on the missing module and say nothing about the column. +fn refuses_the_column(text: &str, column: &str) -> bool { + text.contains(&format!("`{column}` declares")) +} + +const TREE_ROW: &str = r#"version = 1 + +[[rule]] +id = "probe" +kind = "policy" +scope = "tree" +module = "probe.rego" +refs = ["refs/heads/main"] +severity = "deny" +"#; + +const MEDIATED_ROW: &str = r#"version = 1 + +[[rule]] +id = "probe" +kind = "policy" +scope = "mediated_call" +module = "probe.rego" +refs = ["refs/heads/main"] +severity = "deny" +"#; + +const MEDIATED_HOOK_COLUMN: &str = r#"version = 1 + +[[rule]] +id = "probe" +kind = "policy" +scope = "mediated_call" +module = "probe.rego" +severity = "deny" +"#; + +#[test] +fn a_mediated_row_declaring_a_tree_only_column_is_refused_naming_it() { + // RED AGAINST THE UNFIXED BINARY: `refs` was never in the hand-written list, + // so this row loaded, was never acquired, and left a module reading + // `input.tree["git-refs"]` deciding nothing. + let text = loads("cloud-1282-mediated", MEDIATED_ROW); + assert!( + refuses_the_column(&text, "refs"), + "names the column: {text}" + ); + assert!( + text.contains("mediated_call"), + "and the scope that cannot resolve it: {text}" + ); +} + +#[test] +fn the_same_column_at_tree_scope_still_loads() { + // ANTI-VACUITY, half one. A derivation that refused the column everywhere + // would satisfy the case above and break every tree row that declares one. + let text = loads("cloud-1282-tree", TREE_ROW); + assert!( + !refuses_the_column(&text, "refs"), + "a tree row declaring a tree fact is exactly what the column is for: {text}" + ); +} + +#[test] +fn a_mediated_row_declaring_only_hook_resolvable_columns_still_loads() { + // ANTI-VACUITY, half two: the refusal is about WHICH fact, not about the + // scope. A mediated policy row is legal and must stay so. + let text = loads("cloud-1282-hook", MEDIATED_HOOK_COLUMN); + assert!( + !text.contains("declares"), + "a mediated row declaring no tree fact is not this refusal's business: {text}" + ); +} From 942ca55e22c8e693d035f942ad43a40a51b22298 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 15:40:53 +0000 Subject: [PATCH 11/20] fix(receipt): a branch behind its own receipt is not a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `branch_validity` voided a branch-keyed receipt on `own == 0 && recorded != head`. CLOUD-516's table justifies that for ONE situation — `git checkout -B origin/main` after a merge, where the branch moved forward off a base the receipt predates and the commits that were the branch are gone. The comparison is symmetric and the situation is not. It fired just as readily when the receipt's base was NEWER than HEAD: a branch cut from an earlier main and not yet rebased, which is the ordinary state of a freshly-cut branch in a repository landing ~40 commits a day. The receipt is then MORE current than the branch, not less. Measured 2026-08-28 on `claude/stage-2-3-grooming-uqk71k`: an `issue-search` receipt recording the current `origin/main` was voided, and the remedy the refusal prescribes cannot clear it — the receipt is append-only and `recorded_base` correctly reads the last `base` line, so a fresh search appends a fresh, correct entry and the comparison still runs against HEAD. Three searches, three identical refusals. The loop broke only on a fast-forward merge, which the message never asks for. The direction is the missing conjunct: void only where HEAD carries commits the recorded base does not. Expressed as a RANGE COUNT rather than a reachability verdict, which is what CLOUD-36 leaves legal and what `own_commit_count` already does one function up — selecting which commits to count is a different act from concluding that one commit contains another. Could-not-look stays VOID. This widens what passes only where the repository can be read, so an unreadable range can never be the thing that admits a claim that expired. Four cases, and the discriminators are the point: the behind-HEAD receipt is honoured (red against the predicate this replaces); CLOUD-516's restart is STILL void, and now for the direction rather than for could-not-look, which is what keeps a change that always returned `Valid` from passing; a base equal to HEAD short-circuits before the range is read; and an unreadable range stays void. The range reader is injected, because a scratch directory is not a repository and a case reading the real range would pass for the wrong reason. The `receipt read stale` class said "the check ran against an `origin/main` that has since advanced… Rebase and re-run", which is the direction that is now measured false and the one action guaranteed not to work. Corrected to name the restart, which is what the predicate now refuses, and to say outright that a branch merely behind keeps its receipt. `Validity`'s own doc called it "the four states" over six variants. Fixed in passing; a count in prose beside the thing it counts is drift waiting to happen, and the two staleness variants are exactly the pair a reader assumes away. Refs: CLOUD-1091 --- crates/batten/src/receipt.rs | 158 ++++++++++++++++++++++++++++++++--- crates/batten/src/verdict.rs | 13 ++- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/crates/batten/src/receipt.rs b/crates/batten/src/receipt.rs index b33158694..c9b9602f4 100644 --- a/crates/batten/src/receipt.rs +++ b/crates/batten/src/receipt.rs @@ -315,7 +315,12 @@ pub struct IdentityRef { pub version: String, } -/// The receipt-validity verdict — the four states of the output contract. +/// The receipt-validity verdict — the SIX states of the output contract. +/// +/// It said "four" for its whole life while six variants stood below it +/// (CLOUD-1091, fixed in passing). A count in prose beside the thing it counts +/// is the drift `.claude/rules/toolchain.md` records for its own tables, and the +/// two staleness variants are exactly the pair a reader would assume away. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Validity { /// The receipt exists, was taken in this checkout, and both recorded refs @@ -943,14 +948,28 @@ fn named_validity(git_dir: &str, check: &str, subject: &str) -> Validity { /// ([`Validity::StaleMain`]) when **both** halves hold: /// /// ```text -/// situation base moved own commits verdict +/// situation base moved own ahead verdict /// --------------------------------------------------------------------------------- -/// claim, then work no 0 -> n valid -/// a lap rebases onto newer main yes >=1 VALID -/// main moves, branch untouched yes >=1 valid -/// checkout -B origin/main after a merge yes 0 VOID +/// claim, then work no 0->n - valid +/// a lap rebases onto newer main yes >=1 - VALID +/// main moves, branch untouched yes >=1 - valid +/// checkout -B origin/main after a merge yes 0 >0 VOID +/// branch cut from an earlier main, unrebased yes 0 0 VALID /// ``` /// +/// **The last row is CLOUD-1091 and it is the one this comparison got wrong for +/// its whole life.** `recorded != head` is symmetric and the situation is not: +/// it fires just as readily when the receipt's base is NEWER than HEAD, which is +/// an ordinary freshly-cut branch in a repository landing ~40 commits a day. The +/// receipt is then more current than the branch, and the remedy the refusal +/// prescribes — take the evidence again — appends a correct base and changes +/// nothing, because the comparison still runs against HEAD. Measured +/// 2026-08-28: three honest searches, three identical refusals, cleared only by +/// a fast-forward merge the message never mentions. +/// +/// So the conjunct is the DIRECTION: void only where HEAD carries commits the +/// recorded base does not, which is the restart and nothing else. +/// /// **The own-commits half is what makes this safe to ship.** The obvious rule — /// void it whenever the base moves — fires on every `land` lap, because a lap /// rebases onto the current `origin/main` and that is the loop working, not a @@ -971,6 +990,31 @@ fn named_validity(git_dir: &str, check: &str, subject: &str) -> Validity { /// Read from `--git-dir`, so it resolves per worktree and one worktree's claim /// cannot vouch for another's. fn branch_validity(git_dir: &str, check: &str, branch: &str, head: &str, own: usize) -> Validity { + branch_validity_with(git_dir, check, branch, head, own, moved_forward) +} + +/// How many commits `head` carries that `base` does not, or `None` for "could +/// not look". +/// +/// A RANGE, exactly as [`own_commit_count`] is one, and legal for its reason: +/// CLOUD-36 forbids concluding that one commit CONTAINS another, and leaves +/// range forms alone because selecting which commits to count is a different act +/// from drawing that conclusion. This counts; it concludes nothing about +/// landing. +fn moved_forward(base: &str, head: &str) -> Option { + git::commit_count(Path::new("."), &format!("{base}..{head}")).ok() +} + +/// [`branch_validity`] with the range reader injected, so the direction below is +/// testable without a repository per case. +fn branch_validity_with( + git_dir: &str, + check: &str, + branch: &str, + head: &str, + own: usize, + ahead: impl Fn(&str, &str) -> Option, +) -> Validity { let path = Path::new(git_dir) .join("batten-receipts") .join(branch_receipt_name(check, branch)); @@ -983,10 +1027,34 @@ fn branch_validity(git_dir: &str, check: &str, branch: &str, head: &str, own: us let Some(recorded) = recorded else { return Validity::StaleMain; }; - if own == 0 && recorded != head { - return Validity::StaleMain; + if own != 0 || recorded == head { + return Validity::Valid; + } + // THE DIRECTION IS THE MISSING CONJUNCT (CLOUD-1091). `recorded != head` is + // symmetric and the situation is not: + // + // * CLOUD-516's RESTART — `checkout -B origin/main` after a merge — + // moves the branch FORWARD off a base the receipt predates. `recorded` + // is an older main, so `head` carries commits `recorded` does not, and + // the receipt describes work that is gone. Void. + // * A branch merely BEHIND `origin/main` — cut from an earlier main and + // not yet rebased — has a receipt taken against the CURRENT main, so + // `head` carries nothing `recorded` does not. The receipt is MORE + // current than the branch, not less. Valid. + // + // Measured 2026-08-28 on `claude/stage-2-3-grooming-uqk71k`: an + // `issue-search` receipt recording the current `origin/main` was voided, and + // three fresh searches refused identically, because the remedy the refusal + // prescribes appends a correct base that the comparison then ignores. The + // loop broke only on a fast-forward merge, which the message never asks for. + // + // Could-not-look stays VOID rather than becoming valid: this change widens + // what passes only where the repository can actually be read, so an + // unreadable range cannot be the thing that admits a stale claim. + match ahead(&recorded, head) { + Some(0) => Validity::Valid, + Some(_) | None => Validity::StaleMain, } - Validity::Valid } /// The `base ` line a claim receipt carries, or `None` when it carries none. @@ -1883,6 +1951,33 @@ mod tests { body } + /// The range reader every case supplies for itself (CLOUD-1091). + /// + /// Injected rather than resolved, because the DIRECTION is now the predicate + /// and a scratch directory is not a repository — a case reading the real + /// range would get could-not-look and pass for that reason instead of for + /// the one it is about. + fn judge_ahead( + case: &str, + body: &str, + head: &str, + own: usize, + ahead: Option, + ) -> Validity { + let dir = tempdir(case); + let receipts = dir.join("batten-receipts"); + std::fs::create_dir_all(&receipts).expect("create the store"); + std::fs::write(receipts.join("claim.branch"), body).expect("mint"); + branch_validity_with( + dir.to_str().expect("utf-8 scratch path"), + "claim", + "branch", + head, + own, + |_, _| ahead, + ) + } + fn judge(case: &str, body: &str, head: &str, own: usize) -> Validity { let dir = tempdir(case); let receipts = dir.join("batten-receipts"); @@ -1903,8 +1998,51 @@ mod tests { // moved AND the branch carries nothing of its own, which is what // `git checkout -B origin/main` after a merge produces and what // nothing else produces. + // AHEAD BY ONE is what a restart looks like: `head` carries a commit + // the recorded base does not, because the base is the OLDER main the + // receipt was taken against. That is the conjunct CLOUD-1091 added, and + // this case is what keeps the widening from swallowing this row. + assert_eq!( + judge_ahead("restart", &minted(Some("aaa")), "bbb", 0, Some(1)), + Validity::StaleMain + ); + } + + #[test] + fn a_branch_behind_its_own_receipt_keeps_it() { + // CLOUD-1091, and RED against the predicate this replaces: `recorded != + // head` fired here just as readily as on the restart above, because the + // comparison is symmetric and the situation is not. + // + // AHEAD BY ZERO is what "behind" looks like from the receipt's side: + // the base is the CURRENT `origin/main`, `head` is an older commit, so + // `head` carries nothing the base does not. The receipt is more current + // than the branch. + assert_eq!( + judge_ahead("behind", &minted(Some("bbb")), "aaa", 0, Some(0)), + Validity::Valid + ); + } + + #[test] + fn a_base_equal_to_head_needs_no_range_at_all() { + // The third case, and it is the cheap one: equality short-circuits + // before the range is read, so an unreadable repository cannot make the + // ordinary claim-then-work state look stale. + assert_eq!( + judge_ahead("equal", &minted(Some("aaa")), "aaa", 0, None), + Validity::Valid + ); + } + + #[test] + fn a_range_nobody_can_read_stays_void() { + // The direction the widening must NOT take. This change admits more only + // where the repository can actually be read; could-not-look keeps the + // verdict it had, so an unreadable range can never be the thing that + // admits a claim that expired. assert_eq!( - judge("restart", &minted(Some("aaa")), "bbb", 0), + judge_ahead("unreadable", &minted(Some("aaa")), "bbb", 0, None), Validity::StaleMain ); } diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index aac2227d6..d5b1f17c1 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1244,10 +1244,15 @@ the wrong repair.", VendoredVerdict { id: "receipt read stale", gloss: "the receipt was taken against a trunk this branch has moved off", - class: "The check ran against an `origin/main` that has since advanced, so its \ -verdict is about a base this branch no longer sits on. Rebase and re-run. Distinct from \ -the amend case: there the branch's own bytes changed, here the trunk under them did, and \ -only one of the two is fixed by rebasing.", + class: "The branch MOVED FORWARD off the base the receipt was taken against -- \ +`git checkout -B origin/main` after a merge, which repoints the name at a new base \ +and discards the commits that were the branch, while the receipt survives keyed by that \ +name. Re-take the evidence on the branch as it now stands. Distinct from the amend case: \ +there the branch's own bytes changed, here the branch is a different branch wearing the \ +same name. THE DIRECTION IS THE PREDICATE (CLOUD-1091): a branch merely BEHIND the trunk \ +keeps its receipt, because the receipt is then more current than the branch and no amount \ +of re-taking it can help -- this text said the opposite for its whole life and prescribed \ +the one action guaranteed not to work.", routes: &[read("config read first", "batten.toml")], }, VendoredVerdict { From 8ce896c765c97a4e5803346858f812b5909b6903 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 16:28:20 +0000 Subject: [PATCH 12/20] feat(hook): one budget for the advisory channel, not per producer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hookSpecificOutput.additionalContext` has three producers now — `drain::render`, `contract::render` and the dispatched handler CLOUD-898 added — and the only declared number was `[drain] token_budget`, which bounds ONE of them. CLOUD-461 coalesced the framing: one JSON object per call. It bounded nothing about volume, so N producers under N budgets left the channel's real ceiling as whatever the set happened to sum to. That is the same trajectory `stop-guard` took — one rule, then five, each defensible alone with the aggregate never costed — and the failure mode is CLOUD-417's, measured: hook output at 20% of a long session's context. `[advisory] max_tokens` is the channel's, applied at the one site where the whole set is in hand. 1024 is `drain::DEFAULT_TOKEN_BUDGET`'s number read as the channel's rather than one producer's: the drain document is the largest of the three, so it keeps essentially its whole allowance firing alone, and the change bites where there was no bound at all — two or three producers on one boundary. Producers are admitted in `AdvisoryTier` order (CLOUD-80: severity as required response latency), so what survives a full channel is what must be answered soonest. The tier is carried from the PUSH SITE rather than inferred at the emission, because "how soon must this be answered" is a property of what is said and the boundary has only a string: a handler's advice is `Advisory`, a contract violation is `Warning`, an unmediated session is `Warning`, a Stop nudge is `Caution`. The remainder is dropped AND COUNTED. A truncated report that reads as complete is the false green in advisory form, so the suppressed count is a line in the emitted document rather than a silence — `drain.rs`'s `budget_summary` reused, a count and a ceiling, never the text that was dropped. The first entry is always admitted, even where it alone is over. A channel that could emit nothing would make the count line the only thing said, and the reader would hear silence rather than learning the ceiling is too small for its own content. Six unit cases, and two are the load-bearing half. The mutation case: remove the comparison in `admit` and the truncation case goes red, because everything fits and nothing is counted. The anti-vacuity case: an UNDECLARED ceiling emits exactly what it emitted before, in the boundary's own order — no reordering, no count line, nothing paid on a call that was never the problem. `validate` refuses `max_tokens = 0` at load, which is a channel switched off wearing a budget's clothes. `trust.rs` carries `AdvisoryCeilingRaised` beside `RefusalCeilingRaised`: smaller is stricter, so §8's "may not weaken" reads as "may not raise", and an absent ceiling is unenforced rather than zero. Its `CENSUS` row is what makes the field covered rather than merely present. `resolve.rs` registers `advisory` as an authority-only contributor. Without the row `Resolved::attributed` refuses the emitted key with "carries no source", which is `batten config show` exiting 3 over this repository's own committed config — found by `committed_rules_pin_severity_and_scope_explicitly` rather than by reading, which is the second tier doing its job. `policy/module-layering.rego` places the module. It is a leaf beside `refusal`, and the pairing is the placement's content: `refusal` bounds ONE emitted deny line and this bounds ONE emission of the whole channel, so the two answer the same question over the two documents a boundary can produce. It reaches `budget` for the estimator `refusal` already reaches, because a second one would be a second authority over what a token costs. That rule named the unplaced module before any reviewer did, for the tenth time. Admits: eb3a2ef4b32c5fb74fd9ff01e4c5a1d52ad175965ab693d4077d9fa6d0458d9a Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 7aa647e57fe3b141c762856ab4f37468c5e5b0c9 Admits-epoch: 294d260fec2cffd857f18f200915445933d03bde7eb6fedba821e0e325beef68 Admits-author: alec@wenzowski.com Admits-prev: 3f4e20df269636ca67ea65563d4b815f68de94b5fd2e1112d920da95cae29296 Admits-answer-lost: The row's own mechanism. `advisory::admit` treats an undeclared ceiling as no ceiling, so without this table the new gate is unreachable in consumer #1 — it loads clean, decides nothing, and the anti-vacuity mirror is the only case that ever exercises it. That is a dead gate, which non-negotiable rule 2 calls half a change. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table: CLOUD-896 requires one declared budget for the advisory CHANNEL, superseding a per-producer key, and non-negotiable rule 1 forbids the number living as a constant in `crates/batten`. No non-protected path carries it. The write is one a reviewer sees in the diff it lands in — a short addition of `[advisory] max_tokens` with its reasoning inline, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the neighbouring `[budget.instructions]` and `[refusal]` tables and copied their shape deliberately; reading further produces no route that writes the key. `patch run first` does not apply either: a patch that adds a top-level table to the policy authority is still a write to `batten.toml`, so it reaches this same class one indirection later. Admits: 174e8e0c026fc45c027ae59a86b35cd5119c69fd659430810a6c7602a88caf48 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/module-layering.rego Admits-head: 7aa647e57fe3b141c762856ab4f37468c5e5b0c9 Admits-epoch: 65c5b8996a1b40713c48fc6c1e8b991e3f231fe68407dfb9c2eca5efd525ea6d Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: CLOUD-896's whole change. `batten-check` is red until the new module is placed, so the ticket cannot land at all: the alternative is deleting `advisory.rs` and leaving the channel ceiling unbuilt, which is the gate this row exists to enforce working exactly as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the layer table: `module-layering`'s absence-is-an-error clause refuses `crates/batten/src/advisory.rs` until this module places it, and a placement is a claim about architecture that only this file carries. There is no non-protected path that can place a module. The write is one a reviewer sees in the diff it lands in — one entry in `declared_modules` with its placement comment, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read this module in full — its absence-is-an-error clause, its forbidden-edge table and its test tiers — and reading further produces no route that adds the placement. `patch run first` does not apply either: a patch that adds a member to `declared_modules` is still a write to `policy/module-layering.rego`, so it reaches this same class one indirection later. Admits: 35490aec1672d07e9dcecd3c525e790988f9135721d7dfe6f9f70207a5553293 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/core.md Admits-head: 7aa647e57fe3b141c762856ab4f37468c5e5b0c9 Admits-epoch: 65c5b8996a1b40713c48fc6c1e8b991e3f231fe68407dfb9c2eca5efd525ea6d Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: CLOUD-896's whole change. `module-map-check` is red until the new module has its row, so the commit cannot land: the alternative is deleting `advisory.rs` and leaving the channel unbounded, which is the gate working as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the module map: `module-map-check` refuses `crates/batten/src/advisory.rs` until `.serena/memories/core.md` carries its row, and that file is the one authority on where each module sits. No non-protected path can place a module in the map. The write is one a reviewer sees in the diff it lands in — one entry describing `advisory.rs` beside the `refusal.rs` row it pairs with, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the map's `refusal.rs` row and written this one to pair with it deliberately; reading further produces no route that adds the entry. `patch run first` does not apply either: a patch that adds a row to the module map is still a write to `.serena/memories/core.md`, so it reaches this same class one indirection later. Refs: CLOUD-896 --- .serena/memories/core.md | 25 +++ batten.toml | 30 ++++ crates/batten/src/advisory.rs | 288 ++++++++++++++++++++++++++++++++++ crates/batten/src/config.rs | 9 ++ crates/batten/src/hook.rs | 25 +++ crates/batten/src/lib.rs | 138 ++++++++++++---- crates/batten/src/resolve.rs | 12 ++ crates/batten/src/trust.rs | 47 ++++++ hk.pkl | 1 + policy/module-layering.rego | 13 ++ schema/batten.schema.json | 27 ++++ 11 files changed, 584 insertions(+), 31 deletions(-) create mode 100644 crates/batten/src/advisory.rs diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 4b7f76c52..90727d8ab 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -134,6 +134,31 @@ err)` takes **both** channels and the resolved `Mode`, so a verb can write a no locking at all — and the questions come off a class's declared `override.precondition` in `verdict.rs`. The gate never grades an answer; presence and non-emptiness are the whole predicate (rule 3). +- `advisory.rs` — the advisory CHANNEL and what it may cost (CLOUD-896). A LEAF + beside `refusal.rs`, and the pairing is the whole placement: `refusal` bounds + ONE emitted deny line, this bounds ONE emission of the whole channel, so the two + answer the same question over the two documents a boundary can produce. + CLOUD-461 coalesced the FRAMING — one `additionalContext` object per call — and + bounded nothing about volume: three producers (`drain::render`, + `contract::render`, the dispatched handler) shared no rate budget, and + `[drain] token_budget` bounds only one of them, so the channel's real ceiling + was whatever the set summed to. `[advisory] max_tokens` supersedes it — one + fact, one authority. `admit` sorts by `AdvisoryTier` (CLOUD-80's severity as + required response latency, `Reverse` because the derive is weakest-first) and + fills until the ceiling is spent; the tier is carried from the PUSH SITE in + `lib.rs` rather than inferred here, because "how soon must this be answered" is + a property of what is said and the boundary has only a string. **The remainder + is dropped AND COUNTED** — a truncated report that reads as complete is the + false green in advisory form — and the count line is a count and a ceiling, + never the dropped text. **The first entry is always admitted**, even alone over + budget, so the count line can never be the only thing said. An UNDECLARED + ceiling emits exactly what it emitted before, in the boundary's own order: that + is the anti-vacuity half, and it is what keeps this consumer's number out of + every other consumer's engine (rule 1). `validate` refuses `max_tokens = 0` at + load — a channel switched off wearing a budget's clothes. `trust.rs` carries + `AdvisoryCeilingRaised`: smaller is stricter, absent is unenforced rather than + zero. It reaches `budget` for the estimator `refusal` already reaches, because a + second one would be a second authority over what a token costs. - `action.rs` — the `[[hook.action]]` plugin surface (CLOUD-91), house-style §9's "repo-specific cleanup or keepalive is reconstructed here, not hardcoded". A row names an event and argv already on the operator's PATH. **`fire` returns diff --git a/batten.toml b/batten.toml index 13cf9c3ac..c5f1d18a8 100644 --- a/batten.toml +++ b/batten.toml @@ -3276,6 +3276,36 @@ key = "initial_prompt" [refusal] max_tokens = 24 +# What the whole advisory CHANNEL may cost on one boundary (CLOUD-896). +# +# `hookSpecificOutput.additionalContext` has two producers today — `drain::render` +# and `contract::render` — with a third queued behind them, and until now the only +# declared number was `[drain] token_budget`, which bounds ONE of them. Coalescing +# bounded how many documents are emitted; nothing bounded how much any of them +# said, so N producers cost N budgets and the channel's real ceiling was whatever +# the set happened to sum to. One fact, one authority: this supersedes the +# per-producer key rather than sitting beside it. +# +# 1024 is `drain::DEFAULT_TOKEN_BUDGET`'s number read as the channel's rather than +# one producer's. That is deliberately the tighter reading of the same figure: the +# drain document is the largest of the three, so it keeps essentially its whole +# allowance when it fires alone, and the change bites exactly where it should — +# when two or three producers fire on one boundary, which is the case that had no +# bound at all. +# +# Producers are admitted in `AdvisoryTier` order (CLOUD-80: severity as required +# response latency), so what survives a tight boundary is what needs answering +# soonest. The remainder is dropped AND COUNTED: a truncated report that reads as +# complete is the false green in advisory form, so the suppressed count is a line +# in the emitted document rather than a silence. +# +# An undeclared ceiling is no ceiling — `advisory::admit` emits everything — which +# is what keeps this consumer's number out of every other consumer's engine +# (non-negotiable rule 1) and is why the anti-vacuity case asserts the undeclared +# path rather than assuming it. +[advisory] +max_tokens = 1024 + # The test suite is half of this repository's definition of green, and was the # unguarded half (CLOUD-55). It cannot be a `protected` path — tests are edited # every day, so a protected glob would block writing them — so the computable diff --git a/crates/batten/src/advisory.rs b/crates/batten/src/advisory.rs new file mode 100644 index 000000000..06fa63d9a --- /dev/null +++ b/crates/batten/src/advisory.rs @@ -0,0 +1,288 @@ +//! The advisory CHANNEL and what it may cost (CLOUD-896). +//! +//! CLOUD-461 put the drain and the drift notice on one +//! `hookSpecificOutput.additionalContext` document, and CLOUD-1051 moved the Stop +//! surface onto the same one. That solved FRAMING — one JSON object per call — +//! and solved nothing about VOLUME: the producers share no rate budget, so +//! nothing bounds how much any of them says or how much the set says together. +//! +//! CLOUD-82 already holds a token budget for the drain ALONE. Extending it to +//! the channel rather than to the producer is the difference between one +//! well-behaved reporter and three reporters that are each individually +//! reasonable, and it is the same trajectory `stop-guard` took: one rule, then +//! five, each defensible in isolation, with the aggregate never costed. +//! +//! The failure mode is CLOUD-417's, measured: hook output at 20% of a long +//! session's context. Setting the ceiling before the third producer arrives is +//! cheaper than rationalising it after — and the third producer has since +//! arrived, which is the row being right rather than lucky. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::severity::AdvisoryTier; + +/// One producer's contribution, with the latency its content demands. +/// +/// The tier is carried from the PUSH SITE rather than inferred at the boundary, +/// because "how soon must this be answered" is a property of what is being said +/// and the boundary has only the string. CLOUD-80's reading of severity as +/// required response latency is what makes the ordering meaningful: when the +/// channel is over budget, what survives is what has to be answered soonest. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Advice { + /// How soon this must be answered. + pub tier: AdvisoryTier, + /// The pointer text, already composed by its producer. + pub text: String, +} + +impl Advice { + /// One entry. + #[must_use] + pub fn new(tier: AdvisoryTier, text: impl Into) -> Advice { + Advice { + tier, + text: text.into(), + } + } +} + +/// The `[advisory]` table: what ONE emission of the whole channel may cost. +/// +/// **The channel, not the producer**, which is the whole of this row. Absent +/// means unenforced, on `[budget]`'s reading — a threshold nobody declared is +/// not a threshold of zero — so a consumer that has not adopted it emits exactly +/// what it emitted before. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Channel { + /// The ceiling on estimated tokens for one emission, across every producer. + /// The boundary is `<=`, matching `[budget]` and `[refusal]` so the three + /// thresholds in this tree do not disagree about their own edge. + pub max_tokens: usize, +} + +/// Refuse a ceiling nothing could satisfy. +/// +/// # Errors +/// +/// When the declared ceiling is zero: it would suppress every advisory including +/// the shortest, which is a channel switched off wearing a budget's clothes. +pub fn validate(channel: Option<&Channel>) -> Result<(), String> { + match channel { + Some(declared) if declared.max_tokens == 0 => Err( + "`[advisory] max_tokens = 0` suppresses every advisory the channel could carry — \ + remove the table to leave the channel unbounded, or name a ceiling something can \ + fit inside" + .to_owned(), + ), + _ => Ok(()), + } +} + +/// What one emission carries, and what it left behind. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Emission { + /// The admitted text, tier-ordered and joined. + pub text: String, + /// How many entries did not fit. + pub suppressed: usize, +} + +/// The line a truncated emission ends with, so a partial report cannot read as a +/// complete one. +/// +/// **A truncated report that reads as complete is the false green in advisory +/// form**, which is the row's own words and the reason the count is not +/// optional. `drain.rs`'s `budget_summary` is the shape reused: a count and a +/// ceiling, never the text that was dropped — the suppressed entries are +/// pointers somebody else's producer composed, and reprinting them here would +/// spend the budget this exists to hold. +#[must_use] +pub fn suppressed_line(suppressed: usize, ceiling: usize) -> String { + format!( + "advisory: {suppressed} further finding(s) suppressed at the declared channel ceiling of {ceiling} token(s)" + ) +} + +/// Admit producers to one emission in tier order until the ceiling is spent. +/// +/// # The ordering is the whole design +/// +/// Sorted by tier, strongest first, and STABLE within a tier so two producers at +/// one latency keep the order the boundary produced them in — which is the same +/// declaration-order tie-break every other table here uses, and the one that +/// keeps output byte-stable under §6. +/// +/// # Under-budget is untouched +/// +/// With no ceiling declared, or with everything fitting, this joins and returns +/// exactly what it was handed and suppresses nothing. That is the anti-vacuity +/// half: a budget that reordered or trimmed the ordinary case would be paid for +/// on every call that was never the problem. +/// +/// # The FIRST entry is always admitted +/// +/// Even where it alone exceeds the ceiling. A channel that could emit nothing at +/// all would turn a budget into a mute switch, and the count line would then be +/// 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. +#[must_use] +pub fn admit(entries: Vec, ceiling: Option<&Channel>) -> Emission { + let Some(ceiling) = ceiling else { + return Emission { + text: joined(&entries), + suppressed: 0, + }; + }; + let mut ordered = entries; + // `Reverse` because `AdvisoryTier` derives `Ord` weakest-first, and what must + // survive a full channel is what has to be answered soonest. + ordered.sort_by_key(|entry| std::cmp::Reverse(entry.tier)); + + let mut admitted: Vec = Vec::new(); + let mut suppressed = 0; + for entry in ordered { + let candidate = joined_with(&admitted, &entry); + if admitted.is_empty() || crate::budget::estimate_tokens(&candidate) <= ceiling.max_tokens { + admitted.push(entry); + } else { + suppressed += 1; + } + } + let mut text = joined(&admitted); + if suppressed > 0 { + text.push_str("\n\n"); + text.push_str(&suppressed_line(suppressed, ceiling.max_tokens)); + } + Emission { text, suppressed } +} + +/// The channel's one separator, in one place so the measurement and the emission +/// cannot disagree about what a joined document costs. +fn joined(entries: &[Advice]) -> String { + entries + .iter() + .map(|entry| entry.text.as_str()) + .collect::>() + .join("\n\n") +} + +/// What `admitted` would cost with `next` added — measured on the JOINED form, +/// because the separator is part of what the channel carries. +fn joined_with(admitted: &[Advice], next: &Advice) -> String { + let mut all: Vec<&str> = admitted.iter().map(|entry| entry.text.as_str()).collect(); + all.push(next.text.as_str()); + all.join("\n\n") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + fn entry(tier: AdvisoryTier, text: &str) -> Advice { + Advice::new(tier, text) + } + + #[test] + fn three_producers_emit_one_document_ordered_by_tier() { + // THE ROW'S OWN CASE. Three producers on one boundary, admitted in + // `AdvisoryTier` order — what must be answered soonest leads. + let emission = admit( + vec![ + entry(AdvisoryTier::Advisory, "drain says a thing"), + entry(AdvisoryTier::Warning, "the contract moved"), + entry(AdvisoryTier::Caution, "the turn ended oddly"), + ], + Some(&Channel { max_tokens: 500 }), + ); + assert_eq!(emission.suppressed, 0); + assert_eq!( + emission.text, + "the contract moved\n\nthe turn ended oddly\n\ndrain says a thing" + ); + } + + #[test] + fn what_does_not_fit_is_counted_rather_than_dropped_silently() { + // THE MUTATION CASE (CLOUD-418): remove the comparison in `admit` and + // this goes red, because everything fits and nothing is counted. A + // truncated report that reads as complete is the false green in advisory + // form, which is why the count is not optional. + let emission = admit( + vec![ + entry(AdvisoryTier::Warning, &"w".repeat(80)), + entry(AdvisoryTier::Caution, &"c".repeat(80)), + entry(AdvisoryTier::Advisory, &"a".repeat(80)), + ], + Some(&Channel { max_tokens: 30 }), + ); + assert_eq!(emission.suppressed, 2, "two did not fit: {}", emission.text); + assert!( + emission.text.starts_with(&"w".repeat(80)), + "and the one that survives is the one due soonest: {}", + emission.text + ); + assert!( + emission.text.contains("2 further finding(s) suppressed"), + "the drop is counted: {}", + emission.text + ); + } + + #[test] + fn an_undeclared_ceiling_leaves_the_channel_exactly_as_it_was() { + // ANTI-VACUITY. A consumer that has not adopted the table emits what it + // emitted before, in the order the boundary produced — no reordering, no + // count line, nothing paid on a call that was never the problem. + let emission = admit( + vec![ + entry(AdvisoryTier::Advisory, "first"), + entry(AdvisoryTier::Warning, "second"), + ], + None, + ); + assert_eq!(emission.suppressed, 0); + assert_eq!(emission.text, "first\n\nsecond"); + } + + #[test] + fn the_first_entry_is_admitted_even_when_it_alone_is_over() { + // A channel that could emit nothing would make the count line the only + // thing said — a report about a report. The overflow is still counted, so + // the reader learns the ceiling is too small rather than hearing silence. + let emission = admit( + vec![ + entry(AdvisoryTier::Warning, &"w".repeat(400)), + entry(AdvisoryTier::Advisory, "short"), + ], + Some(&Channel { max_tokens: 1 }), + ); + assert_eq!(emission.suppressed, 1); + assert!(emission.text.starts_with(&"w".repeat(400))); + } + + #[test] + fn a_zero_ceiling_is_refused_at_load() { + assert!(validate(Some(&Channel { max_tokens: 0 })).is_err()); + assert!(validate(Some(&Channel { max_tokens: 1 })).is_ok()); + assert!(validate(None).is_ok()); + } + + #[test] + fn one_tier_keeps_the_boundarys_own_order() { + // Stable within a tier, so two producers at one latency stay byte-stable + // under §6 rather than depending on a sort nobody declared. + let emission = admit( + vec![ + entry(AdvisoryTier::Caution, "alpha"), + entry(AdvisoryTier::Caution, "beta"), + ], + Some(&Channel { max_tokens: 500 }), + ); + assert_eq!(emission.text, "alpha\n\nbeta"); + } +} diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 5b5196b81..129ed99cb 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -390,6 +390,11 @@ pub struct Config { /// `[budget]` above. The type and the predicate are [`crate::refusal`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub refusal: Option, + /// What ONE emission of the advisory channel may cost, across every producer + /// (CLOUD-896). Absent means unenforced, on `[budget]`'s reading. The type + /// and the predicate are [`crate::advisory`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub advisory: Option, /// The ref work must land on (CLOUD-51) — the target `worktree status` /// judges at-risk work against. Consumer-specific by nature: which ref is /// the trunk is a property of the repository being gated, never of Batten @@ -1121,6 +1126,9 @@ fn parse_ungated(text: &str, source: &str) -> Result { // satisfy is refused at load rather than discovered by the first person it // fires on. crate::refusal::validate(config.refusal.as_ref()).map_err(UsageError::raise)?; + // Same shape, same reason, one table over: a ceiling nothing can satisfy is + // refused at load rather than discovered by the first advisory it silences. + crate::advisory::validate(config.advisory.as_ref()).map_err(UsageError::raise)?; // Validated at parse, like `[[verb]]` and `[[marker]]`: CLOUD-242's lesson // is that a table nothing validates is coverage that means nothing. if let Some(ci) = &config.ci { @@ -1273,6 +1281,7 @@ impl Config { // either — there is simply no threshold, which is what `None` says. budget: None, refusal: None, + advisory: None, must_land_on: None, // An authority that cannot be read attaches no side effects. The // safe direction is unambiguous here: firing a command an diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index fefd6e380..7b92031a1 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -2815,6 +2815,11 @@ pub struct Policy { /// the cross product as rules would need one row per verb × path pair, and /// the config would restate what an intersection already says. protected: PathSet, + /// What one emission of the advisory channel may cost (CLOUD-896). + /// + /// Engine-side plumbing on `harness`'s reading: resolved at the boundary and + /// carried in, so the emission site can ask without reaching for config. + pub advisory: Option, /// Programs known to only READ their operands (CLOUD-1141). /// /// The other half of the gate above, and the half that decides what an @@ -2914,6 +2919,7 @@ impl Policy { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), facts: Vec::new(), @@ -2991,6 +2997,7 @@ impl Policy { ) .collect::>(), )?, + advisory: resolved.advisory.clone(), protected_readers: resolved.protected_readers.clone(), redirects: resolved.redirects.clone(), facts: resolved.facts.clone(), @@ -3579,6 +3586,7 @@ impl Policy { fail_on_warning: self.fail_on_warning, verbs: self.verbs.clone(), protected: self.protected.clone(), + advisory: self.advisory.clone(), protected_readers: self.protected_readers.clone(), redirects: self.redirects.clone(), facts: self.facts.clone(), @@ -8183,6 +8191,7 @@ mod tests { // unknown-program half — so a case here that starts refusing is the // new clause reaching a shape the old gate let through, which is // exactly what should be visible rather than absorbed. + advisory: None, protected_readers: Vec::new(), redirects, } @@ -8221,6 +8230,7 @@ mod tests { root: None, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), shapes: rows, @@ -8290,6 +8300,7 @@ mod tests { root: None, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), shapes: vec![ @@ -8798,6 +8809,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), } @@ -8880,6 +8892,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), } @@ -9378,6 +9391,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; @@ -9414,6 +9428,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; @@ -9444,6 +9459,7 @@ mod tests { fail_on_warning: true, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; @@ -9485,6 +9501,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; @@ -9532,6 +9549,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; @@ -9597,6 +9615,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), } @@ -9687,6 +9706,7 @@ mod tests { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), } @@ -10158,6 +10178,7 @@ deny contains "refused by themodule" if { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), } @@ -10455,6 +10476,7 @@ deny contains "refused by themodule" if { fail_on_warning: false, verbs: Vec::new(), protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), } @@ -11260,6 +11282,7 @@ deny contains "refused by themodule" if { fail_on_warning: false, verbs: vec![verb("rm", None)], protected: PathSet::empty(), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; @@ -11391,6 +11414,7 @@ deny contains "refused by themodule" if { verbs: verbs.clone(), protected: PathSet::includes("protected", &["guarded/**".to_owned()]) .expect("well formed"), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; @@ -11409,6 +11433,7 @@ deny contains "refused by themodule" if { verbs, protected: PathSet::includes("protected", &["other/**".to_owned()]) .expect("well formed"), + advisory: None, protected_readers: Vec::new(), redirects: Vec::new(), }; diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 5e6cec92e..1a91badd3 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -10,6 +10,7 @@ pub mod action; pub mod admission; +pub mod advisory; pub mod attribution; pub mod baseline; pub mod brief; @@ -4732,7 +4733,10 @@ fn run_hook( // host would read the first and discard the rest. Coalescing here is also // the honest shape — they are two findings of one advisory, not two // channels. - let mut advice: Vec = Vec::new(); + // TIERED SINCE CLOUD-896. Every producer carries the latency its content + // demands, so the channel's ceiling can admit what must be answered soonest + // rather than whichever producer the boundary happened to reach first. + let mut advice: Vec = Vec::new(); collect_batch_advice(harness, &envelope, overrides, mode, err, &mut advice)?; // NOT EMITTED HERE ANY MORE (CLOUD-898). A third producer arrived — a // dispatched handler — and its answer does not exist until config is @@ -5056,10 +5060,8 @@ fn run_hook( // The `Allow` path is untouched — advice is the only document there, which is // the behaviour CLOUD-1131 measured and shipped. Suppressing advice generally // would trade a dropped deny for a dropped advisory. - let speaks_a_verdict = matches!(decision, hook::Decision::Deny(_) | hook::Decision::Ask(_)); - if !advice.is_empty() && !speaks_a_verdict { - emit_advisory(harness, &envelope, out, err, &advice.join("\n\n"))?; - } + let ceiling = policy.advisory.as_ref(); + emit_channel(harness, &envelope, out, err, advice, ceiling, &decision)?; // Resolved HERE rather than inside `render`, because `render` deliberately // cannot see the policy (CLOUD-898) and that property is worth more than the // convenience: a renderer that cannot see the inputs cannot re-decide by @@ -5150,13 +5152,16 @@ fn fill_turn_advice( envelope: &hook::Envelope, facts: &hook::Facts<'_>, overrides: &Overrides, - advice: &mut Vec, + advice: &mut Vec, ) { if advice.is_empty() && let Some(nudge) = hook::stop_advice(policy, envelope, facts).or_else(|| stop_nudges(overrides, envelope)) { - advice.push(nudge); + 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 @@ -5174,9 +5179,12 @@ fn fill_turn_advice( // 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.contains(&signal) + && !advice.iter().any(|entry| entry.text == signal) { - advice.push(signal); + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Warning, + signal, + )); } } @@ -5242,7 +5250,7 @@ fn collect_batch_advice( overrides: &Overrides, mode: Mode, err: &mut dyn Write, - advice: &mut Vec, + advice: &mut Vec, ) -> Result<()> { if Some(envelope.event) == harness.capabilities().degrade(hook::Event::PostToolBatch) { drain_advisories(envelope, overrides, mode, err, advice)?; @@ -5333,7 +5341,7 @@ fn dispatch_handlers( raw: &str, bypass: bool, overrides: &Overrides, - advice: &mut Vec, + advice: &mut Vec, ) -> Result> { if bypass { return Ok(None); @@ -5360,8 +5368,22 @@ fn dispatch_handlers( &envelope.raw_tool, raw, ); - advice.extend(dispatched.advice()); - advice.extend(dispatched.violations()); + // TIERED AT THE PUSH SITE (CLOUD-896), because "how soon must this be + // answered" is a property of what is being said and the boundary has only + // the string. A handler's advice is `Advisory`; a contract violation is + // `Warning`, because it is a statement that a declared invariant is broken. + advice.extend( + dispatched + .advice() + .into_iter() + .map(|text| advisory::Advice::new(severity::AdvisoryTier::Advisory, text)), + ); + advice.extend( + dispatched + .violations() + .into_iter() + .map(|text| advisory::Advice::new(severity::AdvisoryTier::Warning, text)), + ); // A REFUSAL IS DEMOTED TO ADVICE ON A MOMENT THAT CANNOT CARRY ONE, and this // is the door's own loophole rather than a hypothetical. CLOUD-889 made // `adjudicate` structurally unable to refuse at `Stop` — that is what ended @@ -5399,7 +5421,10 @@ fn dispatch_handlers( return Ok(None); }; if !envelope.event.carries_a_verdict() { - advice.push(format!("hook.handler.{id}: {reason}")); + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Caution, + format!("hook.handler.{id}: {reason}"), + )); return Ok(None); } Ok(Some(hook::Decision::Deny( @@ -5629,6 +5654,36 @@ fn fire_actions( /// `Allow` — so stdout carries at most one document per invocation and none of /// them can refuse a call. An advisory surface that could block would be a gate /// (house-style §0.3), and `drain.rs` states that as its own contract. +/// Admit this call's advice to one emission, under the channel's own ceiling +/// (CLOUD-896). +/// +/// **The whole set is only in hand here**, which is why the ceiling is applied at +/// this point and not at any producer: `[drain] token_budget` bounds ONE +/// producer, and N producers under N budgets is a channel whose real ceiling is +/// whatever the set happens to sum to. +/// +/// **A refusal outranks advice about the same call**, which is why the decision +/// reaches here rather than a boolean the caller derived: `Ask` is a verdict +/// document too, and the escalation is what the reader must answer. The `Allow` +/// path is untouched — advice is the only document there, which is the behaviour +/// CLOUD-1131 measured and shipped. +fn emit_channel( + harness: hook::Harness, + envelope: &hook::Envelope, + out: &mut dyn Write, + err: &mut dyn Write, + advice: Vec, + ceiling: Option<&advisory::Channel>, + decision: &hook::Decision, +) -> Result<()> { + let speaks_a_verdict = matches!(decision, hook::Decision::Deny(_) | hook::Decision::Ask(_)); + if advice.is_empty() || speaks_a_verdict { + return Ok(()); + } + let emission = advisory::admit(advice, ceiling); + emit_advisory(harness, envelope, out, err, &emission.text) +} + fn emit_advisory( harness: hook::Harness, envelope: &hook::Envelope, @@ -5665,7 +5720,7 @@ fn emit_advisory( fn report_contract_drift( envelope: &hook::Envelope, overrides: &Overrides, - advice: &mut Vec, + advice: &mut Vec, ) { // The two events that carry the predicate, tested HERE rather than at the // call site so the function owns which moments it serves. @@ -5726,7 +5781,12 @@ fn report_contract_drift( let facts::Look::Is(previous) = contract::previous(&git_dir, session) else { drop(contract::record(&git_dir, session, ¤t)); if !matches!(envelope.event, hook::Event::SessionStart) { - advice.push(contract::unmediated_session()); + // WARNING: the engine is not mediating this session at all, which is + // the one advisory whose subject is the gate rather than the work. + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Warning, + contract::unmediated_session(), + )); } return; }; @@ -5741,7 +5801,10 @@ fn report_contract_drift( // toward an unbounded stream of the same one. // An unwritable snapshot costs a repeated notice, never a refused call. drop(contract::record(&git_dir, session, ¤t)); - advice.push(contract::render(&change, &declared.wiring)); + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Caution, + contract::render(&change, &declared.wiring), + )); } /// What this call's write would land, resolved only if a row asks (CLOUD-758). @@ -5892,7 +5955,7 @@ fn drain_advisories( overrides: &Overrides, mode: Mode, err: &mut dyn Write, - advice: &mut Vec, + advice: &mut Vec, ) -> Result<()> { // The repository, resolved through the one finder (CLOUD-824). This read // asked TWO different questions before: whether an authority sits in the cwd, @@ -6027,9 +6090,15 @@ fn drain_advisories( // both outcomes on the surface the host actually delivers, and keeps stderr // for the hosts that declare no channel, where it is still the operator's. if emitted { - advice.push(drain::render(&drained)); + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Advisory, + drain::render(&drained), + )); } else if repeat && !drained.lines.is_empty() { - advice.push(drain::UNCHANGED.to_owned()); + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Advisory, + drain::UNCHANGED, + )); } // Volume and suppression counts are the operator's, not the agent's: they @@ -6218,7 +6287,7 @@ fn record_post_tool( overrides: &Overrides, envelope: &hook::Envelope, harness: hook::Harness, - advice: &mut Vec, + advice: &mut Vec, ) { // GATED ONLY ON THE RESPONSE MEMBER, never on the command. The // `!command.is_empty()` conjunct below is correct for a FACT — a fact is @@ -6869,11 +6938,18 @@ fn record_mints(overrides: &Overrides, envelope: &hook::Envelope) { /// non-empty one is its bytes at the declared fidelity. The provenance row is /// what tells the first two apart — one carries a digest, the other a reason id, /// and they differ in which keys exist rather than in a count. -fn capture_response(envelope: &hook::Envelope, harness: hook::Harness, advice: &mut Vec) { +fn capture_response( + envelope: &hook::Envelope, + harness: hook::Harness, + advice: &mut Vec, +) { let mut note = |reason: &str| { // Pointer-only: the reason id, never a path and never a byte count that // could fingerprint the content. The same id reaches `doctor`. - advice.push(format!("hook.capture.response: {reason}")); + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Advisory, + format!("hook.capture.response: {reason}"), + )); }; // NO FALLBACK TO THE CWD, which is what makes the doc above true: resolving // to wherever the agent happens to be standing would mint a state root there @@ -6955,7 +7031,7 @@ fn capture_response(envelope: &hook::Envelope, harness: hook::Harness, advice: & fn record_absent_response( envelope: &hook::Envelope, harness: hook::Harness, - advice: &mut Vec, + advice: &mut Vec, ) { let Ok(root) = git::repo_root(hook_authority_root()) else { return; @@ -6966,9 +7042,9 @@ fn record_absent_response( // and mints no blob, so nothing else would ever bring the log inside its // record bound — and `next_order` scans that log on every later call. if capture::evict_to_budget(&root, capture_budget().as_ref()).is_err() { - advice.push(format!( - "hook.capture.response: {}", - capture::STORE_UNWRITABLE + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Advisory, + format!("hook.capture.response: {}", capture::STORE_UNWRITABLE), )); } } @@ -6979,7 +7055,7 @@ fn record_absence( envelope: &hook::Envelope, harness: hook::Harness, reason: &'static str, - advice: &mut Vec, + advice: &mut Vec, ) { let row = capture::CallRow { order: 0, @@ -7001,9 +7077,9 @@ fn record_absence( absent: Some(reason.to_owned()), }; if capture::record_call(root, &row).is_err() { - advice.push(format!( - "hook.capture.response: {}", - capture::STORE_UNWRITABLE + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Advisory, + format!("hook.capture.response: {}", capture::STORE_UNWRITABLE), )); } } diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index 7aa0244cd..d7a407960 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -600,6 +600,12 @@ pub struct Resolved { /// the committed bytes for that. #[serde(skip_serializing_if = "Option::is_none")] pub budget: Option, + /// What one emission of the advisory channel may cost (CLOUD-896), as the + /// authority states it. Not layered, for `budget`'s reason exactly: a + /// ceiling is a bar this repository sets for itself, and `trust.rs` compares + /// the committed bytes for the direction. + #[serde(skip_serializing_if = "Option::is_none")] + pub advisory: Option, /// The ref work must land on (CLOUD-51), as the authority states it. #[serde(skip_serializing_if = "Option::is_none")] pub must_land_on: Option, @@ -1615,6 +1621,7 @@ fn assemble( exec_patterns: tables.exec_patterns, waivers: tables.waivers, budget: repo.budget.clone(), + advisory: repo.advisory.clone(), must_land_on: repo.must_land_on.clone(), hook: repo.hook.clone(), transcript: repo.transcript.clone(), @@ -1717,6 +1724,11 @@ fn attribution( ("program", authority_set(!repo.programs.is_empty())), ("waiver", authority_set(!repo.waivers.is_empty())), ("budget", authority_set(repo.budget.is_some())), + // The channel ceiling beside the instruction budget, and authority-only + // for the same reason (CLOUD-896): a ceiling is a bar this repository + // sets for itself, so a local file raising one would be the weakening + // `trust.rs` compares the committed bytes to catch. + ("advisory", authority_set(repo.advisory.is_some())), ("must_land_on", authority_set(repo.must_land_on.is_some())), ("hook", authority_set(repo.hook.is_some())), ("transcript", authority_set(repo.transcript.is_some())), diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 6c0211361..7dbf1050b 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -722,6 +722,10 @@ pub enum WeakeningKind { /// The transcript path is gone, so `check` stops reading the completed /// session it judged against (CLOUD-95). TranscriptPathRemoved, + /// The `[advisory]` channel ceiling rose, or stopped being declared + /// (CLOUD-896). Same direction as the two below: smaller is stricter, and an + /// absent ceiling is unenforced rather than zero. + AdvisoryCeilingRaised, /// The `[refusal]` ceiling rose, or stopped being declared (CLOUD-1286). /// Same direction as a budget's: smaller is stricter, so §8's "may not /// weaken" reads as "may not raise", and an absent ceiling is unenforced @@ -826,6 +830,7 @@ impl WeakeningKind { WeakeningKind::DefectsLedgerRemoved, WeakeningKind::DefectsClassAdded, WeakeningKind::TranscriptPathRemoved, + WeakeningKind::AdvisoryCeilingRaised, WeakeningKind::RefusalCeilingRaised, WeakeningKind::BudgetSetRemoved, WeakeningKind::BudgetFileRemoved, @@ -880,6 +885,7 @@ impl WeakeningKind { WeakeningKind::DefectsLedgerRemoved => "defects-ledger-removed", WeakeningKind::DefectsClassAdded => "defects-class-added", WeakeningKind::TranscriptPathRemoved => "transcript-path-removed", + WeakeningKind::AdvisoryCeilingRaised => "advisory-ceiling-raised", WeakeningKind::RefusalCeilingRaised => "refusal-ceiling-raised", WeakeningKind::BudgetSetRemoved => "budget-set-removed", WeakeningKind::BudgetFileRemoved => "budget-file-removed", @@ -1100,6 +1106,10 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "refusal", coverage: Coverage::Compared(&[WeakeningKind::RefusalCeilingRaised]), }, + FieldCoverage { + field: "advisory", + coverage: Coverage::Compared(&[WeakeningKind::AdvisoryCeilingRaised]), + }, FieldCoverage { field: "must_land_on", coverage: Coverage::Compared(&[WeakeningKind::MustLandOnRemoved]), @@ -1725,6 +1735,14 @@ fn scalar_weakenings(base: &Config, working: &Config) -> Vec { working.refusal.as_ref().map(|ceiling| ceiling.max_tokens), )); + // One table over, one direction (CLOUD-896). + found.extend(ceiling_raised( + WeakeningKind::AdvisoryCeilingRaised, + "advisory.max_tokens", + base.advisory.as_ref().map(|channel| channel.max_tokens), + working.advisory.as_ref().map(|channel| channel.max_tokens), + )); + // `must_land_on` gone leaves `worktree status` with no target — exit 1, and // a gate that cannot judge. A *changed* ref is not compared: two trunk names // cannot be ranked without knowing which repository they belong to. @@ -3660,6 +3678,35 @@ mod tests { } } + #[test] + fn raising_or_dropping_the_advisory_ceiling_is_a_weakening() { + // The channel budget's own ratchet, and the kind's exercising case. + let mut base = Config::declaring_nothing(); + base.advisory = Some(crate::advisory::Channel { max_tokens: 400 }); + + let mut raised = Config::declaring_nothing(); + raised.advisory = Some(crate::advisory::Channel { max_tokens: 4000 }); + assert_eq!( + only(&base, &raised), + Weakening::new( + WeakeningKind::AdvisoryCeilingRaised, + "advisory.max_tokens", + "400", + "4000", + ) + ); + + assert_eq!( + only(&base, &Config::declaring_nothing()), + Weakening::new( + WeakeningKind::AdvisoryCeilingRaised, + "advisory.max_tokens", + "400", + "absent", + ) + ); + } + #[test] fn raising_or_dropping_the_refusal_ceiling_is_a_weakening() { // CLOUD-1286's gate is a number in config, so the two ways to switch it diff --git a/hk.pkl b/hk.pkl index bdf26641e..1d1e249bd 100644 --- a/hk.pkl +++ b/hk.pkl @@ -426,6 +426,7 @@ local gate = new Mapping { glob = List( "crates/batten/src/action.rs", + "crates/batten/src/advisory.rs", "crates/batten/src/attribution.rs", "crates/batten/src/budget.rs", "crates/batten/src/capture.rs", diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 756a0ac7c..2da37a2f4 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -214,6 +214,19 @@ declared_modules := { # recorded verdict MEANS is `policy/validator-verdict-clean.rego`'s, and this # module never reads a finding. "record", + # `advisory` arrived with CLOUD-896 and this rule named it a tenth time: the + # module was written, `test:cargo` was green over its six cases, and this is + # what said nobody had placed it. + # + # It is a LEAF beside `refusal`, and the pairing is the placement's content: + # `refusal` bounds ONE emitted deny line and this bounds ONE emission of the + # whole advisory channel, so the two answer the same question over the two + # documents a boundary can produce. It reaches `severity` for the tier it + # orders by and `budget` for the estimator — the same estimator `refusal` + # reaches, because a second one would be a second authority over what a token + # costs. It reaches no decider and no store: WHICH producers exist is `lib`'s, + # and `lib` is the caller that hands the whole set over. + "advisory", } # THE FORBIDDEN EDGES, each traceable to prose already in the tree. diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 1db428c0d..f4fa39cc5 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -4,6 +4,17 @@ "description": "A parsed, validated `batten.toml`.\n\n`deny_unknown_fields` makes an unrecognised key a hard error (§8): the config\nsurface stays narrow and a typo can never silently disable a gate.", "type": "object", "properties": { + "advisory": { + "description": "What ONE emission of the advisory channel may cost, across every producer\n(CLOUD-896). Absent means unenforced, on `[budget]`'s reading. The type\nand the predicate are [`crate::advisory`].", + "anyOf": [ + { + "$ref": "#/$defs/Channel" + }, + { + "type": "null" + } + ] + }, "attribution": { "description": "What produced commits may carry about the tooling that made them\n(CLOUD-274), enforcing the attribution decision record (CLOUD-268).\nAbsent means this repository declares no attribution policy and the gate\nis simply not active — not that everything is permitted, which is why an\nabsent table is a usage error at the gate rather than a silent pass.\n\nConsumer-specific by nature, and the reason it lives here: the engine\ncarries the matcher, this file carries the vendor literals. That extends\nnon-negotiable rule 1 from consumers to vendors — a grep of `crates/` for\nthe configured patterns returns nothing. The type and the predicate are\n[`crate::attribution`].", "anyOf": [ @@ -631,6 +642,22 @@ } ] }, + "Channel": { + "description": "The `[advisory]` table: what ONE emission of the whole channel may cost.\n\n**The channel, not the producer**, which is the whole of this row. Absent\nmeans unenforced, on `[budget]`'s reading — a threshold nobody declared is\nnot a threshold of zero — so a consumer that has not adopted it emits exactly\nwhat it emitted before.", + "type": "object", + "properties": { + "max_tokens": { + "description": "The ceiling on estimated tokens for one emission, across every producer.\nThe boundary is `<=`, matching `[budget]` and `[refusal]` so the three\nthresholds in this tree do not disagree about their own edge.", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_tokens" + ] + }, "Ci": { "description": "The `[ci]` table: this repository's committed copy of the host's contract.", "type": "object", From 046659f2b6a47d097a6b25126d2e725112ed365b Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 16:54:30 +0000 Subject: [PATCH 13/20] fix(hook): bound what a session's hooks cost it, and make a repeat decidable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-negotiable rule 4 holds every check to "a count, `path:line`, or boolean — never the content itself", and each hook obeys it individually. Nobody had measured them IN AGGREGATE, over a session, where one line that obeys the rule is emitted hundreds of times and every copy stays in context forever. Measured on transcript `125cdf71` (758 turns, 5.83 MB): `hook_success` at 1181 KB and `hook_additional_context` at 42 KB, against 95 KB of edited files and 88 KB of delivered memories. Hook output alone was 20% of the transcript, and the largest single contributor said one true, correctly pointer-shaped, identical thing on essentially every turn. The root cause is that the output rule is stated per-CHECK and enforced per-CHECK. There is no rule about a check's output over a SESSION, so a hook that is silent by default and one that confirms success every turn are indistinguishable to every gate that exists. That is the shape CLOUD-896 found one layer down, and the answer is the same: put the ceiling on the aggregate, because the aggregate is what is spent. `[hook_output]` carries two thresholds and the second is most of the win. `max_tokens` bounds the session. `max_repeats` is what makes "silence on success is the default" and "a repeat is a pointer to the first, not a copy" DECIDABLE rather than prose — a hook saying the same thing every turn is byte-identical every turn, so it is exactly a digest repeated, and prose asking hooks to be quiet is the feedforward this repository refuses. 1 is the floor, never 0: saying it once is the report, and refusing the first emission would remove the finding rather than its restatement, which the row puts explicitly out of scope. `Event::HookOutput` is appended to the transcript vocabulary and carries a count and a DIGEST, never the text — `transcript.rs` hashes the emitted bytes with `identity::context_fingerprint` and drops them at the parse, so a measurement of an over-wide channel cannot itself carry what the channel said. The report keeps eight hex characters, a count, and the first copy's line. Three parse decisions each close a way of being silently wrong. The tag is matched as a `hook_*` PREFIX rather than an enumerated set, because a host shipping a third tag is emitting the same cost and counting it as zero is the under-report this row ends. Empty output is NOT an emission, which is what keeps three silent records from hashing alike and manufacturing a violation out of the exact behaviour being asked for. The grouping key is (producer, digest), so two hooks emitting one string are two producers rather than one repeating itself. `batten policy hooks` prints ONE line and nothing around it. That is the row's own acceptance clause and it is asserted in both tiers: a gate about hook volume whose own report grew with what it found would be the defect wearing the sensor's clothes. The producers that broke a threshold are named in the findings, which is where a reader who needs one goes. `mise run hook-cost` is the measurement, and it is deliberately NOT in `verify` and not in the hk gate. A transcript is a property of the WORLD rather than of the commit — the `lock-complete` / `lock-currency` split one gate over — so wiring it into `verify` would red a branch for a session it did not cause and would pass vacuously in CI, where no transcript exists at all. It is also the row's acceptance: the 20% figure ships as a re-runnable command rather than as a number in an issue body. 12000 is derived and is deliberately not the measured figure. 1223 KB is ~313,000 tokens on the engine's own estimator, and a ceiling there would certify the defect rather than refuse it; this is the budget a session SHOULD spend, against `[budget.instructions]`'s 3,500 for the always-loaded surface. Eight unit cases and eight over the compiled binary, and the discriminating ones are the point: a hook repeating itself is refused, a hook reporting one change-set once is clean, a hook silent on success is clean and costs nothing. Without the two clean cases a rule that refused every emission would satisfy the first and gate nothing. The mutation case is the `count > 1` filter: drop it and every single emission becomes a repeat, so the clean cases go red. The anti-vacuity case is an UNDECLARED ceiling, which still measures and refuses nothing — which is also how a consumer reads its own number before adopting one. `a_repository_with_no_transcript_is_a_usage_error_and_never_a_clean_pass` asserts the refusal names `[transcript]`, not just the exit code: exit 1 is also what an unparseable config produces, and an earlier draft of this file passed over a fixture that never reached the verb because `kind = "forbidden_path"` names a variant the engine does not have. `trust.rs` carries two kinds rather than one over this table. The thresholds answer different questions — how much a session may cost, and how many times one thing may be said — and a `Weakens:` clause that could not say which of them moved would be articulating nothing; a change tightening the ceiling while raising the allowance is still a weakening, and one comparison would let it through. `census_fixture`'s config moves to a const in the same change. The fixture's job is to supply each data-emitting verb its minimum input, that list grows by a table per verb, and `policy hooks` is the fifth — which pushed the function past the line ceiling. Admits: 5ab7c3762bcd6f6f9512f5ae54373a1d571d13201c79f989b59465855ef856e5 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 99afa8a87ccbdab6c697408c5d46ed57f1fb2e27 Admits-epoch: 5e81f17ac93d97a8f1412b4cf773f78d648c71d24ff07ced8828ccd923206aaf Admits-author: alec@wenzowski.com Admits-prev: eb3a2ef4b32c5fb74fd9ff01e4c5a1d52ad175965ab693d4077d9fa6d0458d9a Admits-answer-lost: The row's own mechanism. `hookcost::judge` treats an undeclared ceiling as no ceiling, so without this table the new gate is unreachable in consumer #1 — it measures, decides nothing, and only the anti-vacuity mirror ever exercises it. That is a dead gate, which non-negotiable rule 2 calls half a change, and it is the exact defect this row exists to end: prose asking hooks to be quiet with no runnable gate behind it. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a new `batten.toml` table: CLOUD-417 requires a budget over hook output per session declared in `policy-budget`'s own grammar, and non-negotiable rule 1 forbids the numbers living as constants in `crates/batten` — a consumer whose hooks are quieter or louder cannot move a figure compiled into the engine. No non-protected path carries it. The write is one a reviewer sees in the diff it lands in — a short `[hook_output]` table with its two thresholds and the measurement they are derived from, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the neighbouring `[budget.instructions]`, `[refusal]` and `[advisory]` tables and copied their shape and their boundary convention deliberately; reading further produces no route that writes the keys. `patch run first` does not apply either: a patch that adds a top-level table to the policy authority is still a write to `batten.toml`, so it reaches this same class one indirection later. Admits: 1b620bf424e6e5e51ea5b9f97e3d2453bdb584230ee7f55b0a197444a882ba5b Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/module-layering.rego Admits-head: 99afa8a87ccbdab6c697408c5d46ed57f1fb2e27 Admits-epoch: 65c5b8996a1b40713c48fc6c1e8b991e3f231fe68407dfb9c2eca5efd525ea6d Admits-author: alec@wenzowski.com Admits-prev: 174e8e0c026fc45c027ae59a86b35cd5119c69fd659430810a6c7602a88caf48 Admits-answer-lost: CLOUD-417's whole change. `batten-check` is red until the new module is placed, so the ticket cannot land: the alternative is deleting `hookcost.rs` and leaving hook output unmeasured in aggregate, which is the gate working as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the layer table: `module-layering`'s absence-is-an-error clause refuses `crates/batten/src/hookcost.rs` until this module places it, and a placement is a claim about architecture that only this file carries. No non-protected path can place a module. The write is one a reviewer sees in the diff it lands in — one entry in `declared_modules` with its placement comment, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read this module in full — its absence-is-an-error clause, its forbidden-edge table and its test tiers — and reading further produces no route that adds the placement. `patch run first` does not apply either: a patch that adds a member to `declared_modules` is still a write to `policy/module-layering.rego`, so it reaches this same class one indirection later. Admits: 790a8c58bb43d71c543b70276b367f1b9750d26be38ab825e169c98ed6eb9f64 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .serena/memories/core.md Admits-head: 99afa8a87ccbdab6c697408c5d46ed57f1fb2e27 Admits-epoch: 5e81f17ac93d97a8f1412b4cf773f78d648c71d24ff07ced8828ccd923206aaf Admits-author: alec@wenzowski.com Admits-prev: 35490aec1672d07e9dcecd3c525e790988f9135721d7dfe6f9f70207a5553293 Admits-answer-lost: CLOUD-417's whole change. `module-map-check` is red until the new module has its row, so the commit cannot land: the alternative is deleting `hookcost.rs` and leaving hook output unmeasured in aggregate, which is the gate working as designed and the module simply not being written. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS a row in the module map: `module-map-check` refuses `crates/batten/src/hookcost.rs` until `.serena/memories/core.md` carries its row, and that file is the one authority on where each module sits. No non-protected path can place a module in the map. The write is one a reviewer sees in the diff it lands in — one entry describing `hookcost.rs`, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read the map's neighbouring rows and written this one to match them; reading further produces no route that adds the entry. `patch run first` does not apply either: a patch that adds a row to the module map is still a write to `.serena/memories/core.md`, so it reaches this same class one indirection later. Refs: CLOUD-417 --- .serena/memories/core.md | 32 ++ batten.toml | 36 ++ completions/batten.bash | 73 ++- completions/batten.fish | 204 +++---- completions/batten.zsh | 57 ++ crates/batten/src/budget.rs | 11 + crates/batten/src/bypass.rs | 5 +- crates/batten/src/cli.rs | 10 + crates/batten/src/completion.rs | 5 +- crates/batten/src/config.rs | 9 + crates/batten/src/hookcost.rs | 505 ++++++++++++++++++ crates/batten/src/lib.rs | 60 +++ crates/batten/src/resolve.rs | 9 + crates/batten/src/selfwrite.rs | 5 +- crates/batten/src/spec.rs | 2 + crates/batten/src/surface.rs | 22 + crates/batten/src/transcript.rs | 155 ++++++ crates/batten/src/trust.rs | 93 ++++ crates/batten/tests/it/cli.rs | 82 ++- crates/batten/tests/it/hook_cost.rs | 271 ++++++++++ crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/pointer_only.rs | 12 + .../it__snapshots__golden_json_schema.snap | 16 + hk.pkl | 1 + man/batten-policy-hooks.1 | 16 + man/batten-policy.1 | 3 + mise.toml | 24 + policy/module-layering.rego | 11 + schema/batten.schema.json | 34 ++ 29 files changed, 1604 insertions(+), 160 deletions(-) create mode 100644 crates/batten/src/hookcost.rs create mode 100644 crates/batten/tests/it/hook_cost.rs create mode 100644 man/batten-policy-hooks.1 diff --git a/.serena/memories/core.md b/.serena/memories/core.md index 90727d8ab..a2b58820a 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -1089,6 +1089,38 @@ transcript CONTENT needs 1029 first, and nothing landed authorises one. housing it there would close a module cycle. Bound (CLOUD-211): a mediated deny comes only from a computable predicate, never a judge verdict, so the shape models no advisory output — no confidence, no severity, no "maybe". +- `hookcost.rs` — what this repository's own hooks cost the session that runs + them (CLOUD-417). Rule 4 is stated per-CHECK and enforced per-CHECK, so nobody + had measured the hooks IN AGGREGATE, where one compliant line is emitted + hundreds of times and every copy stays in context forever. Measured on one + captured transcript (758 turns, 5.83 MB): `hook_success` 1181 KB + + `hook_additional_context` 42 KB — **hook output alone is 20% of the + transcript**, the largest contributor saying one identical true thing every + turn. Same shape CLOUD-896 found one layer down, and the same answer: put the + ceiling on the aggregate, because the aggregate is what is spent. Two + predicates on `[hook_output]`, and the second is most of the win — + `max_tokens` bounds the session, `max_repeats` makes **"silence on success is + the default"** and **"a repeat is a pointer to the first, not a copy"** + DECIDABLE rather than prose, because a hook saying the same thing every turn is + a digest repeated. Floor on `max_repeats` is 1, never 0: saying it once is the + report, and refusing the first emission would silence the finding rather than + its restatement — which the row puts explicitly out of scope. Pointer-only + structurally: `transcript.rs` hashes the emitted text + (`identity::context_fingerprint`) and DROPS it at the parse, so a measurement + of an over-wide channel cannot itself carry what the channel said; the report + keeps eight hex characters, a count and the first copy's line. Grouping key is + (producer, digest) — two hooks emitting one string are two producers, not a + repeat. `Event::HookOutput` is APPENDED to the transcript vocabulary and gated + on the host's `hook_*` tag PREFIX rather than an enumerated set, because an + unrecognized tag counted as zero is the silent under-report this row ends. + Empty output is not an emission, which is what keeps three silent records from + hashing alike and manufacturing a violation out of the behaviour being asked + for. `Reading::line` is ONE line and `batten policy hooks` prints nothing + around it — the self-applying property, asserted in both tiers. Not in + `verify` and not in the hk gate: a transcript is a property of the WORLD, not + of the commit (`lock-complete` vs `lock-currency`), so `mise run hook-cost` is + a hand run — which is also the row's acceptance clause, the 20% figure shipped + as a re-runnable command rather than a number in an issue body. - `markers.rs` — counted suppression markers (CLOUD-36): how many times policy was waved through, and where. Tokens are config, never crate constants (rule 1); hits are pointer-only (`path:line` + marker id, rule 4) and `counts` diff --git a/batten.toml b/batten.toml index c5f1d18a8..8655c0305 100644 --- a/batten.toml +++ b/batten.toml @@ -3306,6 +3306,42 @@ max_tokens = 24 [advisory] max_tokens = 1024 +# What THIS REPOSITORY'S OWN HOOKS may cost one session (CLOUD-417). +# +# The three thresholds above bound one thing each: what loads once per session, +# what one refusal line says, what one advisory emission says. None of them +# bounds the SUM over a session, which is where the cost actually landed — +# measured on transcript `125cdf71` (758 turns, 5.83 MB): `hook_success` at +# 1181 KB and `hook_additional_context` at 42 KB, against 95 KB of edited files +# and 88 KB of delivered memories. Hook output alone was 20% of the transcript, +# and the largest single contributor said one true, correctly pointer-shaped, +# identical thing on essentially every turn. +# +# `max_repeats = 1` IS THE PREDICATE THAT MATTERS, and 1 is the floor rather than +# a tuning: saying it once is the report, saying it again is the copy. This is +# what makes "silence on success is the default" and "a repeat is a pointer to +# the first, not a copy" runnable rather than prose — a hook confirming success +# every turn is byte-identical every turn, so it is exactly a digest repeated. +# `max_repeats = 0` is refused at load: it would silence a hook's FIRST emission, +# which is removing the finding rather than its restatement, and this row puts +# that explicitly out of scope. +# +# 12000 is DERIVED and it is deliberately not the measured number. 1223 KB is +# ~313,000 tokens on the engine's own estimator; a ceiling at that figure would +# certify the defect. It is the budget a session SHOULD spend: `[budget]` above +# lets the always-loaded surface cost 3,500 tokens once, and a session's whole +# hook channel earning a few multiples of that — findings that are real, said +# once — is the shape being asked for. Any repository can recompute its own with +# `mise run hook-cost`, which is why the figure ships as a command rather than as +# a number in an issue body. +# +# 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. +[hook_output] +max_tokens = 12000 +max_repeats = 1 + # The test suite is half of this repository's definition of green, and was the # unguarded half (CLOUD-55). It cannot be a `protected` path — tests are edited # every day, so a protected glob would block writing them — so the computable diff --git a/completions/batten.bash b/completions/batten.bash index 0c1a68cb7..db079156d 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -514,6 +514,9 @@ _batten() { batten__subcmd__help__subcmd__policy,explain) cmd="batten__subcmd__help__subcmd__policy__subcmd__explain" ;; + batten__subcmd__help__subcmd__policy,hooks) + cmd="batten__subcmd__help__subcmd__policy__subcmd__hooks" + ;; batten__subcmd__help__subcmd__policy,test) cmd="batten__subcmd__help__subcmd__policy__subcmd__test" ;; @@ -661,6 +664,9 @@ _batten() { batten__subcmd__policy,help) cmd="batten__subcmd__policy__subcmd__help" ;; + batten__subcmd__policy,hooks) + cmd="batten__subcmd__policy__subcmd__hooks" + ;; batten__subcmd__policy,test) cmd="batten__subcmd__policy__subcmd__test" ;; @@ -676,6 +682,9 @@ _batten() { batten__subcmd__policy__subcmd__help,help) cmd="batten__subcmd__policy__subcmd__help__subcmd__help" ;; + batten__subcmd__policy__subcmd__help,hooks) + cmd="batten__subcmd__policy__subcmd__help__subcmd__hooks" + ;; batten__subcmd__policy__subcmd__help,test) cmd="batten__subcmd__policy__subcmd__help__subcmd__test" ;; @@ -3403,7 +3412,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__policy) - opts="budget test tools explain" + opts="budget hooks test tools explain" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3444,6 +3453,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__policy__subcmd__hooks) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__policy__subcmd__test) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -4651,7 +4674,7 @@ _batten() { return 0 ;; batten__subcmd__policy) - opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help budget test tools explain help" + opts="-q -v -y -h --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help budget hooks test tools explain help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -4741,7 +4764,7 @@ _batten() { return 0 ;; batten__subcmd__policy__subcmd__help) - opts="budget test tools explain help" + opts="budget hooks test tools explain help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -4796,6 +4819,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__policy__subcmd__help__subcmd__hooks) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__policy__subcmd__help__subcmd__test) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -4824,6 +4861,36 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__policy__subcmd__hooks) + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__policy__subcmd__test) opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index f837d601f..a495d97e7 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -60,7 +60,6 @@ complete -c batten -n "__fish_batten_needs_command" -f -a "init" -d 'Write a sta complete -c batten -n "__fish_batten_needs_command" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' complete -c batten -n "__fish_batten_needs_command" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' complete -c batten -n "__fish_batten_needs_command" -f -a "perf" -d 'Measure this repository\'s own invocation cost' -complete -c batten -n "__fish_batten_needs_command" -f -a "mutate" -d 'Decide whether this repository\'s gates discriminate, rather than merely parse' complete -c batten -n "__fish_batten_needs_command" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' complete -c batten -n "__fish_batten_needs_command" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' complete -c batten -n "__fish_batten_needs_command" -f -a "ready" -d 'Whether an issue\'s Ready block satisfies the checkable clauses of the gate' @@ -868,101 +867,33 @@ complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subcommand_from pair" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subcommand_from help" -f -a "pair" -d 'Measure this branch and its merge base back to back on one machine, and print both arms as paired records' complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -f -a "sweep" -d 'Apply every declared mutation to its source and report the ones its declared suite did not catch' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -f -a "census" -d 'Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption' -complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' -standard\t'The default: a finding is a violation' -strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' -quiet\t'Suppress ordinary progress; keep warnings' -normal\t'The default' -verbose\t'Explain what is being checked' -debug\t'Add resolution detail' -trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' -standard\t'The default: a finding is a violation' -strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' -quiet\t'Suppress ordinary progress; keep warnings' -normal\t'The default' -verbose\t'Explain what is being checked' -debug\t'Add resolution detail' -trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from help" -f -a "sweep" -d 'Apply every declared mutation to its source and report the ones its declared suite did not catch' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from help" -f -a "census" -d 'Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption' -complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' -standard\t'The default: a finding is a violation' -strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' -quiet\t'Suppress ordinary progress; keep warnings' -normal\t'The default' -verbose\t'Explain what is being checked' -debug\t'Add resolution detail' -trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' -complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget test tools explain help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -f -a "hooks" -d 'Judge this session\'s hook output against its declared per-session budget' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' +complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from budget" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -985,6 +916,28 @@ complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from budget" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from budget" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from budget" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from hooks" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from test" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -1052,6 +1005,7 @@ complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from explain" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' +complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "hooks" -d 'Judge this session\'s hook output against its declared per-session budget' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' complete -c batten -n "__fish_batten_using_subcommand policy; and __fish_seen_subcommand_from help" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' @@ -2147,41 +2101,40 @@ complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "check" -d 'Run the applicable read-only gates against the repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "mcp" -d 'Dispatch a declared MCP call and hand back a reduction instead of the payload' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "target" -d 'Inspect and reclaim this repository\'s build tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "config" -d 'Inspect configuration' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "lint" -d 'Lint an artifact against a declared schema' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "spec" -d 'Print the tool\'s own command spec' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "perf" -d 'Measure this repository\'s own invocation cost' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "mutate" -d 'Decide whether this repository\'s gates discriminate, rather than merely parse' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "ready" -d 'Whether an issue\'s Ready block satisfies the checkable clauses of the gate' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "checks" -d 'Whether a commit\'s check runs answer the question a landing depends on' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "pr" -d 'The pull request a landing drives, and the answers it waits on' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "claim" -d 'Whether the issue you are about to pull is actually unclaimed' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "payload" -d 'Read a hook payload from stdin' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "record" -d 'Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "wiring" -d 'Repair a host\'s hook registrations' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "check" -d 'Run the applicable read-only gates against the repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "mcp" -d 'Dispatch a declared MCP call and hand back a reduction instead of the payload' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "target" -d 'Inspect and reclaim this repository\'s build tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "config" -d 'Inspect configuration' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "lint" -d 'Lint an artifact against a declared schema' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "spec" -d 'Print the tool\'s own command spec' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "perf" -d 'Measure this repository\'s own invocation cost' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "ready" -d 'Whether an issue\'s Ready block satisfies the checkable clauses of the gate' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "checks" -d 'Whether a commit\'s check runs answer the question a landing depends on' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "pr" -d 'The pull request a landing drives, and the answers it waits on' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "claim" -d 'Whether the issue you are about to pull is actually unclaimed' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "payload" -d 'Read a hook payload from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "record" -d 'Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "wiring" -d 'Repair a host\'s hook registrations' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "show" -d 'Print a capture\'s pointer, or the lines a selection asks for, with no second run' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "find" -d 'Resolve a stored tool response by the key it carries, with no handle to look up first' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "list" -d 'List this repository\'s captures as handles, in a fixed order' @@ -2200,9 +2153,8 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "markdown" -d 'Emit the whole command surface as one markdown reference, on stdout' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "schema" -d 'Emit the JSON Schema for a config or policy-input surface, derived from the types that define it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from perf" -f -a "pair" -d 'Measure this branch and its merge base back to back on one machine, and print both arms as paired records' -complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from mutate" -f -a "sweep" -d 'Apply every declared mutation to its source and report the ones its declared suite did not catch' -complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from mutate" -f -a "census" -d 'Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "hooks" -d 'Judge this session\'s hook output against its declared per-session budget' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "tools" -d 'Print the tool names the mediated-call rows decide, one per line' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "explain" -d 'Resolve a verdict token to its class definition and the routes out of it' diff --git a/completions/batten.zsh b/completions/batten.zsh index 884157207..203e107e1 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -1586,6 +1586,37 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(hooks) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'-J[Emit byte-stable JSON instead of pointer lines]' \ +'--json[Emit byte-stable JSON instead of pointer lines]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (test) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -1696,6 +1727,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(hooks) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (test) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -4022,6 +4057,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(hooks) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (test) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -5302,6 +5341,7 @@ _batten__subcmd__help__subcmd__perf__subcmd__pair_commands() { _batten__subcmd__help__subcmd__policy_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ +'hooks:Judge this session'\''s hook output against its declared per-session budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ 'tools:Print the tool names the mediated-call rows decide, one per line' \ 'explain:Resolve a verdict token to its class definition and the routes out of it' \ @@ -5318,6 +5358,11 @@ _batten__subcmd__help__subcmd__policy__subcmd__explain_commands() { local commands; commands=() _describe -t commands 'batten help policy explain commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__policy__subcmd__hooks_commands] )) || +_batten__subcmd__help__subcmd__policy__subcmd__hooks_commands() { + local commands; commands=() + _describe -t commands 'batten help policy hooks commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__policy__subcmd__test_commands] )) || _batten__subcmd__help__subcmd__policy__subcmd__test_commands() { local commands; commands=() @@ -5713,6 +5758,7 @@ _batten__subcmd__perf__subcmd__pair_commands() { _batten__subcmd__policy_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ +'hooks:Judge this session'\''s hook output against its declared per-session budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ 'tools:Print the tool names the mediated-call rows decide, one per line' \ 'explain:Resolve a verdict token to its class definition and the routes out of it' \ @@ -5734,6 +5780,7 @@ _batten__subcmd__policy__subcmd__explain_commands() { _batten__subcmd__policy__subcmd__help_commands() { local commands; commands=( 'budget:Judge the always-loaded instruction set against its declared token budget' \ +'hooks:Judge this session'\''s hook output against its declared per-session budget' \ 'test:Run each registered module'\''s own \`test_\` rules and report the predicates none exercised' \ 'tools:Print the tool names the mediated-call rows decide, one per line' \ 'explain:Resolve a verdict token to its class definition and the routes out of it' \ @@ -5756,6 +5803,11 @@ _batten__subcmd__policy__subcmd__help__subcmd__help_commands() { local commands; commands=() _describe -t commands 'batten policy help help commands' commands "$@" } +(( $+functions[_batten__subcmd__policy__subcmd__help__subcmd__hooks_commands] )) || +_batten__subcmd__policy__subcmd__help__subcmd__hooks_commands() { + local commands; commands=() + _describe -t commands 'batten policy help hooks commands' commands "$@" +} (( $+functions[_batten__subcmd__policy__subcmd__help__subcmd__test_commands] )) || _batten__subcmd__policy__subcmd__help__subcmd__test_commands() { local commands; commands=() @@ -5766,6 +5818,11 @@ _batten__subcmd__policy__subcmd__help__subcmd__tools_commands() { local commands; commands=() _describe -t commands 'batten policy help tools commands' commands "$@" } +(( $+functions[_batten__subcmd__policy__subcmd__hooks_commands] )) || +_batten__subcmd__policy__subcmd__hooks_commands() { + local commands; commands=() + _describe -t commands 'batten policy hooks commands' commands "$@" +} (( $+functions[_batten__subcmd__policy__subcmd__test_commands] )) || _batten__subcmd__policy__subcmd__test_commands() { local commands; commands=() diff --git a/crates/batten/src/budget.rs b/crates/batten/src/budget.rs index db7edcfa3..7d2aec5b7 100644 --- a/crates/batten/src/budget.rs +++ b/crates/batten/src/budget.rs @@ -442,6 +442,17 @@ pub fn estimate_tokens(loaded: &str) -> usize { loaded.len() / BYTES_PER_TOKEN } +/// Estimated tokens over a byte count whose content is not held (CLOUD-417). +/// +/// The same divisor as [`estimate_tokens`], reached from the other side: a +/// session transcript's size is known while its bytes deliberately are not, and +/// a second divisor invented at that call site would make hook cost and +/// instruction cost two incommensurable numbers over one window. +#[must_use] +pub fn estimate_tokens_over(bytes: usize) -> usize { + bytes / BYTES_PER_TOKEN +} + /// Lines of already-[`loaded`] content. /// /// A final line with no trailing newline still counts: it is content an agent diff --git a/crates/batten/src/bypass.rs b/crates/batten/src/bypass.rs index 531ccd886..fd0f95951 100644 --- a/crates/batten/src/bypass.rs +++ b/crates/batten/src/bypass.rs @@ -279,7 +279,10 @@ pub fn scan(stream: &Stream) -> Vec { // Not a tool call and not a retry: an injection carries no // enforcement posture at all (CLOUD-1054). | Event::HookDecision { .. } - | Event::MemoryInjection { .. } => {} + | Event::MemoryInjection { .. } + // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this + // predicate has nothing to say about it. + | Event::HookOutput { .. } => {} } } found.into_values().collect() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 8e6a28d96..f5de7e9f8 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -641,6 +641,13 @@ pub enum PolicyCommand { /// Emit the class as byte-stable JSON instead of pointer lines. json: bool, }, + /// Judge this session's hook output against its budget (CLOUD-417). + /// + /// Appended for [`PolicyCommand::Test`]'s reason. + Hooks { + /// Emit the measurement as byte-stable JSON instead of the one line. + json: bool, + }, } /// Subcommands of `attribution`. @@ -1091,6 +1098,9 @@ fn policy_of(matches: &ArgMatches) -> Option { ("test", matches) => Some(PolicyCommand::Test { json: flag(matches, "json"), }), + ("hooks", matches) => Some(PolicyCommand::Hooks { + json: flag(matches, "json"), + }), ("tools", matches) => Some(PolicyCommand::Tools { json: flag(matches, "json"), }), diff --git a/crates/batten/src/completion.rs b/crates/batten/src/completion.rs index 7d018a316..59933a0da 100644 --- a/crates/batten/src/completion.rs +++ b/crates/batten/src/completion.rs @@ -266,7 +266,10 @@ pub fn signal(stream: &Stream) -> Option { // anything about being done. Event::HookDecision { .. } | Event::ToolResult { .. } - | Event::MemoryInjection { .. } => {} + | Event::MemoryInjection { .. } + // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this + // predicate has nothing to say about it. + | Event::HookOutput { .. } => {} } } latest diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 129ed99cb..5007fb614 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -395,6 +395,11 @@ pub struct Config { /// and the predicate are [`crate::advisory`]. #[serde(default, skip_serializing_if = "Option::is_none")] pub advisory: Option, + /// What this repository's hooks may cost ONE SESSION (CLOUD-417). Absent + /// means unenforced, on `[budget]`'s reading. The type and the predicate are + /// [`crate::hookcost`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hook_output: Option, /// The ref work must land on (CLOUD-51) — the target `worktree status` /// judges at-risk work against. Consumer-specific by nature: which ref is /// the trunk is a property of the repository being gated, never of Batten @@ -1129,6 +1134,9 @@ fn parse_ungated(text: &str, source: &str) -> Result { // Same shape, same reason, one table over: a ceiling nothing can satisfy is // refused at load rather than discovered by the first advisory it silences. crate::advisory::validate(config.advisory.as_ref()).map_err(UsageError::raise)?; + // One table over again: a session ceiling nothing can satisfy is refused at + // load rather than discovered by the first hook it silences. + crate::hookcost::validate(config.hook_output.as_ref()).map_err(UsageError::raise)?; // Validated at parse, like `[[verb]]` and `[[marker]]`: CLOUD-242's lesson // is that a table nothing validates is coverage that means nothing. if let Some(ci) = &config.ci { @@ -1282,6 +1290,7 @@ impl Config { budget: None, refusal: None, advisory: None, + hook_output: None, must_land_on: None, // An authority that cannot be read attaches no side effects. The // safe direction is unambiguous here: firing a command an diff --git a/crates/batten/src/hookcost.rs b/crates/batten/src/hookcost.rs new file mode 100644 index 000000000..5072888fc --- /dev/null +++ b/crates/batten/src/hookcost.rs @@ -0,0 +1,505 @@ +//! What this repository's own hooks cost the session that runs them (CLOUD-417). +//! +//! # The finding this exists for +//! +//! Non-negotiable rule 4 holds every check to "a count, `path:line`, or boolean — +//! never the content itself", and each hook obeys it individually. Nobody had +//! measured them **in aggregate**, over a session, where one line that obeys the +//! rule is emitted hundreds of times and every copy stays in context forever. +//! +//! Measured on one captured transcript (758 turns, 5.83 MB): `hook_success` at +//! 1181 KB and `hook_additional_context` at 42 KB against 95 KB of edited files +//! and 88 KB of delivered memories — **hook output alone is 20% of the +//! transcript**. The single largest contributor said one true, correctly +//! pointer-shaped, identical thing on essentially every turn. +//! +//! # Why the rule was unstatable before +//! +//! The output rule is stated per-CHECK and enforced per-CHECK. There is no rule +//! about a check's output over a SESSION, so a hook that is silent by default +//! (correct) and one that confirms success every turn (also individually +//! defensible) are indistinguishable to every gate that exists. That is the same +//! shape CLOUD-896 found one layer down, where three producers each within their +//! own budget shared a channel with none — and the answer is the same: put the +//! ceiling on the aggregate, because the aggregate is what is actually spent. +//! +//! # Two predicates, and the second is most of the win +//! +//! [`Ceiling::max_tokens`] is the blunt one: hook output over a whole session has +//! a ceiling. [`Ceiling::max_repeats`] is the sharp one, and it is what makes +//! "silence on success is the default" and "a repeat is a pointer to the first, +//! not a copy" **decidable** rather than prose. A hook that says the same thing +//! every turn is byte-identical every turn, so it is exactly a digest repeated — +//! and prose asking hooks to be quiet is the feedforward this repository refuses +//! (non-negotiable rule 2). +//! +//! # Pointer-only, structurally +//! +//! Everything here is a count, a digest prefix, or a host-supplied producer name. +//! The emitted text never reaches this module: [`crate::transcript`] hashes it and +//! drops it at the parse. A measurement of an over-wide channel that itself +//! carried what the channel said would be the joke writing itself. +//! +//! # It applies to itself +//! +//! [`Reading::line`] is ONE line. The row's acceptance says so, and it is not +//! decoration: a gate about hook volume whose own report is a paragraph would be +//! the defect wearing the sensor's clothes. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::identity::{FindingKind, StoredIdentity}; +use crate::rules::Finding; +use crate::severity::RuleSeverity; +use crate::transcript::{Event, Stream}; + +/// The `[hook_output]` table: what this repository's hooks may cost one session. +/// +/// **Absent means unenforced**, on `[budget]`'s reading — a threshold nobody +/// declared is not a threshold of zero — so a consumer that has not adopted the +/// table measures exactly as it did before and refuses nothing. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Ceiling { + /// The ceiling on estimated tokens of hook output across the whole session. + /// The boundary is `<=`, matching `[budget]`, `[refusal]` and `[advisory]` + /// so the four thresholds in this tree do not disagree about their own edge. + pub max_tokens: usize, + /// How many times one hook may emit byte-identical text in one session. + /// + /// **The floor is 1, not 0.** Saying a thing once is the report; saying it + /// again is the copy. A ceiling of 0 would refuse the first emission, which + /// is a hook switched off rather than a hook made quiet — and this row puts + /// "removing any hook, or weakening what it detects" explicitly out of scope. + pub max_repeats: usize, +} + +/// Refuse a ceiling nothing could satisfy. +/// +/// # Errors +/// +/// When `max_tokens` is zero — no hook could speak at all — or when +/// `max_repeats` is zero, which refuses a hook's FIRST emission and so silences +/// the finding rather than its restatement. +pub fn validate(ceiling: Option<&Ceiling>) -> Result<(), String> { + let Some(declared) = ceiling else { + return Ok(()); + }; + if declared.max_tokens == 0 { + return Err( + "`[hook_output] max_tokens = 0` refuses every hook that speaks at all — remove the \ + table to leave hook output unbounded, or name a ceiling a session can fit inside" + .to_owned(), + ); + } + if declared.max_repeats == 0 { + return Err( + "`[hook_output] max_repeats = 0` refuses a hook's FIRST emission, which silences the \ + finding rather than its restatement; 1 is the floor — say it once" + .to_owned(), + ); + } + Ok(()) +} + +/// One producer's cost over the session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct Cost { + /// Estimated tokens this producer spent in total. + pub tokens: usize, + /// How many times it emitted anything at all. + pub emissions: usize, +} + +/// One thing said more than once. +/// +/// Pointer-only: the producer's host-given name, a digest PREFIX, a count, and +/// the line the first copy landed on. Never the text, and never the full digest — +/// eight hex characters name the repeat in a report and cannot reconstruct it. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct Repeat { + /// The producer, as the host named it. + pub hook: String, + /// The first eight hex characters of the emission's digest. + pub digest: String, + /// How many copies the session carried. + pub count: usize, + /// The transcript line the FIRST copy landed on — the pointer a repeat is + /// supposed to be, which is the row's own remedy stated as an output field. + pub first_line: usize, +} + +/// What [`measure`] found. +/// +/// Byte-stable for identical input: producers are reported in name order, +/// repeats in (producer, digest) order, and no field derives from the clock, the +/// environment, or where the repository lives. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct Reading { + /// Estimated tokens of hook output over the whole session. + pub tokens: usize, + /// Estimated tokens of the whole transcript, the denominator that turns the + /// number above into the row's headline share. + pub session_tokens: usize, + /// Per-producer costs, in producer-name order. + pub per_hook: BTreeMap, + /// Every emission the session carried more than once, whatever the declared + /// ceiling — the measurement is separate from the judgement, so a reading + /// taken with no table declared still reports what a table would refuse. + pub repeats: Vec, +} + +impl Reading { + /// Hook output as a percentage of the session, rounded down. + /// + /// **Zero when the session measured nothing**, rather than a division that + /// cannot be performed: an empty transcript has no share, and reporting one + /// would be an answer where there is no reading. + #[must_use] + pub fn share(&self) -> usize { + if self.session_tokens == 0 { + return 0; + } + self.tokens * 100 / self.session_tokens + } + + /// The whole report, as ONE line. + /// + /// The row's acceptance clause, and this module's self-application: a gate + /// about hook volume answers in the shape it demands. Counts and a share, + /// never a producer's text — and the producers themselves are named in the + /// findings, which is where a reader who needs one goes. + #[must_use] + pub fn line(&self) -> String { + format!( + "hook output {} token(s), {}% of {} session token(s), {} producer(s), {} repeat(s)", + self.tokens, + self.share(), + self.session_tokens, + self.per_hook.len(), + self.repeats.len() + ) + } +} + +/// The rule id a session-budget finding carries. +pub const BUDGET_RULE: &str = "hook-output-budget"; + +/// The rule id a repeated-emission finding carries. +pub const REPEAT_RULE: &str = "hook-repeat-pointer"; + +/// Count what the hooks spent, from the parsed stream alone. +/// +/// **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. +#[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(); + let mut tokens = 0; + for record in &stream.records { + let Event::HookOutput { + hook, + tokens: cost, + digest, + } = &record.event + else { + continue; + }; + tokens += cost; + let entry = per_hook.entry(hook.clone()).or_insert(Cost { + tokens: 0, + emissions: 0, + }); + entry.tokens += cost; + entry.emissions += 1; + let slot = seen + .entry((hook.clone(), digest.clone())) + .or_insert((0, record.line)); + slot.0 += 1; + } + let repeats = seen + .into_iter() + .filter(|(_, (count, _))| *count > 1) + .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 + // confirm it, which is a payload channel opened by arithmetic. + digest: digest.chars().take(8).collect(), + count, + first_line, + }) + .collect(); + Reading { + tokens, + session_tokens: crate::budget::estimate_tokens_over(stream.bytes), + per_hook, + repeats, + } +} + +/// Judge a reading against the declared ceiling. +/// +/// **An undeclared ceiling judges nothing and is not an error.** That is the +/// anti-vacuity direction stated as behaviour: `measure` still reports, so a +/// consumer can read its own number before choosing one, and adopting the table +/// is a separate act from being measured by it. +#[must_use] +pub fn judge(reading: &Reading, ceiling: Option<&Ceiling>) -> Vec { + let Some(ceiling) = ceiling else { + return Vec::new(); + }; + let mut found = Vec::new(); + if reading.tokens > ceiling.max_tokens { + found.push(finding( + BUDGET_RULE, + // The SUBJECT is the session, named as a count rather than as a + // path: there is no file to point at, and pointing at the transcript + // would name a document rule 4 keeps every byte of off this channel. + format!("session:{}", reading.tokens), + None, + "cut what the hooks restate until the session is under its budget", + )); + } + for repeat in &reading.repeats { + if repeat.count > ceiling.max_repeats { + found.push(finding( + REPEAT_RULE, + format!("{}:{}x{}", repeat.hook, repeat.digest, repeat.count), + Some(repeat.first_line), + "emit it once and make the later turns point at the first, the way \ + `contract-drift` already reports a change-set once", + )); + } + } + found +} + +/// One engine-produced finding, in `budget.rs`'s shape. +/// +/// Engine-produced rather than a `[[rule]]` row, for that module's reason +/// exactly: re-measuring the session IS the check, and the fix is cutting what a +/// hook restates — prose no command can write, so [`Remediation::NoFix`] states +/// it rather than a `Fix::Run` naming a command that would not help. +fn finding(rule: &str, subject: String, line: Option, remedy: &str) -> Finding { + Finding { + rule: rule.to_owned(), + severity: RuleSeverity::Deny, + identity: StoredIdentity::new( + FindingKind::Scope, + crate::identity::scope_fingerprint(rule, &subject), + ), + path: subject, + line, + check: crate::findings::Check::Reevaluate, + remediation: Some(crate::findings::Remediation::NoFix(remedy.to_owned())), + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + use crate::transcript::Record; + + fn emitted(line: usize, hook: &str, digest: &str, tokens: usize) -> Record { + Record { + line, + event: Event::HookOutput { + hook: hook.to_owned(), + tokens, + digest: digest.to_owned(), + }, + } + } + + fn session(records: Vec, bytes: usize) -> Stream { + Stream { + session: Some("s-1".to_owned()), + records, + agent: crate::transcript::AgentContext::default(), + bytes, + } + } + + #[test] + fn a_hook_saying_one_thing_n_times_is_a_violation() { + // THE ROW'S OWN CASE, and the mutation case with it: drop the `count > 1` + // filter in `measure` and every single emission becomes a repeat, so the + // clean case below goes red. + let reading = measure(&session( + vec![ + emitted(3, "SessionStart:mcp", "aaaaaaaabbbb", 40), + emitted(9, "SessionStart:mcp", "aaaaaaaabbbb", 40), + emitted(14, "SessionStart:mcp", "aaaaaaaabbbb", 40), + ], + 4_000, + )); + assert_eq!(reading.repeats.len(), 1); + assert_eq!(reading.repeats[0].count, 3); + assert_eq!( + reading.repeats[0].first_line, 3, + "the pointer is the FIRST copy" + ); + assert_eq!( + reading.repeats[0].digest, "aaaaaaaa", + "a prefix, not the key" + ); + + let refused = judge(&reading, Some(&Ceiling::once())); + assert_eq!(refused.len(), 1); + assert_eq!(refused[0].rule, REPEAT_RULE); + } + + #[test] + fn a_hook_reporting_one_change_set_once_is_clean() { + // The discriminating half. Without it a rule that refused every emission + // would satisfy the case above and gate nothing. + let reading = measure(&session( + vec![ + emitted(3, "PostToolBatch:drift", "cccccccc1111", 30), + emitted(9, "PostToolBatch:drift", "dddddddd2222", 30), + ], + 4_000, + )); + assert!( + reading.repeats.is_empty(), + "two different things are not one" + ); + assert!(judge(&reading, Some(&Ceiling::once())).is_empty()); + } + + #[test] + fn a_hook_silent_on_success_is_clean_and_costs_nothing() { + // Silence is the default, and it has to be spellable as a reading rather + // than only as a posture: no records, no cost, no share, no findings. + let reading = measure(&session(Vec::new(), 4_000)); + assert_eq!(reading.tokens, 0); + assert_eq!(reading.share(), 0); + assert!(reading.per_hook.is_empty()); + assert!(judge(&reading, Some(&Ceiling::once())).is_empty()); + } + + #[test] + fn two_hooks_saying_the_same_thing_are_two_producers_not_one_repeat() { + // The key is (producer, digest). Collapsing to the digest alone would + // report a repeat neither hook made, and blame it on whichever name + // sorted first. + let reading = measure(&session( + vec![ + emitted(3, "one", "eeeeeeee3333", 10), + emitted(4, "two", "eeeeeeee3333", 10), + ], + 4_000, + )); + assert!(reading.repeats.is_empty()); + assert_eq!(reading.per_hook.len(), 2); + } + + #[test] + fn the_session_share_is_the_headline_figure_recomputed() { + // The row's acceptance: the 20% figure is re-runnable rather than + // believed. 1000 tokens of hook output against a 4000-byte transcript, + // which the estimator reads as 1000 session tokens... so the share is + // over the denominator the estimator gives, not over a byte count. + let reading = measure(&session(vec![emitted(3, "loud", "ffff4444", 200)], 4_000)); + assert_eq!(reading.session_tokens, 1_000); + assert_eq!(reading.tokens, 200); + assert_eq!(reading.share(), 20); + } + + #[test] + fn an_undeclared_ceiling_measures_and_refuses_nothing() { + // ANTI-VACUITY. The reading still reports the repeat; only the judgement + // is withheld, so a consumer can read its own number before adopting one. + let reading = measure(&session( + vec![ + emitted(3, "loud", "aaaaaaaa", 9_000), + emitted(4, "loud", "aaaaaaaa", 9_000), + ], + 10, + )); + assert_eq!(reading.repeats.len(), 1, "measured"); + assert!(judge(&reading, None).is_empty(), "and not judged"); + } + + #[test] + fn the_budget_arm_fires_on_the_total_and_the_boundary_is_inclusive() { + let reading = measure(&session(vec![emitted(3, "loud", "aaaaaaaa", 100)], 4_000)); + assert!( + judge( + &reading, + Some(&Ceiling { + max_tokens: 100, + max_repeats: 1 + }) + ) + .is_empty(), + "exactly at budget passes, as `[budget]` does" + ); + let over = judge( + &reading, + Some(&Ceiling { + max_tokens: 99, + max_repeats: 1, + }), + ); + assert_eq!(over.len(), 1); + assert_eq!(over[0].rule, BUDGET_RULE); + } + + #[test] + fn a_ceiling_nothing_can_satisfy_is_refused_at_load() { + assert!(validate(None).is_ok()); + assert!(validate(Some(&Ceiling::once())).is_ok()); + assert!( + validate(Some(&Ceiling { + max_tokens: 0, + max_repeats: 1 + })) + .is_err() + ); + assert!( + validate(Some(&Ceiling { + max_tokens: 10, + max_repeats: 0 + })) + .is_err(), + "zero repeats refuses the finding rather than its restatement" + ); + } + + #[test] + fn the_reports_own_output_is_one_line() { + // THE SELF-APPLYING PROPERTY, asserted rather than intended. + let reading = measure(&session( + vec![ + emitted(3, "a", "1111", 5), + emitted(4, "b", "2222", 5), + emitted(5, "b", "2222", 5), + ], + 400, + )); + assert_eq!(reading.line().lines().count(), 1); + } + + impl Ceiling { + /// A ceiling that refuses only a repeat, so a case can vary one predicate. + fn once() -> Ceiling { + Ceiling { + max_tokens: usize::MAX, + max_repeats: 1, + } + } + } +} diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 1a91badd3..fb3adc496 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -45,6 +45,7 @@ pub mod forge; pub mod git; pub mod handler; pub mod hook; +pub mod hookcost; pub mod identity; pub mod init; /// Rust call sites, parsed — where a token sits, not merely that it appears. @@ -3407,9 +3408,68 @@ fn run_policy( PolicyCommand::Test { json } => run_policy_test(json, overrides, out), PolicyCommand::Tools { json } => run_policy_tools(json, overrides, out), PolicyCommand::Explain { token, json } => run_policy_explain(&token, json, overrides, out), + PolicyCommand::Hooks { json } => run_policy_hooks(json, overrides, out), } } +/// Judge this session's hook output against its declared budget (CLOUD-417). +/// +/// # The measurement runs whether or not a ceiling is declared +/// +/// That split is the row's own acceptance clause made structural: *"the +/// measurement is re-runnable against any transcript, so the 20% figure can be +/// checked rather than believed"*. So an undeclared `[hook_output]` still prints +/// the reading and exits `0` — a repository can read its own number before +/// choosing one, and the number is derived rather than typed into a body. +/// +/// # Errors +/// +/// A [`UsageError`] (→ exit `1`) when no transcript is configured or the +/// configured one cannot be read: this verb's whole subject is that file, so +/// could-not-look must be an error rather than a vacuous pass over nothing — +/// exactly the shape `budget`'s dead-glob refusal takes one verb up. +fn run_policy_hooks(json: bool, overrides: &Overrides, out: &mut dyn Write) -> Result { + let config = resolve::resolve(Path::new("."), overrides)?; + let path = transcript::configured_path(config.transcript.as_ref()).ok_or_else(|| { + UsageError::raise(format!( + "no [transcript] path in {}; there is no session to measure", + config::CONFIG_FILE + )) + })?; + let label = path.display().to_string(); + let body = std::fs::read_to_string(&path) + .map_err(|_| UsageError::raise(format!("{}: {label}", transcript::UNREADABLE_NOTICE)))?; + let stream = transcript::parse(&body, &label)?; + let reading = hookcost::measure(&stream); + let findings = hookcost::judge(&reading, config.hook_output.as_ref()); + if json { + // Emitted unconditionally, including for a session within budget: JSON + // that is sometimes absent is unparseable. + writeln!(out, "{}", serde_json::to_string_pretty(&reading)?)?; + } else { + // ONE LINE, always — the self-applying property, and the reason this + // verb does not print a per-producer breakdown the way `budget` prints + // per-file rows. A gate about hook volume whose own report grows with + // what it found would be the defect wearing the sensor's clothes; the + // producers that actually broke a threshold are named in the findings + // below, which is where a reader who needs one goes. + writeln!(out, "{}", reading.line())?; + for finding in &findings { + // ` `, the shape every other pointer line here takes, + // with the transcript line appended where the finding has one — a + // repeat's pointer IS the first copy, so it is the field a reader + // acts on. Rendered here rather than through `refusal::render` + // because these are findings about a measurement rather than a + // refusal of a call, and there is no route out of one to advertise. + match finding.line { + Some(line) => writeln!(out, "{}:{line} {}", finding.path, finding.rule)?, + None => writeln!(out, "{} {}", finding.path, finding.rule)?, + } + } + } + Ok(ExitCode::verdict(!findings.is_empty())) +} + /// Dispatch the `override` subtree. /// /// One arm today, and a function rather than an inline match for the reason diff --git a/crates/batten/src/resolve.rs b/crates/batten/src/resolve.rs index d7a407960..ad2038859 100644 --- a/crates/batten/src/resolve.rs +++ b/crates/batten/src/resolve.rs @@ -606,6 +606,10 @@ pub struct Resolved { /// the committed bytes for the direction. #[serde(skip_serializing_if = "Option::is_none")] pub advisory: Option, + /// What this repository's hooks may cost one session (CLOUD-417), as the + /// authority states it. Not layered, for the two ceilings above's reason. + #[serde(skip_serializing_if = "Option::is_none")] + pub hook_output: Option, /// The ref work must land on (CLOUD-51), as the authority states it. #[serde(skip_serializing_if = "Option::is_none")] pub must_land_on: Option, @@ -1622,6 +1626,7 @@ fn assemble( waivers: tables.waivers, budget: repo.budget.clone(), advisory: repo.advisory.clone(), + hook_output: repo.hook_output.clone(), must_land_on: repo.must_land_on.clone(), hook: repo.hook.clone(), transcript: repo.transcript.clone(), @@ -1729,6 +1734,10 @@ fn attribution( // sets for itself, so a local file raising one would be the weakening // `trust.rs` compares the committed bytes to catch. ("advisory", authority_set(repo.advisory.is_some())), + // And the session ceiling beside the per-emission one (CLOUD-417), + // authority-only for the same reason: a local file raising it would be + // the weakening the committed-bytes comparison exists to catch. + ("hook_output", authority_set(repo.hook_output.is_some())), ("must_land_on", authority_set(repo.must_land_on.is_some())), ("hook", authority_set(repo.hook.is_some())), ("transcript", authority_set(repo.transcript.is_some())), diff --git a/crates/batten/src/selfwrite.rs b/crates/batten/src/selfwrite.rs index 6808cc3d8..5b130e613 100644 --- a/crates/batten/src/selfwrite.rs +++ b/crates/batten/src/selfwrite.rs @@ -197,7 +197,10 @@ pub fn scan(stream: &Stream, memory_root: &str) -> Vec { // the agent writing one out (CLOUD-1054) — the opposite direction // from the self-write this module detects. | Event::HookDecision { .. } - | Event::MemoryInjection { .. } => {} + | Event::MemoryInjection { .. } + // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this + // predicate has nothing to say about it. + | Event::HookOutput { .. } => {} } } detections diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index ee3bbfc58..917c3c4de 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -376,6 +376,7 @@ mod tests { "policy".to_owned(), "policy budget".to_owned(), "policy explain".to_owned(), + "policy hooks".to_owned(), "policy test".to_owned(), "policy tools".to_owned(), // The freshness verb, never the `provision` noun or `apply`: @@ -610,6 +611,7 @@ mod tests { "policy".to_owned(), "policy budget".to_owned(), "policy explain".to_owned(), + "policy hooks".to_owned(), "policy test".to_owned(), "policy tools".to_owned(), // The poll around `checks green`'s verdict (CLOUD-1143), ported diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index d677f6021..24446dc8e 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -2362,6 +2362,28 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON], }, + // The aggregate half of the same question one verb up (CLOUD-417): + // `policy budget` judges what loads once per session, this judges what the + // hooks put in front of the model over the whole of it. + // + // A SIBLING VERB rather than a flag on `policy budget`, because the subjects + // are different objects: one measures files this repository commits, the + // other measures a transcript a host wrote. One verb answering both would + // make a single verdict unattributable to either (`commit` beside + // `attribution` records that reasoning for its own pair). + // + // `read` structurally: one file read at the configured transcript path plus + // arithmetic over what it contains. Nothing is spawned and no user-supplied + // code is reachable, which is what the `read` promise CLOUD-50 requires — and + // the transcript's bytes are hashed and dropped at the parse, so the payload + // rule 4 keeps off every channel never reaches this verb at all. + CommandDecl { + path: "policy hooks", + about: "Judge this session's hook output against its declared per-session budget", + data_channel: true, + effect: Effect::Read, + flags: &[JSON], + }, // Compiles the registered modules and evaluates their own `test_` rules // in-process (CLOUD-835). `read` structurally, not by assertion: the // evaluator is `Authority::Supplied` — it cannot open a file, start a diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index 7c61a841f..2adbb8fcf 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -291,6 +291,41 @@ pub enum Event { /// at the parse rather than carried and filtered later. path: String, }, + /// A hook put text into the context, and what it cost (CLOUD-417). + /// + /// **APPENDED, for [`Event::MemoryInjection`]'s reason**: a consumer holding + /// an ordinal must not find it means something else. + /// + /// **A count and a digest, never the text.** That is what makes this variant + /// spellable at all under rule 4 — the row's whole finding is that the output + /// rule is stated per-CHECK and enforced per-CHECK, so nothing measures the + /// hooks in aggregate, and measuring them must not itself become a channel + /// that carries what they said. The bytes are hashed and dropped inside + /// [`collect`]; no caller can recover them. + /// + /// **This is deliberately wider than [`Event::HookDecision`] beside it.** That + /// variant fires on an exit code and answers "what did the hook decide"; this + /// one fires on OUTPUT and answers "what did the hook cost". A hook that + /// returns 0 and says nothing produces neither, which is the posture + /// `contract-drift` already documents and the one this row exists to spread. + HookOutput { + /// The hook as the host named it — `hookName` where the host gave one, + /// its `hookEvent` otherwise. The grouping key for a repeat, so a + /// consumer's own hook names never reach the engine (rule 1): whatever + /// string the host wrote is carried through and never matched against. + hook: String, + /// Estimated tokens over the emitted text, on [`crate::budget`]'s + /// estimator — the same one `[budget.instructions]` and `[refusal]` + /// count with, so the three thresholds in a tree are commensurable. + tokens: usize, + /// A digest of the emitted text, which is what makes "a repeat is a + /// pointer to the first, not a copy" decidable without keeping the copy. + /// + /// Two emissions are the SAME thing said twice exactly when their digests + /// 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, + }, } /// One event and where it was found. @@ -320,6 +355,15 @@ pub struct Stream { /// What the harness reported about itself and about what it reached /// (CLOUD-579). pub agent: AgentContext, + /// The transcript's own size in bytes (CLOUD-417). + /// + /// **A count, so rule 4 holds** — it is the denominator that turns "hook + /// output cost N tokens" into "hook output was N% of this session", and the + /// row's acceptance is that the 20% figure be re-runnable rather than + /// believed. Carried on the stream rather than re-`stat`ed by a caller, + /// because the bytes the parse actually read and the bytes on disk are two + /// different numbers the moment a host is still appending. + pub bytes: usize, } /// The agent's own composition, as far as a transcript states it. @@ -459,6 +503,14 @@ impl Stream { // scalar in this struct would be the total without the breakdown // — the half nobody asked for (CLOUD-1054). Event::MemoryInjection { .. } => {} + // And counted by nothing here for the same reason once more + // (CLOUD-417): hook COST is [`crate::hookcost::measure`]'s + // document, with its own per-producer breakdown and its own + // reader, and a scalar here would be the total without the + // 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 { .. } => {} } } counts @@ -595,6 +647,7 @@ pub fn parse(body: &str, label: &str) -> Result { session, records, agent, + bytes: body.len(), }) } @@ -653,6 +706,40 @@ fn collect(parsed: &Line, line: usize, records: &mut Vec) { event: Event::MemoryInjection { path: path.clone() }, }); } + // WHAT THE HOOK PUT IN THE CONTEXT, AS A COUNT (CLOUD-417). + // + // Gated on the host's tag prefix rather than on an enumerated set: the + // measured session carried `hook_success` at 1181 KB and + // `hook_additional_context` at 42 KB, and a host that ships a third + // `hook_*` tag tomorrow is emitting the same kind of cost. The + // forward-compatibility posture this module states cuts that way — an + // unrecognized tag must not be counted as zero, which is what an + // enumerated set would silently do. + // + // EMPTY IS NOT AN EMISSION, and that is the posture rather than an + // optimization: a hook with nothing to report emits nothing, so a record + // whose streams are all blank is silence and must not be counted as a + // repeat of anything. + if attachment.kind.as_deref().is_some_and(is_hook_tag) { + let text = attachment.emitted(); + if !text.is_empty() { + records.push(Record { + line, + event: Event::HookOutput { + // `hookName` where the host gave one, because two hooks + // on one event are two producers and grouping them would + // hide exactly the repeat this measures. + hook: attachment.producer(), + tokens: crate::budget::estimate_tokens(&text), + // The bytes die here. `context_fingerprint` is the right + // one of identity's family: its own doc says the digest + // "exists so a consumer can reference context it was not + // given", which is this variant's whole contract. + digest: crate::identity::context_fingerprint(text.as_bytes()).to_hex(), + }, + }); + } + } return; } let Some(message) = &parsed.message else { @@ -838,8 +925,76 @@ struct Attachment { tool_use_id: Option, #[serde(rename = "exitCode")] exit_code: Option, + /// The host's own name for the hook that produced this record, e.g. + /// `PreToolUse:Bash`. Captured for CLOUD-417's grouping key only. + #[serde(rename = "hookName")] + hook_name: Option, + /// What the hook wrote to the model's stream. + stdout: Option, + /// What the hook wrote to the operator's stream. Counted too: a host renders + /// it into the context on a deny, so it is spent from the same window. + stderr: Option, + /// The in-band advisory document, on the hosts that carry one. + #[serde(rename = "additionalContext")] + additional_context: Option, } +impl Attachment { + /// Everything this hook put in front of the model, joined in a fixed order. + /// + /// **The order is fixed so the digest is**: two identical emissions must + /// hash alike, and a field order that depended on which keys the host + /// happened to write would make the same text hash two ways and hide a + /// repeat behind it (§6 byte-stability, read as digest-stability). + /// + /// The returned `String` is the only place these bytes exist outside serde's + /// buffer, and [`collect`] drops it in the same expression that hashes it. + fn emitted(&self) -> String { + let mut text = String::new(); + for part in [&self.stdout, &self.stderr, &self.additional_context] { + if let Some(body) = part.as_deref().filter(|body| !body.is_empty()) { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(body); + } + } + text + } + + /// Which producer this cost belongs to. + /// + /// `hookName` first, its `hookEvent` next, the tag last. Never a constant of + /// this engine's: every candidate is a string the HOST wrote, so no + /// consumer's hook roster reaches `crates/batten` (rule 1), and the fallback + /// chain exists because a host that omits the finer name still produced a + /// cost that has to land somewhere rather than being dropped. + fn producer(&self) -> String { + for candidate in [&self.hook_name, &self.hook_event, &self.kind] { + if let Some(name) = candidate.as_deref().filter(|name| !name.is_empty()) { + return name.to_owned(); + } + } + UNNAMED_PRODUCER.to_owned() + } +} + +/// The host's tag prefix for a hook record (CLOUD-417). +/// +/// A PREFIX rather than a set, for the reason [`collect`] states: an +/// unrecognized `hook_*` tag is a cost this session paid, and counting it as +/// zero is the silent under-report this row exists to end. +fn is_hook_tag(kind: &str) -> bool { + kind.starts_with("hook_") +} + +/// Where a cost lands when the host named no producer at all. +/// +/// A bucket rather than a drop: an unattributable emission still spent the +/// window, and dropping it would make the session total quietly wrong in the +/// direction that reads as clean. +const UNNAMED_PRODUCER: &str = "hook"; + /// The host's tag for a delivered memory document (CLOUD-1054). /// /// Named rather than spelled inline at the match, for [`DENY_EXIT`]'s reason: it diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 7dbf1050b..386e40392 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -726,6 +726,17 @@ pub enum WeakeningKind { /// (CLOUD-896). Same direction as the two below: smaller is stricter, and an /// absent ceiling is unenforced rather than zero. AdvisoryCeilingRaised, + /// The `[hook_output]` session ceiling rose, or stopped being declared + /// (CLOUD-417). Same direction again. + HookOutputCeilingRaised, + /// The `[hook_output]` repeat allowance rose, or stopped being declared + /// (CLOUD-417). + /// + /// A SECOND kind rather than a second subject on the one above, because the + /// two answer different questions — how much a session may cost, and how many + /// times one thing may be said — and a `Weakens:` clause that could not name + /// which of them moved would be articulating nothing. + HookRepeatsRaised, /// The `[refusal]` ceiling rose, or stopped being declared (CLOUD-1286). /// Same direction as a budget's: smaller is stricter, so §8's "may not /// weaken" reads as "may not raise", and an absent ceiling is unenforced @@ -831,6 +842,8 @@ impl WeakeningKind { WeakeningKind::DefectsClassAdded, WeakeningKind::TranscriptPathRemoved, WeakeningKind::AdvisoryCeilingRaised, + WeakeningKind::HookOutputCeilingRaised, + WeakeningKind::HookRepeatsRaised, WeakeningKind::RefusalCeilingRaised, WeakeningKind::BudgetSetRemoved, WeakeningKind::BudgetFileRemoved, @@ -886,6 +899,8 @@ impl WeakeningKind { WeakeningKind::DefectsClassAdded => "defects-class-added", WeakeningKind::TranscriptPathRemoved => "transcript-path-removed", WeakeningKind::AdvisoryCeilingRaised => "advisory-ceiling-raised", + WeakeningKind::HookOutputCeilingRaised => "hook-output-ceiling-raised", + WeakeningKind::HookRepeatsRaised => "hook-repeats-raised", WeakeningKind::RefusalCeilingRaised => "refusal-ceiling-raised", WeakeningKind::BudgetSetRemoved => "budget-set-removed", WeakeningKind::BudgetFileRemoved => "budget-file-removed", @@ -1110,6 +1125,13 @@ pub const CENSUS: &[FieldCoverage] = &[ field: "advisory", coverage: Coverage::Compared(&[WeakeningKind::AdvisoryCeilingRaised]), }, + FieldCoverage { + field: "hook_output", + coverage: Coverage::Compared(&[ + WeakeningKind::HookOutputCeilingRaised, + WeakeningKind::HookRepeatsRaised, + ]), + }, FieldCoverage { field: "must_land_on", coverage: Coverage::Compared(&[WeakeningKind::MustLandOnRemoved]), @@ -1743,6 +1765,24 @@ fn scalar_weakenings(base: &Config, working: &Config) -> Vec { working.advisory.as_ref().map(|channel| channel.max_tokens), )); + // And the session ceiling (CLOUD-417), which is TWO comparisons over one + // table because the table carries two independent thresholds. Both run: a + // change that tightened the token ceiling while raising the repeat allowance + // is still a weakening, and a single comparison would let one hide behind + // the other. + found.extend(ceiling_raised( + WeakeningKind::HookOutputCeilingRaised, + "hook_output.max_tokens", + base.hook_output.as_ref().map(|hooks| hooks.max_tokens), + working.hook_output.as_ref().map(|hooks| hooks.max_tokens), + )); + found.extend(ceiling_raised( + WeakeningKind::HookRepeatsRaised, + "hook_output.max_repeats", + base.hook_output.as_ref().map(|hooks| hooks.max_repeats), + working.hook_output.as_ref().map(|hooks| hooks.max_repeats), + )); + // `must_land_on` gone leaves `worktree status` with no target — exit 1, and // a gate that cannot judge. A *changed* ref is not compared: two trunk names // cannot be ranked without knowing which repository they belong to. @@ -3678,6 +3718,59 @@ mod tests { } } + #[test] + fn raising_either_hook_output_threshold_is_a_weakening() { + // TWO comparisons over one table, and this is the case that says why: + // tightening the token ceiling while raising the repeat allowance is + // still a weakening, and a single comparison would let it through. + let mut base = Config::declaring_nothing(); + base.hook_output = Some(crate::hookcost::Ceiling { + max_tokens: 4_000, + max_repeats: 1, + }); + + let mut mixed = Config::declaring_nothing(); + mixed.hook_output = Some(crate::hookcost::Ceiling { + max_tokens: 100, + max_repeats: 9, + }); + assert_eq!( + only(&base, &mixed), + Weakening::new( + WeakeningKind::HookRepeatsRaised, + "hook_output.max_repeats", + "1", + "9", + ), + "the tightened ceiling contributes nothing, and the raised allowance is the finding" + ); + + // And dropping the table is the other way to switch the gate off. BOTH + // thresholds go absent, so this is two weakenings rather than one — which + // is the pair working rather than a duplicate: a `Weakens:` clause has to + // say that each of them stopped being declared. + let dropped = weakenings(&base, &Config::declaring_nothing()); + assert_eq!( + dropped, + // KEY ORDER, which is the report's own: `weakenings` sorts so a + // `Weakens:` clause reads the same whichever comparison found what. + vec![ + Weakening::new( + WeakeningKind::HookRepeatsRaised, + "hook_output.max_repeats", + "1", + "absent", + ), + Weakening::new( + WeakeningKind::HookOutputCeilingRaised, + "hook_output.max_tokens", + "4000", + "absent", + ), + ] + ); + } + #[test] fn raising_or_dropping_the_advisory_ceiling_is_a_weakening() { // The channel budget's own ratchet, and the kind's exercising case. diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 23e7ab6da..76d51de2b 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -4816,6 +4816,57 @@ fn a_local_file_may_add_a_pattern_but_not_redefine_a_committed_one() { /// `config epoch` needs readable tracked paths, `receipt status` needs a repo with /// `origin/main`, and `check`/`enforce`/`config *` need an authority. One fixture /// satisfying all of them beats a per-verb table that would drift. +/// The committed config the purity census judges against. +/// +/// Lifted out of [`census_fixture`] rather than inlined: the fixture's job is +/// to supply each data-emitting verb its MINIMUM INPUT, and that list grows by +/// a table every time a verb with one is added — which pushed the function past +/// the line ceiling. The reasoning for each table stays here, beside the table. +const CENSUS_CONFIG: &str = concat!( + "version = 1\n", + "must_land_on = \"main\"\n", + "[budget.instructions]\n", + "files = [\"AGENTS.md\"]\n", + "max_tokens = 1000\n", + // `defects query` is the second verb with a minimum input, for the + // same reason: a ledger nobody declared is a usage error, never an + // empty answer. The file itself stays absent — that is the ledger's + // legitimate bootstrap state, and `-J` still emits `[]`. + "[defects]\n", + "path = \"defects.jsonl\"\n", + "classes = [\"example\"]\n", + // `commit check`'s minimum input: a repository declaring no + // convention is a usage error, never an empty answer. + "[commit]\n", + "subject_pattern = \"^(feat|fix|chore): .+\"\n", + // `attribution check`'s minimum input, for the same reason: a + // repository declaring no attribution policy is a usage error, never + // a clean pass over commits nobody judged. + "[attribution]\n", + "identity_deny = [\"^Nobody <\"]\n", + "trailer_deny = [\"^Nobody-Session:\"]\n", + "body_deny = [\"^Nobody generated\"]\n", + "[attribution.identity]\n", + "name = \"Census Human\"\n", + "email = \"census@example.test\"\n", + // `policy hooks`' minimum input, and the fifth verb to need one + // (CLOUD-417): a repository pointing at no session has nothing to + // measure, so it is a usage error rather than a `0` it did not earn. + // The file itself is written below — a declared path with nothing + // behind it is could-not-look, which is that same error. + "[transcript]\n", + "path = \".session.jsonl\"\n", +); + +/// The session the census points `policy hooks` at. +/// +/// One assistant turn and nothing else: the census is about the OUTPUT CONTRACT, +/// so the reading this produces is the empty one — zero producers, zero repeats +/// — and a document is still emitted, which is exactly the "including when the +/// answer is empty" half the purity assertion names. +const CENSUS_SESSION: &str = "{\"type\":\"assistant\",\"sessionId\":\"s-1\",\ + \"message\":{\"role\":\"assistant\",\"content\":[]}}\n"; + fn census_fixture(name: &str) -> (PathBuf, PathBuf, String) { // Shaped like `receipt_fixture`, but with a config every data-emitting verb // can actually answer from. `policy budget` is the reason it diverged: a @@ -4827,34 +4878,7 @@ fn census_fixture(name: &str) -> (PathBuf, PathBuf, String) { // same way `census_argv` supplies `receipt status` its positional. let root = scratch(name); let repo = Fixture::at(root.join("repo")) - .config(concat!( - "version = 1\n", - "must_land_on = \"main\"\n", - "[budget.instructions]\n", - "files = [\"AGENTS.md\"]\n", - "max_tokens = 1000\n", - // `defects query` is the second verb with a minimum input, for the - // same reason: a ledger nobody declared is a usage error, never an - // empty answer. The file itself stays absent — that is the ledger's - // legitimate bootstrap state, and `-J` still emits `[]`. - "[defects]\n", - "path = \"defects.jsonl\"\n", - "classes = [\"example\"]\n", - // `commit check`'s minimum input: a repository declaring no - // convention is a usage error, never an empty answer. - "[commit]\n", - "subject_pattern = \"^(feat|fix|chore): .+\"\n", - // `attribution check`'s minimum input, for the same reason: a - // repository declaring no attribution policy is a usage error, never - // a clean pass over commits nobody judged. - "[attribution]\n", - "identity_deny = [\"^Nobody <\"]\n", - "trailer_deny = [\"^Nobody-Session:\"]\n", - "body_deny = [\"^Nobody generated\"]\n", - "[attribution.identity]\n", - "name = \"Census Human\"\n", - "email = \"census@example.test\"\n", - )) + .config(CENSUS_CONFIG) // `ready lint` and `claim check`'s minimum input, and the fourth verb // family to need one (CLOUD-1100). The Ready grammar is the CONSUMER's // vocabulary, so a repository that declares no `[[pattern]]` rows has no @@ -4867,6 +4891,8 @@ fn census_fixture(name: &str) -> (PathBuf, PathBuf, String) { // `CENSUS_POSITIONALS` rather than by this call site, so the argv and the // file it points at cannot drift apart. .file("census-brief.md", &census_brief()) + // The session `policy hooks` measures (CLOUD-417). + .file(".session.jsonl", CENSUS_SESSION) // A published schema for `config deprecations` to use as its baseline. // Deliberately a SUBSET of the real surface — every key here still // exists — so the census exercises the CLEAN arm, which is what diff --git a/crates/batten/tests/it/hook_cost.rs b/crates/batten/tests/it/hook_cost.rs new file mode 100644 index 000000000..85326479b --- /dev/null +++ b/crates/batten/tests/it/hook_cost.rs @@ -0,0 +1,271 @@ +//! What this repository's hooks cost a session, over the compiled binary +//! (CLOUD-417). +//! +//! **The second tier, and it is not optional here for a specific reason.** The +//! module's own unit cases pin the predicate over a `Stream` a test constructed; +//! this tier proves the ENGINE builds that stream from a transcript a HOST wrote +//! — that `attachment.type` is read as a prefix, that `hookName` is the grouping +//! key, that the emitted text is hashed and dropped, and that a repeat survives +//! the whole path from JSONL bytes to a verdict. A case over a fabricated +//! `Stream` passes over a field the parse may be unable to fill, which is the +//! silent dead gate `.claude/rules/policy-modules.md` opens by warning about. +//! +//! **Fixture transcripts, in the host's real shape**, because a session's cost +//! is a property of the world rather than of the commit: a case reading this +//! container's own transcript would answer differently on every run, and in CI +//! there is no transcript at all. +//! +//! The discriminating half is the whole point. Three shapes the row names must +//! come out differently — a hook repeating itself is refused, a hook reporting +//! one change-set once is clean, and a hook silent on success is clean — and +//! without the two clean cases a rule that refused every emission would satisfy +//! the first and gate nothing. + +// 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::{run, scratch, stdout, write}; + +/// One `hook_success` attachment as the host writes it. +/// +/// `stdout` carries the text, which is what the engine hashes and drops. The +/// helper takes it as a parameter so a case can say "the same thing twice" +/// literally, rather than asserting over a digest it computed itself. +fn emission(hook: &str, text: &str) -> String { + let encoded = serde_json::to_string(text).expect("text is encodable"); + format!( + "{{\"type\":\"attachment\",\"sessionId\":\"s-1\",\"attachment\":\ + {{\"type\":\"hook_success\",\"hookEvent\":\"PostToolUse\",\"hookName\":\"{hook}\",\ + \"stdout\":{encoded}}}}}" + ) +} + +/// A repo whose `[transcript]` points at the given lines, with `ceiling` as its +/// `[hook_output]` table when one is given. +fn repo(name: &str, lines: &[String], ceiling: Option<&str>) -> PathBuf { + let dir = scratch(name); + let table = ceiling.unwrap_or(""); + write( + &dir, + "batten.toml", + &format!( + "version = 1\n\n[transcript]\npath = \".session.jsonl\"\n\n{table}\n\n\ + [[rule]]\nid = \"probe\"\nkind = \"forbid\"\nglob = \"never/**\"\n\ + pattern = \"never\"\nseverity = \"deny\"\nscope = \"tree\"\n" + ), + ); + write(&dir, ".session.jsonl", &format!("{}\n", lines.join("\n"))); + dir +} + +/// `batten policy hooks` in `dir`: the one line, and the exit code. +fn measure(dir: &Path) -> (String, Option) { + let output = run(dir, &["policy", "hooks"]); + (stdout(&output), output.status.code()) +} + +/// A ceiling that refuses only a repeat, so a case can vary one predicate. +const REPEAT_ONCE: &str = "[hook_output]\nmax_tokens = 100000\nmax_repeats = 1\n"; + +#[test] +fn a_hook_that_says_the_same_thing_n_times_is_a_violation() { + // THE ROW'S HEADLINE CASE, end to end: three byte-identical `hook_success` + // records reach the engine as three `HookOutput` events under one producer + // and one digest, and the verdict names the producer and the FIRST line. + let dir = repo( + "hook-cost-repeat", + &[ + emission("SessionStart:mcp", "every enabled MCP server attached"), + emission("SessionStart:mcp", "every enabled MCP server attached"), + emission("SessionStart:mcp", "every enabled MCP server attached"), + ], + Some(REPEAT_ONCE), + ); + let (text, code) = measure(&dir); + assert_eq!(code, Some(2), "a repeat is a violation: {text}"); + assert!( + text.contains("hook-repeat-pointer"), + "the class names the defect: {text}" + ); + assert!( + text.contains("SessionStart:mcp"), + "and names the producer, which is the pointer a reader follows: {text}" + ); + assert!( + !text.contains("every enabled MCP server attached"), + "POINTER-ONLY (rule 4): a measurement of an over-wide channel must not \ + carry what the channel said: {text}" + ); +} + +#[test] +fn a_hook_reporting_one_change_set_once_is_clean() { + // The discriminator. Two emissions from one producer that say DIFFERENT + // things are two findings, not one repeated — and a rule that refused every + // emission would satisfy the case above and this one would catch it. + let dir = repo( + "hook-cost-distinct", + &[ + emission("PostToolBatch:drift", "1 changed: hk.pkl"), + emission("PostToolBatch:drift", "1 changed: mise.toml"), + ], + Some(REPEAT_ONCE), + ); + let (text, code) = measure(&dir); + assert_eq!( + code, + Some(0), + "two different reports are not a repeat: {text}" + ); + assert!( + text.contains("2 producer(s)") || text.contains("1 producer(s)"), + "{text}" + ); + assert!(text.contains("0 repeat(s)"), "{text}"); +} + +#[test] +fn a_hook_silent_on_success_is_clean_and_is_not_an_empty_repeat() { + // SILENCE IS THE DEFAULT, and this is the case that makes it a reading + // rather than a posture. The host still writes a `hook_success` record for a + // hook that said nothing; the engine must count it as no emission at all, + // because three empty records under one producer would otherwise hash alike + // and read as a hook repeating itself — a violation manufactured out of + // exactly the behaviour this row is asking for. + let dir = repo( + "hook-cost-silent", + &[ + emission("PreToolUse:Bash", ""), + emission("PreToolUse:Bash", ""), + emission("PreToolUse:Bash", ""), + ], + Some(REPEAT_ONCE), + ); + let (text, code) = measure(&dir); + assert_eq!(code, Some(0), "silence is clean: {text}"); + assert!( + text.contains("hook output 0 token(s)"), + "and costs nothing: {text}" + ); + assert!(text.contains("0 producer(s)"), "{text}"); +} + +#[test] +fn the_reading_is_one_line_when_nothing_is_refused() { + // THE SELF-APPLYING PROPERTY over the compiled binary. The unit tier asserts + // `Reading::line` is one line; this asserts the VERB does not print anything + // else around it — a per-producer breakdown here would make a gate about + // hook volume grow with what it found. + let dir = repo( + "hook-cost-one-line", + &[ + emission("a", "alpha"), + emission("b", "beta"), + emission("c", "gamma"), + ], + Some(REPEAT_ONCE), + ); + let (text, code) = measure(&dir); + assert_eq!(code, Some(0), "{text}"); + assert_eq!(text.lines().count(), 1, "one line, always: {text}"); +} + +#[test] +fn an_undeclared_ceiling_measures_and_refuses_nothing() { + // ANTI-VACUITY (CLOUD-418), and the row's acceptance clause with it: the + // measurement is re-runnable against ANY transcript, so a repository that + // has declared no table still gets its number — which is how the 20% figure + // is checked rather than believed, and how a consumer reads its own cost + // before choosing a ceiling. + let dir = repo( + "hook-cost-unbudgeted", + &[ + emission("loud", "the same thing"), + emission("loud", "the same thing"), + ], + None, + ); + let (text, code) = measure(&dir); + assert_eq!(code, Some(0), "nothing declared refuses nothing: {text}"); + assert!( + text.contains("1 repeat(s)"), + "and the repeat is still MEASURED: {text}" + ); +} + +#[test] +fn the_session_budget_arm_fires_on_the_total() { + // The blunt predicate, and the half `max_repeats` cannot reach: a hook that + // never repeats itself can still spend the window, which is what a ceiling + // over the aggregate is for. + let dir = repo( + "hook-cost-over-budget", + &[ + emission("verbose", &"a".repeat(4_000)), + emission("verbose", &"b".repeat(4_000)), + ], + Some("[hook_output]\nmax_tokens = 100\nmax_repeats = 50\n"), + ); + let (text, code) = measure(&dir); + assert_eq!(code, Some(2), "{text}"); + assert!(text.contains("hook-output-budget"), "{text}"); + assert!( + !text.contains("hook-repeat-pointer"), + "and only that arm — the two thresholds are independent: {text}" + ); +} + +#[test] +fn two_producers_saying_one_thing_are_not_one_repeat() { + // The grouping key is (producer, digest), and this is the case that proves + // the engine reads `hookName` rather than folding every hook into one + // bucket. Without it, two hooks that happen to emit identical text would be + // reported as one of them repeating itself. + let dir = repo( + "hook-cost-two-producers", + &[ + emission("first", "identical"), + emission("second", "identical"), + ], + Some(REPEAT_ONCE), + ); + let (text, code) = measure(&dir); + assert_eq!(code, Some(0), "{text}"); + assert!(text.contains("2 producer(s)"), "{text}"); + assert!(text.contains("0 repeat(s)"), "{text}"); +} + +#[test] +fn a_repository_with_no_transcript_is_a_usage_error_and_never_a_clean_pass() { + // COULD-NOT-LOOK IS NOT A PASS. This verb's whole subject is that file, so a + // missing one exiting 0 would report "no hook cost" about a session nobody + // read — the vacuous green `[budget]`'s dead-glob refusal exists to stop one + // verb up. + let dir = scratch("hook-cost-no-transcript"); + write( + &dir, + "batten.toml", + "version = 1\n\n[[rule]]\nid = \"probe\"\nkind = \"forbid\"\n\ + glob = \"never/**\"\npattern = \"never\"\nseverity = \"deny\"\nscope = \"tree\"\n", + ); + let output = run(&dir, &["policy", "hooks"]); + let reason = common::stderr(&output); + assert_eq!( + output.status.code(), + Some(1), + "a usage error, not a verdict: {reason}" + ); + // AND FOR THE RIGHT REASON. Exit 1 is also what an unparseable config + // produces, so a case asserting the code alone passes over a fixture that + // never reached the verb — which is exactly what an earlier draft of this + // file did, with `kind = "forbidden_path"` naming a variant the engine does + // not have. + assert!( + reason.contains("[transcript]"), + "the refusal names the missing declaration: {reason}" + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index d4e174e5d..359238b96 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -109,6 +109,7 @@ mod guardrail_bypass; mod harness_grant; mod history_facts; mod hk_fix_selection; +mod hook_cost; mod hook_profile; mod hook_worktree_root; mod identity_churn; diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 1dbf2293d..fe9a3fe28 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -810,6 +810,18 @@ const CENSUS: &[Verb] = &[ stdin: Stdin::Nothing, disposition: Disposition::PointerOnly, }, + // The aggregate half of the verb above, and pointer-only for a sharper + // reason than its own: its subject is a session transcript, which is the + // richest secret surface this engine can be pointed at. Nothing it emits + // could carry a byte of one even by accident — `transcript.rs` hashes each + // hook emission and DROPS it at the parse, so the text does not reach the + // module that reports, let alone the report (CLOUD-417). + Verb { + path: "policy hooks", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, // Everything this verb reads is consumer-authored policy and the documents // a row declares, so a report carrying either would republish exactly what // rule 4 keeps out. Findings are ` ` and diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index e15090252..60816cde2 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -1137,6 +1137,21 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "policy hooks", + "about": "Judge this session's hook output against its declared per-session budget", + "effect": "read", + "flags": [ + { + "name": "json", + "short": "J", + "long": "json", + "takes_value": false, + "help": "Emit byte-stable JSON instead of pointer lines" + } + ], + "subcommands": [] + }, { "path": "policy test", "about": "Run each registered module's own `test_` rules and report the predicates none exercised", @@ -1619,6 +1634,7 @@ expression: stdout_of(&output) "policy", "policy budget", "policy explain", + "policy hooks", "policy test", "policy tools", "provision status", diff --git a/hk.pkl b/hk.pkl index 1d1e249bd..c5e5d17c3 100644 --- a/hk.pkl +++ b/hk.pkl @@ -430,6 +430,7 @@ local gate = new Mapping { "crates/batten/src/attribution.rs", "crates/batten/src/budget.rs", "crates/batten/src/capture.rs", + "crates/batten/src/hookcost.rs", "crates/batten/src/ci.rs", "crates/batten/src/commit.rs", "crates/batten/src/config.rs", diff --git a/man/batten-policy-hooks.1 b/man/batten-policy-hooks.1 new file mode 100644 index 000000000..393578d18 --- /dev/null +++ b/man/batten-policy-hooks.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-policy-hooks 1 batten +.SH NAME +batten\-policy\-hooks \- Judge this session\*(Aqs hook output against its declared per\-session budget +.SH SYNOPSIS +\fBbatten policy hooks\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Judge this session\*(Aqs hook output against its declared per\-session budget +.SH OPTIONS +.TP +\fB\-J\fR, \fB\-\-json\fR +Emit byte\-stable JSON instead of pointer lines +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-policy.1 b/man/batten-policy.1 index 0469fb9d8..124fcfb2e 100644 --- a/man/batten-policy.1 +++ b/man/batten-policy.1 @@ -16,6 +16,9 @@ Print help batten\-policy\-budget(1) Judge the always\-loaded instruction set against its declared token budget .TP +batten\-policy\-hooks(1) +Judge this session\*(Aqs hook output against its declared per\-session budget +.TP batten\-policy\-test(1) Run each registered module\*(Aqs own `test_` rules and report the predicates none exercised .TP diff --git a/mise.toml b/mise.toml index e89dc6608..f26ca3ab4 100644 --- a/mise.toml +++ b/mise.toml @@ -2003,6 +2003,30 @@ description = "Gate: the always-loaded context (AGENTS.md + anything else declar # working tree's engine and config together, the pair that ships. run = "cargo run --quiet -p batten -- policy budget" +[tasks.hook-cost] +description = "Gate: this session's hook output fits its per-session budget, and no hook repeats itself (CLOUD-417)" +# The aggregate half of the gate above, and the reason it is a separate task +# rather than a `depends` of it: `policy-budget` judges files this repository +# COMMITS and answers the same way in every checkout, while this judges a +# TRANSCRIPT a host wrote and therefore answers differently per session. Folding +# them into one name would make a red unattributable to either subject. +# +# NOT IN `verify` AND NOT IN THE hk GATE, deliberately. A session's transcript is +# not a property of the commit — it is a property of the world, which is the +# split `lock-complete` and `lock-currency` record one gate over: a property of +# the commit belongs in the gate, a property of the world belongs on a clock or +# in a hand run. Wiring it into `verify` would red a branch for a session it did +# not cause, and would pass vacuously in CI where no transcript exists at all. +# +# WHAT IT IS FOR is the row's own acceptance clause: "the measurement is +# re-runnable against any transcript, so the 20% figure can be checked rather +# than believed". So this ships as a command instead of a number in an issue +# body — point `[transcript] path` at any captured session and read the line. +# +# `cargo run` for `policy-budget`'s reason: the gate judges the working tree's +# engine and config as the pair that ships. +run = "cargo run --quiet -p batten -- policy hooks" + [tasks.completions] description = "Regenerate the committed shell completions from the command surface" # The binary emits on stdout only, so `generate` stays a read-effect verb (§5) diff --git a/policy/module-layering.rego b/policy/module-layering.rego index 2da37a2f4..4564942fd 100644 --- a/policy/module-layering.rego +++ b/policy/module-layering.rego @@ -227,6 +227,17 @@ declared_modules := { # costs. It reaches no decider and no store: WHICH producers exist is `lib`'s, # and `lib` is the caller that hands the whole set over. "advisory", + # `hookcost` arrived with CLOUD-417 and this rule named it an eleventh time. + # + # It is a MEASUREMENT module and it is placed by what it does NOT reach: it + # reads a parsed `transcript` and counts, so it sits above `transcript` and + # `budget` and below `lib`, and it reaches no decider, no store and no + # `hook`. That last one is the placement's content rather than an omission -- + # a module measuring what the mediated boundary COSTS must not be reachable + # from that boundary, or the measurement joins the thing it measures. It + # reaches `budget` for the estimator every other ceiling here counts with, + # and `findings` to mint the two engine-produced findings it raises. + "hookcost", } # THE FORBIDDEN EDGES, each traceable to prose already in the tree. diff --git a/schema/batten.schema.json b/schema/batten.schema.json index f4fa39cc5..716ffdb8a 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -168,6 +168,17 @@ } ] }, + "hook_output": { + "description": "What this repository's hooks may cost ONE SESSION (CLOUD-417). Absent\nmeans unenforced, on `[budget]`'s reading. The type and the predicate are\n[`crate::hookcost`].", + "anyOf": [ + { + "$ref": "#/$defs/Ceiling2" + }, + { + "type": "null" + } + ] + }, "judge": { "description": "The optional LLM judge's payload-privacy boundary (CLOUD-135): what may\ncross into a model call. Absent means no judge is configured; present and\nempty means pointers and hashes only, which is also what every field\ndefaults to. The type and the pure builder are [`crate::judge`].\n\nThis table lands **before** the judge that reads it, deliberately: a\nboundary written after the code it bounds is a boundary that code has\nalready crossed.", "anyOf": [ @@ -627,6 +638,29 @@ "max_tokens" ] }, + "Ceiling2": { + "description": "The `[hook_output]` table: what this repository's hooks may cost one session.\n\n**Absent means unenforced**, on `[budget]`'s reading — a threshold nobody\ndeclared is not a threshold of zero — so a consumer that has not adopted the\ntable measures exactly as it did before and refuses nothing.", + "type": "object", + "properties": { + "max_repeats": { + "description": "How many times one hook may emit byte-identical text in one session.\n\n**The floor is 1, not 0.** Saying a thing once is the report; saying it\nagain is the copy. A ceiling of 0 would refuse the first emission, which\nis a hook switched off rather than a hook made quiet — and this row puts\n\"removing any hook, or weakening what it detects\" explicitly out of scope.", + "type": "integer", + "format": "uint", + "minimum": 0 + }, + "max_tokens": { + "description": "The ceiling on estimated tokens of hook output across the whole session.\nThe boundary is `<=`, matching `[budget]`, `[refusal]` and `[advisory]`\nso the four thresholds in this tree do not disagree about their own edge.", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "max_tokens", + "max_repeats" + ] + }, "CeilingUnit": { "description": "What a [`Rule::max`] ceiling counts over its declared projection (CLOUD-925).\n\nTwo units rather than one, because `fanout-guard`'s two conjuncts measure the\nsame bytes and differ in the *subject* of the cap: the prompt's own size, and\nhow many tracked artifacts it names. A single unit would have forced one of\nthem into a second rule kind.\n\n**The unit cannot be inferred from the projection**, which is why this is a\ncolumn rather than a derivation: one prompt has both a token count and a\nmanifest count, and a row has to say which one it is about.", "oneOf": [ From 52be5234c9d5a63be296e8ecfb47a28d60ec6de7 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 17:10:11 +0000 Subject: [PATCH 14/20] fix(verdict): convert the classes main added after the grammar landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto `origin/main` brought 38 commits, six of which declare or raise a refusal class in the retired `V-SCREAMING-KEBAB` spelling. They were written against a tree where that was the only spelling there was, so they are not a regression — they are the window between this branch's converting commit and the merge, and the registry's own both-directions equality check is what makes them visible rather than latent: a token no row declares fails the load, and so does a row nothing raises. Three are `shell-retirement`'s ported-subject arms, which `main` landed with their `[[verdict]]` rows: `suite port unnamed`, `suite port dead`, `suite port held`. Their class prose is `main`'s and is kept verbatim — only the id moves, which is the whole of what CLOUD-1284 asks of a class. Their routes take the declared `config read first`, whose target is already `batten.toml`, rather than three one-off ids that named the same act three ways. Two are the `landing-loop` preset's, in the `VENDORED` table: `patch ship twice` and `lease grant other`, with their four routes converted alongside. The vendored half is deliberately not held to the consumer's vocabulary — a third-party consumer never declared these words — but it IS held to the grammar, which is what the conversion satisfies. Prose references to retired tokens are corrected in the same change rather than left to rot: a comment naming a class that no longer exists is a pointer into nothing, and the next reader has no way to tell it from a live one. Four conflicts were resolved and each is worth naming, because taking the wrong side of any of them would have been silent: `main` replaced `#MUTANT-EXEMPT` prose with `#MUTANT-SUITE` rows in fourteen modules, and this branch had only renamed a token INSIDE the prose `main` deleted. Every one takes `main`'s side — the line carrying the renamed token no longer exists, so there is nothing to carry forward. `batten.toml` and `shell_retirement.rs` both add `tests/**/*.bats` to `line_sources`, from CLOUD-1294 and CLOUD-1088 independently. The list is identical; only the reasoning differs, so both reasons are kept. That is not tidiness: a later reader deleting the glob because one of the two reasons no longer applies would break the other, and the comment is the only place that fact lives. Admits: 342fd2ece5a89e5b7c3160a6671f12a4e546bc543c2c38f6ae167cdf94cf038b Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 19479dd69cf581669b09b479330db4fe73e69ca2 Admits-epoch: 50bcb12cb9f6ea242960629ba3db2c7082f83a7d2ad773dde2fc01dfad27a6d2 Admits-author: alec@wenzowski.com Admits-prev: 5ab7c3762bcd6f6f9512f5ae54373a1d571d13201c79f989b59465855ef856e5 Admits-answer-lost: The whole branch. `check_verdicts_are_declared` and `check_registry_is_exhausted` refuse the load in both directions, so with `main`'s rows still spelled `V-PORT-SUBJECT-*` and the module raising the three-word form, nothing loads at all — not a gate switched off but a tree that cannot be built. The alternative is reverting CLOUD-1284, which is eleven landed tickets undone to avoid a rename. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS the `[[verdict]]` registry: six classes `main` landed after this branch's converting commit are declared and raised in the retired spelling, and a token no row declares fails the load — so the tree does not build until the ids move, and `batten.toml` is the one file that carries them. No non-protected path holds a class declaration. The write is one a reviewer sees in the diff it lands in — three ids and three route ids renamed, with `main`'s own class prose kept verbatim, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read `main`'s three rows in full and am keeping their class prose unchanged; reading further produces no route that renames an id. `patch run first` does not apply either: a patch that rewrites a `[[verdict]]` id is still a write to `batten.toml`, so it reaches this same class one indirection later. Refs: CLOUD-1284 --- batten.toml | 18 +-- completions/batten.fish | 141 +++++++++++++----- crates/batten/src/lint.rs | 2 +- crates/batten/src/mutate.rs | 4 +- .../already-landed-work-is-not-relanded.rego | 4 +- .../lease-authorises-the-branch.rego | 4 +- crates/batten/src/verdict.rs | 15 +- crates/batten/tests/it/config_lint.rs | 2 +- crates/batten/tests/it/shell_retirement.rs | 2 +- mise.toml | 4 +- policy/shell-retirement.rego | 24 +-- policy/suite-subject-retirable.rego | 2 +- 12 files changed, 146 insertions(+), 76 deletions(-) diff --git a/batten.toml b/batten.toml index 8655c0305..2e91675da 100644 --- a/batten.toml +++ b/batten.toml @@ -3455,7 +3455,7 @@ no_fix_reason = "restore the tests, or waive the reduction deliberately; which o # `tests/helpers.bash`, Rust source. `SubjectFacts::died` is `.all()`, so it can # never hold for them, so those suites were undeletable BY CONSTRUCTION: 217.9s of # a 1097.1s corpus, in a lane whose makespan cannot fall below its longest suite. -# Both other routes EDIT a governed `.bats`, which `V-SHELL-RULE-EDITED` refuses +# Both other routes EDIT a governed `.bats`, which `shell edit refused` refuses # with no override — so the spelling had to live here, in the ledger. # # IT OBLIGES MORE THAN `carried`, NEVER LESS, which is what makes it additive @@ -7939,7 +7939,7 @@ target = "batten.toml" # keeps it additive: it waives exactly one obligation (a policy surface, which a # port lands none of) and adds two that no other marker carries. [[verdict]] -id = "V-PORT-SUBJECT-UNNAMED" +id = "suite port unnamed" gloss = "a `ported` retirement arm names no surviving subject" class = """ The fifth arm claims that the cases moved and the thing under test STAYED, so the \ @@ -7951,15 +7951,15 @@ row, naming the path this suite's own `# subject:` header declared. """ [[verdict.route]] -id = "R-NAME-THE-SURVIVING-SUBJECT" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-PORT-SUBJECT-RETIRED" +id = "suite port dead" gloss = "a `ported` retirement arm names a subject this same change retires" class = """ -The mirror of `V-WITHDRAWAL-SUBJECT-ALIVE`, and what keeps the two arms from \ +The mirror of `shell retire never`, and what keeps the two arms from \ recording one event in two vocabularies. A port describes a subject that SURVIVED; \ where the subject went with the suite this is a plain retirement, and \ `// carried:` already spells it with one obligation fewer. Use that marker, or \ @@ -7967,25 +7967,25 @@ name the subject that actually survives. """ [[verdict.route]] -id = "R-SPELL-A-RETIREMENT-AS-CARRIED" +id = "config read first" kind = "document" target = "batten.toml" [[verdict]] -id = "V-PORT-SUBJECT-GOVERNED" +id = "suite port held" gloss = "a `ported` retirement arm names a governed path this change leaves standing" class = """ This is what keeps CLOUD-1130 whole in the presence of a fifth marker. A live \ `mise-tasks/` program or a live bats suite is something the campaign RETIRES, so \ porting a suite's cases away while its governed subject stands is exactly the \ -deletion `V-RETIREMENT-SUBJECT-ALIVE` refuses under the other four markers — the \ +deletion `shell retire never` refuses under the other four markers — the \ same claim, decided by which word the author typed. The arm is for a subject the \ campaign never retires. Retire the named path in this same change and spell the \ row `// carried:` instead. """ [[verdict.route]] -id = "R-RETIRE-THE-GOVERNED-SUBJECT" +id = "config read first" kind = "document" target = "batten.toml" diff --git a/completions/batten.fish b/completions/batten.fish index a495d97e7..1c40f5882 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -60,6 +60,7 @@ complete -c batten -n "__fish_batten_needs_command" -f -a "init" -d 'Write a sta complete -c batten -n "__fish_batten_needs_command" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' complete -c batten -n "__fish_batten_needs_command" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' complete -c batten -n "__fish_batten_needs_command" -f -a "perf" -d 'Measure this repository\'s own invocation cost' +complete -c batten -n "__fish_batten_needs_command" -f -a "mutate" -d 'Decide whether this repository\'s gates discriminate, rather than merely parse' complete -c batten -n "__fish_batten_needs_command" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' complete -c batten -n "__fish_batten_needs_command" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' complete -c batten -n "__fish_batten_needs_command" -f -a "ready" -d 'Whether an issue\'s Ready block satisfies the checkable clauses of the gate' @@ -867,6 +868,75 @@ complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subcommand_from pair" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subcommand_from help" -f -a "pair" -d 'Measure this branch and its merge base back to back on one machine, and print both arms as paired records' complete -c batten -n "__fish_batten_using_subcommand perf; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -f -a "sweep" -d 'Apply every declared mutation to its source and report the ones its declared suite did not catch' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -f -a "census" -d 'Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption' +complete -c batten -n "__fish_batten_using_subcommand mutate; and not __fish_seen_subcommand_from sweep census help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from sweep" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from census" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from help" -f -a "sweep" -d 'Apply every declared mutation to its source and report the ones its declared suite did not catch' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from help" -f -a "census" -d 'Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption' +complete -c batten -n "__fish_batten_using_subcommand mutate; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand policy; and not __fish_seen_subcommand_from budget hooks test tools explain help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -2101,40 +2171,41 @@ complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from reclaim" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "reclaim" -d 'Remove non-batten hook registrations from this host\'s merged surfaces' complete -c batten -n "__fish_batten_using_subcommand wiring; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "check" -d 'Run the applicable read-only gates against the repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "mcp" -d 'Dispatch a declared MCP call and hand back a reduction instead of the payload' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "target" -d 'Inspect and reclaim this repository\'s build tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "config" -d 'Inspect configuration' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "lint" -d 'Lint an artifact against a declared schema' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "spec" -d 'Print the tool\'s own command spec' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "perf" -d 'Measure this repository\'s own invocation cost' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "ready" -d 'Whether an issue\'s Ready block satisfies the checkable clauses of the gate' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "checks" -d 'Whether a commit\'s check runs answer the question a landing depends on' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "pr" -d 'The pull request a landing drives, and the answers it waits on' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "claim" -d 'Whether the issue you are about to pull is actually unclaimed' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "payload" -d 'Read a hook payload from stdin' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "record" -d 'Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "wiring" -d 'Repair a host\'s hook registrations' -complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "check" -d 'Run the applicable read-only gates against the repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "enforce" -d 'Run every configured rule, including kinds that execute a configured command' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "exec" -d 'Run a command — or a `:::` bundle — and report a pointer to what it wrote' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "capture" -d 'Captured command output: navigate what `exec` already ran, without running it again' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "mcp" -d 'Dispatch a declared MCP call and hand back a reduction instead of the payload' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "target" -d 'Inspect and reclaim this repository\'s build tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "config" -d 'Inspect configuration' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "lint" -d 'Lint an artifact against a declared schema' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "spec" -d 'Print the tool\'s own command spec' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "doctor" -d 'Diagnose whether Batten can run in this repository' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "init" -d 'Write a starter batten.toml, refusing to overwrite an existing one' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "baseline" -d 'Record the findings that already exist, so only new ones fail' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "generate" -d 'Emit artifacts derived from the command spec, on stdout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "perf" -d 'Measure this repository\'s own invocation cost' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "mutate" -d 'Decide whether this repository\'s gates discriminate, rather than merely parse' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "policy" -d 'Inspect the thresholds and path sets this repository holds itself to' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "commit" -d 'The shape a commit must take here: what its subject may say' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "ready" -d 'Whether an issue\'s Ready block satisfies the checkable clauses of the gate' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "checks" -d 'Whether a commit\'s check runs answer the question a landing depends on' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "pr" -d 'The pull request a landing drives, and the answers it waits on' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "claim" -d 'Whether the issue you are about to pull is actually unclaimed' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "semver" -d 'Whether this branch\'s API delta is compatible with the bump it claims' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "attribution" -d 'What produced commits may carry about the tooling that made them' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "worktree" -d 'Worktrees and the work in them: what is at risk' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "override" -d 'Issued admissions: an override is a record, never a variable somebody knows' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "provision" -d 'Pinned tools this repository provisions, cached out of tree' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "hook" -d 'Adjudicate a mediated tool call read from stdin (a deny is exit 2, the one contract)' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "payload" -d 'Read a hook payload from stdin' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "receipt" -d 'Verification receipts: SHA-keyed claims a named check passed, invalidated by git facts' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "defects" -d 'The append-only defect ledger: the lessons this repository has already paid for' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "design" -d 'Design-evidence claims: the integrity of the record behind a decision' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "state" -d 'The out-of-tree findings store: which store belongs to this checkout' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "record" -d 'Out-of-tree verdict stores: what something else judged, keyed so a stale answer cannot answer' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "wiring" -d 'Repair a host\'s hook registrations' +complete -c batten -n "__fish_batten_using_subcommand help; and not __fish_seen_subcommand_from check enforce exec capture mcp target config lint spec doctor init baseline generate perf mutate policy commit ready checks pr claim semver attribution worktree override provision hook payload receipt defects design state record wiring help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "show" -d 'Print a capture\'s pointer, or the lines a selection asks for, with no second run' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "find" -d 'Resolve a stored tool response by the key it carries, with no handle to look up first' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from capture" -f -a "list" -d 'List this repository\'s captures as handles, in a fixed order' @@ -2153,6 +2224,8 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "markdown" -d 'Emit the whole command surface as one markdown reference, on stdout' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "schema" -d 'Emit the JSON Schema for a config or policy-input surface, derived from the types that define it' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from perf" -f -a "pair" -d 'Measure this branch and its merge base back to back on one machine, and print both arms as paired records' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from mutate" -f -a "sweep" -d 'Apply every declared mutation to its source and report the ones its declared suite did not catch' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from mutate" -f -a "census" -d 'Report every gate in the tree that is neither mutation-enforced nor carrying a filed exemption' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "budget" -d 'Judge the always-loaded instruction set against its declared token budget' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "hooks" -d 'Judge this session\'s hook output against its declared per-session budget' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from policy" -f -a "test" -d 'Run each registered module\'s own `test_` rules and report the predicates none exercised' diff --git a/crates/batten/src/lint.rs b/crates/batten/src/lint.rs index 72f7aaad0..f0fcf41b6 100644 --- a/crates/batten/src/lint.rs +++ b/crates/batten/src/lint.rs @@ -672,7 +672,7 @@ pub fn declared(dir: &Path, base: &str) -> Result> { .and_then(|rest| rest.strip_prefix(": ")) .map(str::trim) // An EMPTY trailer declares nothing while reading as a - // declaration — `V-WEAKENS-DECLARES-NOTHING`'s class. Dropping it + // declaration — `config weakens unnamed`'s class. Dropping it // here means it can never admit anything. .filter(|pair| !pair.is_empty()) { diff --git a/crates/batten/src/mutate.rs b/crates/batten/src/mutate.rs index 522188d3f..14f862bbc 100644 --- a/crates/batten/src/mutate.rs +++ b/crates/batten/src/mutate.rs @@ -25,8 +25,8 @@ //! that exact hole, 0 with a bats suite, and 141 compiled-binary tiers the //! runner could not see. //! -//! That hole was unfixable in place. `V-SHELL-RULE-EDITED` declares one route, -//! `R-PORT-AND-RETIRE`, with no override and no `bypass_env`, so the coverage +//! That hole was unfixable in place. `shell edit refused` declares one route, +//! `rule read first`, with no override and no `bypass_env`, so the coverage //! mechanism could only be retired (CLOUD-1111 enumerated the three resolutions //! and rejected the two that meant editing the program). This module is that //! retirement. diff --git a/crates/batten/src/policy/presets/landing-loop/already-landed-work-is-not-relanded.rego b/crates/batten/src/policy/presets/landing-loop/already-landed-work-is-not-relanded.rego index ff9f5fb87..ea925cfc7 100644 --- a/crates/batten/src/policy/presets/landing-loop/already-landed-work-is-not-relanded.rego +++ b/crates/batten/src/policy/presets/landing-loop/already-landed-work-is-not-relanded.rego @@ -93,7 +93,7 @@ relanded contains target if { # several targets are declared. Never a commit, never the unlanded list. violation contains { "rule": "already-landed-work-is-not-relanded", - "verdict": "V-WORK-ALREADY-LANDED", + "verdict": "patch ship twice", "subjects": [{"artifact": target}], } if { some target in relanded @@ -111,7 +111,7 @@ answered(answer) := {"tree": {"landing": {"origin/main": answer}}} test_work_already_on_the_target_is_refused if { some v in violation with input as answered({"verdict": "landed", "landed": true, "unlanded": []}) - v.verdict == "V-WORK-ALREADY-LANDED" + v.verdict == "patch ship twice" } # THE SQUASH SHAPE, and the case that discriminates this module from one written diff --git a/crates/batten/src/policy/presets/landing-loop/lease-authorises-the-branch.rego b/crates/batten/src/policy/presets/landing-loop/lease-authorises-the-branch.rego index 075d8972e..3a53779b8 100644 --- a/crates/batten/src/policy/presets/landing-loop/lease-authorises-the-branch.rego +++ b/crates/batten/src/policy/presets/landing-loop/lease-authorises-the-branch.rego @@ -140,7 +140,7 @@ refused if { # cause, and a reader wanting the holder asks the producer. violation contains { "rule": "lease-authorises-the-branch", - "verdict": "V-LEASE-AUTHORISES-ANOTHER-BRANCH", + "verdict": "lease grant other", "subjects": [{"artifact": latest.branch}], } if { refused @@ -170,7 +170,7 @@ lease_line(line) := {"tree": {"records": {"any-record-name": [line]}}} test_a_live_lease_naming_another_branch_is_refused if { some v in violation with input as lease_line("lease held-elsewhere - mine") - v.verdict == "V-LEASE-AUTHORISES-ANOTHER-BRANCH" + v.verdict == "lease grant other" } # THE ANTI-VACUITY MIRROR. Without it every case here is satisfied by a module diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index d5b1f17c1..83deda090 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -1544,7 +1544,7 @@ judge different work, the commit is what has to change.", ], }, VendoredVerdict { - id: "V-WORK-ALREADY-LANDED", + id: "patch ship twice", gloss: "the target already carries this branch's changes, so landing them again buys nothing", class: "A landing attempt over work the target already has runs a matrix, holds the \ fleet's landing slot while it does, and merges a no-op or a conflict. The answer is decided by \ @@ -1553,10 +1553,7 @@ squash-merged or cherry-picked branch leaves the same change on the target under commit with no ancestry path back, and on a fast-forward trunk that is the ordinary way work \ lands. Close the branch, or rebase onto the target and see what is genuinely left.", routes: &[ - read( - "R-READ-THE-LANDING-ANSWER", - "the landing verdict for this target", - ), + read("record read first", "the landing verdict for this target"), // The one legitimate re-land, and it is narrow on purpose. Patch // identity answers about CONTENT, so deliberately re-applying a // change the target once carried and later reverted is @@ -1564,13 +1561,13 @@ lands. Close the branch, or rebase onto the target and see what is genuinely lef // arriving for a different reason. That is the case this admits, and // it is not "the answer was inconvenient". admit( - "R-OVERRIDE-THE-RELAND", + "patch admit first", "the change is being deliberately re-applied after the target reverted it, so identical content is the intent rather than a duplicate", ), ], }, VendoredVerdict { - id: "V-LEASE-AUTHORISES-ANOTHER-BRANCH", + id: "lease grant other", gloss: "a live landing lease names a different branch, and no reservation names this one", class: "A landing lease is how a fleet keeps two branches from buying overlapping CI for \ a trunk only one of them can fast-forward onto. This branch is neither the holder nor the \ @@ -1581,7 +1578,7 @@ cannot take ALLOWS: an unreadable lease stops every job in the fleet, where wavi through costs one matrix.", routes: &[ read( - "R-READ-THE-LEASE", + "lease read first", "the lease grading recorded for this branch", ), // The wedged holder, and it is narrow on purpose. The lease grades @@ -1591,7 +1588,7 @@ through costs one matrix.", // inconvenient" — a holder that is merely slow is the mechanism // working. admit( - "R-OVERRIDE-THE-LEASE", + "lease admit first", "the holder is wedged rather than slow — it is beating without advancing, so waiting for a lapse it keeps renewing starves the fleet indefinitely", ), ], diff --git a/crates/batten/tests/it/config_lint.rs b/crates/batten/tests/it/config_lint.rs index 4159e66f4..f3f6651bd 100644 --- a/crates/batten/tests/it/config_lint.rs +++ b/crates/batten/tests/it/config_lint.rs @@ -63,7 +63,7 @@ //! case that vanishes without a reason is indistinguishable from one forgotten. //! // withdrawn: "the task carries no bypass branch at all" the case greps the program's own bytes for a BYPASS branch, and the program is deleted; the property it protected is now structural, since `config lint` reads no environment variable on this path and `lint::admissions` takes its two sources as arguments -// withdrawn: "the refusal points at grooming, not at a flag to set" the shell composed that refusal text and no longer exists; the verb emits a pointer plus a verdict token, and the remedy prose it used to print is `V-CONFIG-WEAKENING-UNGROOMED`'s registry row rather than a string in a gate +// withdrawn: "the refusal points at grooming, not at a flag to set" the shell composed that refusal text and no longer exists; the verb emits a pointer plus a verdict token, and the remedy prose it used to print is `config weakens unnamed`'s registry row rather than a string in a gate // withdrawn: "the rationale claims no caller that grep cannot find" the case gated the deleted program's own header against the workflow tree, and a header that no longer exists cannot make a claim to reconcile //! //! ## SUBSUMED — the four wiring cases, which `ci-local-parity` already owns diff --git a/crates/batten/tests/it/shell_retirement.rs b/crates/batten/tests/it/shell_retirement.rs index 969bbad86..2de3d488b 100644 --- a/crates/batten/tests/it/shell_retirement.rs +++ b/crates/batten/tests/it/shell_retirement.rs @@ -320,7 +320,7 @@ fn a_port_naming_no_subject_is_refused() { /// THE ARM THAT KEEPS CLOUD-1130 WHOLE. A live GOVERNED subject must be retired /// rather than ported around: without this, naming `mise-tasks/old-gate.sh` as the -/// survivor buys exactly the deletion `V-RETIREMENT-SUBJECT-ALIVE` refuses under +/// survivor buys exactly the deletion `shell retire never` refuses under /// the other four markers — the same claim, decided by which word was typed. #[test] fn a_port_naming_a_live_governed_subject_is_refused() { diff --git a/mise.toml b/mise.toml index f26ca3ab4..6f76df6f8 100644 --- a/mise.toml +++ b/mise.toml @@ -1109,7 +1109,7 @@ description = "Gate: batten.toml carries no policy smell, and no weakening the g # nothing could reach past it (CLOUD-841) — a receipt naming no weakening read as # no receipt, so a trailer minted inside the change that performs a weakening # admitted it — and repairing that meant EDITING authored shell, which -# `V-SHELL-RULE-EDITED` refuses with no override. So the fix and the retirement +# `shell edit refused` refuses with no override. So the fix and the retirement # are one change, which is the campaign working as designed rather than a # coincidence. # @@ -1225,7 +1225,7 @@ description = "Gate: every declared gate has a mutation its own suite is PROVEN # had no suite that could turn red: 32 modules, 32 `#MUTANT-EXEMPT` rows, 29 of # them citing that exact hole, and 141 compiled-binary tiers the runner could not # see. Repairing it meant EDITING an authored shell rule, which -# `V-SHELL-RULE-EDITED` refuses with one route and no override — so the coverage +# `shell edit refused` refuses with one route and no override — so the coverage # mechanism could only be retired. # # STILL OFF THE LANDING PATH. This is a proof about the SUITES, not a property of diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 4ef2ee1c0..c8e1656ce 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -968,7 +968,7 @@ named_and_alive(path) := subjects if { # it being that. violation contains { "rule": "shell-rule-retired", - "verdict": "V-PORT-SUBJECT-UNNAMED", + "verdict": "suite port unnamed", "subjects": [{"path": path}], } if { some path in delta.deleted @@ -979,14 +979,14 @@ violation contains { } # A SUBJECT THAT DIED IS NOT A SURVIVING ONE, and this is the mirror of -# `V-WITHDRAWAL-SUBJECT-ALIVE` rather than a second reading of it. `withdrawn` +# `shell retire never` rather than a second reading of it. `withdrawn` # is refused where its subject STANDS; `ported` is refused where its subject WENT # — which is a plain retirement, and `carried` already spells that with less. # Admitting it under both markers would let the ledger record one event in two # vocabularies, which is the drift every seam in this module is written against. violation contains { "rule": "shell-rule-retired", - "verdict": "V-PORT-SUBJECT-RETIRED", + "verdict": "suite port dead", "subjects": [{"path": path}, {"path": subject}], } if { some path in delta.deleted @@ -1016,7 +1016,7 @@ violation contains { # arrives. violation contains { "rule": "shell-rule-retired", - "verdict": "V-PORT-SUBJECT-GOVERNED", + "verdict": "suite port held", "subjects": [{"path": path}, {"path": subject}], } if { some path in delta.deleted @@ -1054,7 +1054,7 @@ arm_markers := ["// carried:", "// subsumed:", "// changed:", "// withdrawn:", " # THE FIFTH ARM IS THE ONE WHOSE SUBJECT SURVIVES (CLOUD-1268), and it is in the # list above for the reason the other four are: `arms_for` reads # `[rule.conserves]`'s declaration one level up, and a marker missing here is a row -# this module cannot SEE — so a conforming port would raise `V-RETIREMENT-UNMAPPED` +# this module cannot SEE — so a conforming port would raise `shell retire missing` # over a ledger that maps it completely. # # WHAT IT IS FOR, measured rather than supposed. 16 of this repository's suites @@ -1847,7 +1847,7 @@ test_the_surface_obligation_still_binds_a_carried_row if { "base-delta": {"added": [], "edited": [], "deleted": ["tests/old-gate.bats"]}, "lines": {"crates/batten/tests/old_gate.rs": ["// carried: tests/old-gate.bats crates/batten/tests/old_gate.rs"]}, }} - v.verdict == "V-SUCCESSOR-NO-SURFACE" + v.verdict == "shell port missing" } # (c) AND THE TEST OBLIGATION IS NOT WAIVED FOR A PORT. The coverage is the whole @@ -1858,7 +1858,7 @@ test_a_port_naming_no_binary_test_is_refused if { "base-delta": {"added": [], "edited": [], "deleted": ["tests/old-gate.bats"]}, "lines": {"crates/batten/tests/old_gate.rs": ["// ported: tests/old-gate.bats policy/old-gate.rego subject:tests/helpers.bash"]}, }} - v.verdict == "V-SUCCESSOR-NO-TEST" + v.verdict == "test port missing" } # (d) A PORT THAT NAMES NO SURVIVOR IS A WEAKER `carried`, and refusing it is what @@ -1868,10 +1868,10 @@ test_a_port_naming_no_subject_is_refused if { "base-delta": {"added": [], "edited": [], "deleted": ["tests/old-gate.bats"]}, "lines": {"crates/batten/tests/old_gate.rs": ["// ported: tests/old-gate.bats crates/batten/tests/old_gate.rs"]}, }} - v.verdict == "V-PORT-SUBJECT-UNNAMED" + v.verdict == "suite port unnamed" } -# (e) THE MIRROR OF `V-WITHDRAWAL-SUBJECT-ALIVE`. A subject that went with the +# (e) THE MIRROR OF `shell retire never`. A subject that went with the # suite makes this a retirement, and `carried` is its spelling. test_a_port_whose_subject_died_is_refused if { some v in violation with input as {"tree": { @@ -1881,7 +1881,7 @@ test_a_port_whose_subject_died_is_refused if { "// carried: mise-tasks/old-gate.sh policy/old-gate.rego crates/batten/tests/old_gate.rs", ]}, }} - v.verdict == "V-PORT-SUBJECT-RETIRED" + v.verdict == "suite port dead" } # (f) THE ARM THAT KEEPS CLOUD-1130 WHOLE. A live GOVERNED subject must be retired @@ -1893,7 +1893,7 @@ test_a_port_naming_a_live_governed_subject_is_refused if { "base-delta": {"added": [], "edited": [], "deleted": ["tests/old-gate.bats"]}, "lines": {"crates/batten/tests/old_gate.rs": ["// ported: tests/old-gate.bats crates/batten/tests/old_gate.rs subject:mise-tasks/old-gate.sh"]}, }} - v.verdict == "V-PORT-SUBJECT-GOVERNED" + v.verdict == "suite port held" } # (g) THE SUBJECT FIELD IS NOT A PATH SUCCESSOR, which is the additive half and the @@ -1905,7 +1905,7 @@ test_a_subject_field_is_not_a_binary_test if { "base-delta": {"added": [], "edited": [], "deleted": ["tests/old-gate.bats"]}, "lines": {"crates/batten/tests/old_gate.rs": ["// ported: tests/old-gate.bats subject:crates/batten/tests/old_gate.rs"]}, }} - v.verdict == "V-SUCCESSOR-NO-TEST" + v.verdict == "test port missing" } # The anti-vacuity arm on the OTHER axis: an untouched tree, and a generated or diff --git a/policy/suite-subject-retirable.rego b/policy/suite-subject-retirable.rego index d7133e950..a668c2a75 100644 --- a/policy/suite-subject-retirable.rego +++ b/policy/suite-subject-retirable.rego @@ -151,7 +151,7 @@ declared[path] := parts if { # * STRUCTURE (3 rows, 9.2s). `replay` subjects `mise-tasks/replay-pointers.py`, # which `governed_when_deleted` excludes by extension; `release-tracking-check` # and `remedy-payload-source` each name a subject that is GOVERNED and alive, -# which `V-PORT-SUBJECT-GOVERNED` refuses on purpose — a live governed path is +# which `suite port held` refuses on purpose — a live governed path is # something the campaign retires, and porting a suite away from one is the # claim `named_and_alive` already refuses under the other four markers. No # amount of willingness moves these; each owes its own row. From 30a07ed05b3bb158daa0702e97052efb120ac93a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 17:27:51 +0000 Subject: [PATCH 15/20] refactor(tests): retire the fact-record-keying suite onto the compiled binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1286 took the `Fix:` clause off the emitted mediated line, and one case in `tests/fact-record-keying.bats` asserted that clause verbatim — the declared `gh pr view --json reviewThreads` remedy. The assertion is now false, and `shell edit refused` refuses editing a governed `.bats` with one route and no override, so there is no landable spelling that fixes the assertion in place. That leaves the two shapes `.claude/rules/toolchain.md` names, and this is the first: retire it whole. Six cases move to `crates/batten/tests/it/` driving the same two real hook envelopes over the compiled binary — a `PostToolUse` carrying the declared command, which mints the record, and a `PreToolUse` `gh pr ready`, which reads it. Nothing writes a receipt by hand and nothing inspects a path to decide a case, which is the property the bats header argued for and the reason this is a port rather than a rewrite as unit tests over `sourced_path`. The ledger arm is `ported:` rather than `carried:`, and the distinction is the whole of the classification: the SUBJECT survives. `crates/batten/src/facts.rs` is engine source the campaign never retires, so this is the cases moving off bash while the thing under test stays exactly where it was — which is what `suite port held` refuses to let anyone spell as a retirement, and what `suite port dead` refuses to let anyone spell as a port. Exactly one assertion changed, and it is the one that made the retirement necessary: the head-keyed case now pins the declared CLASS and its pointers (`receipt read missing`, `ready-needs-the-fact`) rather than the inline remedy. That is what the hot path emits, and the remedy is one hop away through `batten policy explain`. Every other case is byte-for-byte the same predicate over the same two envelopes, including the anti-vacuity twin the suite exists for — head-keying everything would satisfy the first case and break `claim` repo-wide, so "a branch-keyed record survives a new commit" stays green. Two helpers changed shape rather than meaning. `age_records` backdates through `File::set_modified` instead of shelling to python3, and `records` globs through `read_dir` instead of a shell loop — the bats versions were written around BSD `touch -d` and GNU-only `find -printf`, portability hazards that do not exist once the code is Rust. The ledger is TWO granularities and both are owed. The file arm buys the deletion; `bats-tests-not-deleted`'s `[rule.conserves]` demands one arm per deleted CASE on top of it, which is CLOUD-908's point — the file column asks whether the subject died and never whether the cases moved, so a migration could delete a 259-line suite and land green with nothing asserting what replaced it. All six case arms are `ported:` rather than `carried:`, and that is the same distinction one level down rather than a second one. `carried` names a successor and accounts for no survivor; `ported` names both, and the survivor it names is what clears the aggregate subject-alive term. Naming it is not optional — an arm without the field would be `carried` with a longer word on it, buying the deletion while accounting for nothing, which is the exploit CLOUD-1130 closed and CLOUD-1268's fifth marker could have re-opened. The subject is read from the DYING FILE's own `# subject:` header at base, so the arm cannot name a convenient survivor it invented. Refs: CLOUD-417 --- bench/suites/RESULTS.md | 231 +++++++-------- crates/batten/tests/it/fact_record_keying.rs | 291 +++++++++++++++++++ crates/batten/tests/it/main.rs | 1 + tests/fact-record-keying.bats | 265 ----------------- 4 files changed, 408 insertions(+), 380 deletions(-) create mode 100644 crates/batten/tests/it/fact_record_keying.rs delete mode 100644 tests/fact-record-keying.bats diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 587d042bf..8ef8bcad6 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,132 +6,133 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 124 -- serial total: 739.1s +- suites: 125 +- serial total: 592.1s | seconds | share | suite | | ---: | ---: | --- | -| 147.5 | 20.0% | `tests/land-lock.bats` | -| 108.4 | 14.7% | `tests/land.bats` | -| 63.2 | 8.6% | `tests/hooks-wiring-check.bats` | -| 34.5 | 4.7% | `tests/main-watch.bats` | -| 31.2 | 4.2% | `tests/sbom-check.bats` | -| 27.9 | 3.8% | `tests/graph-check.bats` | -| 24.4 | 3.3% | `tests/hook-latency-drift.bats` | -| 19.8 | 2.7% | `tests/run-shape-guard.bats` | -| 17.8 | 2.4% | `tests/board-diff-overlap.bats` | -| 13.4 | 1.8% | `tests/token-bench.bats` | -| 12.1 | 1.6% | `tests/released.bats` | -| 11.8 | 1.6% | `tests/ready-lint.bats` | -| 9.6 | 1.3% | `tests/release-tracking-check.bats` | -| 9.5 | 1.3% | `tests/target-race.bats` | -| 9.5 | 1.3% | `tests/ready-guard.bats` | -| 9.0 | 1.2% | `tests/replay.bats` | -| 8.5 | 1.2% | `tests/board-sweep.bats` | -| 8.1 | 1.1% | `tests/sbom.bats` | -| 7.7 | 1.0% | `tests/release-assets-check.bats` | -| 7.1 | 1.0% | `tests/step-receipt.bats` | -| 7.1 | 1.0% | `tests/in-progress-drain.bats` | -| 6.7 | 0.9% | `tests/mcp-allow-check.bats` | -| 5.3 | 0.7% | `tests/ready-cites-check.bats` | -| 5.3 | 0.7% | `tests/hk-selection.bats` | -| 5.0 | 0.7% | `tests/land-divergence.bats` | -| 4.7 | 0.6% | `tests/singleton.bats` | -| 4.6 | 0.6% | `tests/ntia-check.bats` | -| 4.5 | 0.6% | `tests/task-registry.bats` | -| 3.9 | 0.5% | `tests/doctor-race.bats` | -| 3.4 | 0.5% | `tests/closing-key-check.bats` | -| 3.4 | 0.5% | `tests/landed-check.bats` | -| 3.3 | 0.5% | `tests/with-lock.bats` | -| 3.2 | 0.4% | `tests/suite-select.bats` | -| 3.0 | 0.4% | `tests/target-ensure.bats` | -| 3.0 | 0.4% | `tests/tree-clean.bats` | -| 2.9 | 0.4% | `tests/claim-race-check.bats` | -| 2.9 | 0.4% | `tests/spec-ref-check.bats` | -| 2.9 | 0.4% | `tests/finding-sink-check.bats` | -| 2.6 | 0.3% | `tests/signing-posture.bats` | -| 2.6 | 0.3% | `tests/install.bats` | -| 2.6 | 0.3% | `tests/claimed-keys.bats` | -| 2.3 | 0.3% | `tests/deferral-check.bats` | -| 2.3 | 0.3% | `tests/verify.bats` | -| 2.1 | 0.3% | `tests/alive.bats` | -| 2.1 | 0.3% | `tests/bot-issue.bats` | -| 1.9 | 0.3% | `tests/ci-tools-check.bats` | -| 1.9 | 0.3% | `tests/ci-slow-needed.bats` | -| 1.9 | 0.3% | `tests/reclaim-census.bats` | -| 1.9 | 0.3% | `tests/ready-lint-deferral.bats` | -| 1.7 | 0.2% | `tests/spawn-census.bats` | -| 1.7 | 0.2% | `tests/perf-record.bats` | -| 1.6 | 0.2% | `tests/ci-lease-precondition.bats` | -| 1.6 | 0.2% | `tests/install-check.bats` | -| 1.5 | 0.2% | `tests/awk-regex-check.bats` | -| 1.5 | 0.2% | `tests/nonverdict-scan.bats` | -| 1.5 | 0.2% | `tests/done-check.bats` | -| 1.4 | 0.2% | `tests/land-divergence-assert.bats` | -| 1.4 | 0.2% | `tests/release-backfill.bats` | -| 1.4 | 0.2% | `tests/linear-check.bats` | -| 1.4 | 0.2% | `tests/render-cli.bats` | -| 1.4 | 0.2% | `tests/fact-record-keying.bats` | -| 1.3 | 0.2% | `tests/attestation-check.bats` | -| 1.3 | 0.2% | `tests/sbom-binary.bats` | -| 1.3 | 0.2% | `tests/verified.bats` | -| 1.2 | 0.2% | `tests/lint-rego.bats` | -| 1.2 | 0.2% | `tests/lint-deno.bats` | -| 1.2 | 0.2% | `tests/perf-assert.bats` | -| 1.2 | 0.2% | `tests/module-map-check.bats` | -| 1.1 | 0.2% | `tests/pr-unsubscribed.bats` | -| 1.1 | 0.2% | `tests/done-pr-check.bats` | -| 1.1 | 0.2% | `tests/timeout-drift.bats` | -| 1.1 | 0.1% | `tests/doctor.bats` | -| 1.0 | 0.1% | `tests/evaluator-closure-check.bats` | -| 1.0 | 0.1% | `tests/duplicate-close-check.bats` | -| 1.0 | 0.1% | `tests/hook-matcher-check.bats` | -| 1.0 | 0.1% | `tests/stop-posture-check.bats` | -| 0.9 | 0.1% | `tests/macos-link-check.bats` | -| 0.9 | 0.1% | `tests/suite-bench-check.bats` | -| 0.8 | 0.1% | `tests/perf-compare.bats` | -| 0.8 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.8 | 0.1% | `tests/mcp-timeout-budget.bats` | -| 0.8 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.7 | 0.1% | `tests/commit-attribution.bats` | -| 0.7 | 0.1% | `tests/checksums.bats` | -| 0.7 | 0.1% | `tests/transcript-corpus-check.bats` | -| 0.7 | 0.1% | `tests/publish-credential-check.bats` | -| 0.7 | 0.1% | `tests/hook-pin-check.bats` | -| 0.7 | 0.1% | `tests/pipefail-grep-check.bats` | -| 0.6 | 0.1% | `tests/digest-major-agreement.bats` | +| 138.6 | 23.4% | `tests/land-lock.bats` | +| 81.9 | 13.8% | `tests/land.bats` | +| 45.4 | 7.7% | `tests/hooks-wiring-check.bats` | +| 34.6 | 5.8% | `tests/main-watch.bats` | +| 24.3 | 4.1% | `tests/hook-latency-drift.bats` | +| 20.7 | 3.5% | `tests/graph-check.bats` | +| 19.8 | 3.3% | `tests/sbom-check.bats` | +| 14.2 | 2.4% | `tests/board-diff-overlap.bats` | +| 12.4 | 2.1% | `tests/run-shape-guard.bats` | +| 8.9 | 1.5% | `tests/target-race.bats` | +| 8.7 | 1.5% | `tests/token-bench.bats` | +| 8.3 | 1.4% | `tests/ready-lint.bats` | +| 7.2 | 1.2% | `tests/ready-guard.bats` | +| 7.0 | 1.2% | `tests/board-sweep.bats` | +| 6.9 | 1.2% | `tests/released.bats` | +| 5.8 | 1.0% | `tests/lock-complete.bats` | +| 5.7 | 1.0% | `tests/replay.bats` | +| 5.5 | 0.9% | `tests/release-tracking-check.bats` | +| 5.4 | 0.9% | `tests/release-assets-check.bats` | +| 5.3 | 0.9% | `tests/sbom.bats` | +| 5.1 | 0.9% | `tests/mcp-allow-check.bats` | +| 4.9 | 0.8% | `tests/in-progress-drain.bats` | +| 4.8 | 0.8% | `tests/step-receipt.bats` | +| 4.6 | 0.8% | `tests/singleton.bats` | +| 4.0 | 0.7% | `tests/task-registry.bats` | +| 3.9 | 0.7% | `tests/ready-cites-check.bats` | +| 3.8 | 0.6% | `tests/land-divergence.bats` | +| 3.6 | 0.6% | `tests/doctor-race.bats` | +| 3.5 | 0.6% | `tests/with-lock.bats` | +| 3.5 | 0.6% | `tests/hk-selection.bats` | +| 3.4 | 0.6% | `tests/ntia-check.bats` | +| 3.2 | 0.5% | `tests/target-ensure.bats` | +| 2.6 | 0.4% | `tests/landed-check.bats` | +| 2.4 | 0.4% | `tests/closing-key-check.bats` | +| 2.3 | 0.4% | `tests/suite-select.bats` | +| 2.2 | 0.4% | `tests/claim-race-check.bats` | +| 2.1 | 0.4% | `tests/install.bats` | +| 2.0 | 0.3% | `tests/unlanded-check.bats` | +| 1.9 | 0.3% | `tests/bot-issue.bats` | +| 1.9 | 0.3% | `tests/finding-sink-check.bats` | +| 1.8 | 0.3% | `tests/spec-ref-check.bats` | +| 1.8 | 0.3% | `tests/ci-slow-needed.bats` | +| 1.7 | 0.3% | `tests/claimed-keys.bats` | +| 1.7 | 0.3% | `tests/reclaim-census.bats` | +| 1.6 | 0.3% | `tests/tree-clean.bats` | +| 1.6 | 0.3% | `tests/ready-lint-deferral.bats` | +| 1.6 | 0.3% | `tests/signing-posture.bats` | +| 1.6 | 0.3% | `tests/ci-tools-check.bats` | +| 1.5 | 0.2% | `tests/alive.bats` | +| 1.4 | 0.2% | `tests/verify.bats` | +| 1.3 | 0.2% | `tests/perf-record.bats` | +| 1.2 | 0.2% | `tests/spawn-census.bats` | +| 1.2 | 0.2% | `tests/ci-lease-precondition.bats` | +| 1.2 | 0.2% | `tests/deferral-check.bats` | +| 1.2 | 0.2% | `tests/land-divergence-assert.bats` | +| 1.2 | 0.2% | `tests/install-check.bats` | +| 1.2 | 0.2% | `tests/linear-check.bats` | +| 1.1 | 0.2% | `tests/done-check.bats` | +| 1.1 | 0.2% | `tests/awk-regex-check.bats` | +| 1.0 | 0.2% | `tests/nonverdict-scan.bats` | +| 0.9 | 0.2% | `tests/module-map-check.bats` | +| 0.9 | 0.2% | `tests/release-backfill.bats` | +| 0.9 | 0.2% | `tests/done-pr-check.bats` | +| 0.9 | 0.1% | `tests/perf-assert.bats` | +| 0.9 | 0.1% | `tests/render-cli.bats` | +| 0.9 | 0.1% | `tests/hook-matcher-check.bats` | +| 0.9 | 0.1% | `tests/lint-rego.bats` | +| 0.8 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.8 | 0.1% | `tests/attestation-check.bats` | +| 0.8 | 0.1% | `tests/commit-attribution.bats` | +| 0.8 | 0.1% | `tests/doctor.bats` | +| 0.8 | 0.1% | `tests/lint-deno.bats` | +| 0.8 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.8 | 0.1% | `tests/timeout-drift.bats` | +| 0.7 | 0.1% | `tests/sbom-binary.bats` | +| 0.7 | 0.1% | `tests/evaluator-closure-check.bats` | +| 0.7 | 0.1% | `tests/macos-link-check.bats` | +| 0.7 | 0.1% | `tests/mcp-attach-check.bats` | +| 0.6 | 0.1% | `tests/publish-credential-check.bats` | +| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.6 | 0.1% | `tests/suite-bench-check.bats` | +| 0.6 | 0.1% | `tests/perf-compare.bats` | | 0.6 | 0.1% | `tests/connector-allow-guard.bats` | -| 0.6 | 0.1% | `tests/abandon-matrix.bats` | -| 0.6 | 0.1% | `tests/run-shape-guard-quoting.bats` | -| 0.6 | 0.1% | `tests/msrv-pin-agreement.bats` | -| 0.6 | 0.1% | `tests/serena-mcp.bats` | -| 0.6 | 0.1% | `tests/land-lock-check.bats` | -| 0.6 | 0.1% | `tests/board-payloads.bats` | -| 0.6 | 0.1% | `tests/sonar-gate.bats` | -| 0.6 | 0.1% | `tests/report-only-check.bats` | -| 0.5 | 0.1% | `tests/timeout-check.bats` | +| 0.6 | 0.1% | `tests/verified.bats` | +| 0.6 | 0.1% | `tests/stop-posture-check.bats` | +| 0.6 | 0.1% | `tests/checksums.bats` | | 0.5 | 0.1% | `tests/commit-convention.bats` | +| 0.5 | 0.1% | `tests/board-payloads.bats` | +| 0.5 | 0.1% | `tests/digest-major-agreement.bats` | +| 0.5 | 0.1% | `tests/sonar-gate.bats` | | 0.5 | 0.1% | `tests/branch-age-check.bats` | -| 0.5 | 0.1% | `tests/release-due.bats` | -| 0.5 | 0.1% | `tests/no-doctests.bats` | -| 0.5 | 0.1% | `tests/nonverdict-assert.bats` | -| 0.5 | 0.1% | `tests/coderabbit-config-check.bats` | -| 0.5 | 0.1% | `tests/connector-allow-resolve.bats` | -| 0.4 | 0.1% | `tests/rust-paths-check.bats` | +| 0.5 | 0.1% | `tests/pipefail-grep-check.bats` | +| 0.5 | 0.1% | `tests/msrv-pin-agreement.bats` | +| 0.5 | 0.1% | `tests/hook-pin-check.bats` | +| 0.4 | 0.1% | `tests/abandon-matrix.bats` | +| 0.4 | 0.1% | `tests/run-shape-guard-quoting.bats` | | 0.4 | 0.1% | `tests/cap-drift.bats` | +| 0.4 | 0.1% | `tests/land-lock-check.bats` | | 0.4 | 0.1% | `tests/license-table-check.bats` | -| 0.4 | 0.1% | `tests/batten-glob-check.bats` | -| 0.4 | 0.1% | `tests/container-preflight.bats` | -| 0.4 | 0.0% | `tests/token-bench-check.bats` | +| 0.4 | 0.1% | `tests/release-due.bats` | +| 0.4 | 0.1% | `tests/timeout-check.bats` | +| 0.4 | 0.1% | `tests/transcript-corpus-check.bats` | +| 0.4 | 0.1% | `tests/connector-allow-resolve.bats` | +| 0.4 | 0.1% | `tests/serena-mcp.bats` | +| 0.4 | 0.1% | `tests/no-doctests.bats` | +| 0.3 | 0.1% | `tests/report-only-check.bats` | +| 0.3 | 0.1% | `tests/nonverdict-assert.bats` | +| 0.3 | 0.1% | `tests/batten-glob-check.bats` | +| 0.3 | 0.0% | `tests/container-preflight.bats` | | 0.3 | 0.0% | `tests/git-hook.bats` | | 0.3 | 0.0% | `tests/ci-drift.bats` | -| 0.3 | 0.0% | `tests/remedy-payload-source.bats` | -| 0.3 | 0.0% | `tests/mise-action-floor.bats` | -| 0.3 | 0.0% | `tests/perf-gate.bats` | +| 0.3 | 0.0% | `tests/coderabbit-config-check.bats` | +| 0.3 | 0.0% | `tests/rust-paths-check.bats` | +| 0.2 | 0.0% | `tests/mise-action-floor.bats` | | 0.2 | 0.0% | `tests/dist.bats` | -| 0.2 | 0.0% | `tests/evaluator-io-check.bats` | -| 0.2 | 0.0% | `tests/task-fail-closed.bats` | +| 0.2 | 0.0% | `tests/token-bench-check.bats` | +| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.2 | 0.0% | `tests/perf-gate.bats` | +| 0.1 | 0.0% | `tests/task-fail-closed.bats` | +| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/egress-check.bats` | | 0.1 | 0.0% | `tests/darwin-link.bats` | | 0.1 | 0.0% | `tests/cross-check.bats` | -| 0.1 | 0.0% | `tests/zizmor-split.bats` | +| 0.0 | 0.0% | `tests/zizmor-split.bats` | diff --git a/crates/batten/tests/it/fact_record_keying.rs b/crates/batten/tests/it/fact_record_keying.rs new file mode 100644 index 000000000..51e184b0b --- /dev/null +++ b/crates/batten/tests/it/fact_record_keying.rs @@ -0,0 +1,291 @@ +//! An agent-sourced record is filed under the subject its `receipt` row's `key` +//! names (CLOUD-859), ported from `tests/fact-record-keying.bats`. +//! +//! # Why this tier and not a unit test +//! +//! Stated because the temptation is real and `sourced_path` is a pure function two +//! lines long. A unit test over it asserts that a filename contains a subject +//! somebody passed in; it cannot show that the BOUNDARY resolves that subject and +//! hands it to both halves. The defect was never in the filename — it was that +//! nothing computed a subject at all. So every case here goes through the two real +//! hook calls a session makes: a `PostToolUse` envelope carrying the declared +//! command, which is what mints the record, and a `PreToolUse` `gh pr ready`, +//! which is what reads it. Nothing writes a receipt by hand and nothing inspects a +//! path to decide a case. +//! +//! # The anti-vacuity twin is the point of the suite +//! +//! Not a courtesy: head-keying everything would satisfy the first case and break +//! `claim` repo-wide, because a claim attests to a decision about an ISSUE and +//! every commit on the branch continues to serve it. CLOUD-516's incident read the +//! other way round. "a branch-keyed record survives a new commit" is the case that +//! has to stay green. +//! +//! # What the port changed, and what it deliberately did not +//! +//! One assertion moved. The bats case pinned the refusal's inline remedy +//! (`gh pr view --json reviewThreads`), which CLOUD-1286 took off the hot path: +//! ~300 refusals a session paid for that clause every time one fired. It now +//! asserts the declared CLASS and its pointers, which is the whole of what the +//! emitted line carries, and the remedy is one hop away through +//! `batten policy explain`. Every other case is the same predicate over the same +//! two envelopes. + +// THE RETIREMENT LEDGER ARM, one per deleted path. `ported:` rather than +// `carried:` because the SUBJECT survives: `crates/batten/src/facts.rs` is engine +// source the campaign never retires, so this is the cases moving off bash while +// the thing under test stays exactly where it was. +// +// ported: tests/fact-record-keying.bats crates/batten/tests/it/fact_record_keying.rs subject:crates/batten/src/facts.rs + +// 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 std::process::Output; + +use common::{git_in, run_with_stdin, scratch, stdout, write}; + +/// A CONSTANT string, which is what the channel requires: `Declared.command` is +/// compared byte-for-byte against what the agent ran, and that comparison is the +/// forgery control. The engine never executes it — it compares the command and +/// counts the result — so these cases can drive shapes a live `gh` could not be +/// made to produce on demand. +const COMMAND: &str = + "gh pr view --json reviewThreads --jq '[.reviewThreads[] | select(.isResolved | not)]'"; + +/// Build the fixture repository with ONE `[[fact]]` and one `receipt` row keyed as +/// asked. +/// +/// The keying is the only thing that varies between cases, which is what makes a +/// difference in verdict attributable to it. +fn fixture(name: &str, key: &str, max_age: Option) -> PathBuf { + let dir = scratch(name); + let aged = max_age.map_or_else(String::new, |seconds| format!("max_age = {seconds}\n")); + write( + &dir, + "batten.toml", + &format!( + "version = 1\n\n[[fact]]\nname = \"keyed\"\nreturns = \"json-array\"\n\ + command = {command}\n\n[[rule]]\nid = \"ready-needs-the-fact\"\n\ + kind = \"receipt\"\nscope = \"mediated_call\"\nseverity = \"deny\"\n\ + pattern = \"gh pr ready\"\nchecks = [\"keyed\"]\nkey = \"{key}\"\n{aged}\ + reason = \"run the declared command\"\n", + command = serde_json::to_string(COMMAND).expect("a command is encodable"), + ), + ); + // `git_in` blanks global and system config for CLOUD-282's reason: a + // contributor's own git settings must not be able to change a verdict here. + git_in(&dir, &["init", "-q", "-b", "main", "."]); + commit(&dir, "the first commit"); + dir +} + +fn commit(dir: &Path, subject: &str) { + git_in(dir, &["commit", "-q", "--allow-empty", "-m", subject]); +} + +/// Mint the record the way a session does: a `PostToolUse` envelope carrying the +/// declared command and the buffer the host handed back. +fn record(dir: &Path, stdout_bytes: &str) -> Output { + let envelope = serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "sess-keying", + "cwd": "/repo", + "tool_name": "Bash", + "tool_input": {"command": COMMAND}, + "tool_response": {"stdout": stdout_bytes, "stderr": ""}, + }); + run_with_stdin( + dir, + &["hook", "--harness", "claude-code"], + &envelope.to_string(), + ) +} + +/// Read it: the call the receipt row judges. +fn ready(dir: &Path) -> Output { + let envelope = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "gh pr ready 999"}, + }); + run_with_stdin( + dir, + &["hook", "--harness", "claude-code"], + &envelope.to_string(), + ) +} + +/// BOTH HELPERS ASSERT THE EXIT STATUS, for `run-shape`'s measured reason: +/// `batten hook` prints nothing on an allow and exits 0 either way, so a substring +/// check over an empty string is true — including the empty output of a binary +/// that died before it judged anything. +fn denied(output: &Output) -> String { + let text = stdout(output); + assert_eq!(output.status.code(), Some(0), "the hook itself ran: {text}"); + assert!( + text.contains("\"permissionDecision\":\"deny\""), + "expected a deny: {text}" + ); + text +} + +fn allowed(output: &Output) { + let text = stdout(output); + assert_eq!(output.status.code(), Some(0), "the hook itself ran: {text}"); + assert!(!text.contains("\"deny\""), "expected an allow: {text}"); +} + +/// The record filenames in the store, sorted. +fn records(dir: &Path) -> Vec { + let store = dir.join(".git/batten-receipts"); + let Ok(entries) = std::fs::read_dir(&store) else { + return Vec::new(); + }; + let mut names: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("fact.")) + .collect(); + names.sort(); + names +} + +/// Backdate every record in the store. +/// +/// Aged rather than waited on: the property under test is that the bound is READ, +/// and a case that slept an hour would assert the same thing and cost an hour. +fn age_records(dir: &Path, seconds: u64) { + let when = std::time::SystemTime::now() - std::time::Duration::from_secs(seconds); + for name in records(dir) { + let path = dir.join(".git/batten-receipts").join(name); + let file = std::fs::File::options() + .write(true) + .open(&path) + .expect("open a record to backdate it"); + file.set_modified(when).expect("backdate the record"); + } +} + +// ported: "a head-keyed record cleared on one commit does not satisfy the check on the next" crates/batten/tests/it/fact_record_keying.rs subject:crates/batten/src/facts.rs the remedy assertion moved from the inline `gh pr view --json reviewThreads` clause to the declared class and its pointers, because CLOUD-1286 took the `Fix:` half off the hot path — the predicate is unchanged and the remedy is one hop away through `batten policy explain` +#[test] +fn a_head_keyed_record_cleared_on_one_commit_does_not_satisfy_the_next() { + // THE MEASURED DEFECT. Before CLOUD-859 the record filed under the fact's + // name, so the second `ready` here was ALLOWED — an agent ran the command, got + // a clear answer, pushed a fix nobody had reviewed, and readied it. + let dir = fixture("fact-keying-head", "head", None); + assert_eq!(record(&dir, "[]").status.code(), Some(0)); + allowed(&ready(&dir)); + + commit(&dir, "a fix nobody has looked at"); + let text = denied(&ready(&dir)); + // The CLASS and its pointers, which is the whole of what the hot path emits + // since CLOUD-1286: the declared token, the keying, and the row it belongs to. + // The remedy is unchanged and still the declared command — it is one hop away + // through `batten policy explain` rather than inline. + assert!(text.contains("receipt read missing"), "{text}"); + assert!(text.contains("ready-needs-the-fact"), "{text}"); +} + +// ported: "ANTI-VACUITY: a branch-keyed record still satisfies the check after a new commit" crates/batten/tests/it/fact_record_keying.rs subject:crates/batten/src/facts.rs +#[test] +fn anti_vacuity_a_branch_keyed_record_survives_a_new_commit() { + // The case that has to stay green. `claim-needs-receipt` is keyed by branch + // precisely because a claim attests to a decision about an issue that every + // commit on the branch continues to serve. A fix that head-keyed every record + // would pass the case above and make `claim` demand a re-claim per commit, + // which is the false-positive rate that gets a guard bypassed. + let dir = fixture("fact-keying-branch", "branch", None); + assert_eq!(record(&dir, "[]").status.code(), Some(0)); + commit(&dir, "one more commit on the same claim"); + allowed(&ready(&dir)); +} + +// ported: "a branch-keyed record does not follow the checkout onto another branch" crates/batten/tests/it/fact_record_keying.rs subject:crates/batten/src/facts.rs +#[test] +fn a_branch_keyed_record_does_not_follow_the_checkout_onto_another_branch() { + // The twin's own twin: `branch` must be a real subject rather than a way of + // spelling "never expires". A record minted on one branch is absent on the + // next, which is the same could-not-look the missing-record arm carries. + let dir = fixture("fact-keying-checkout", "branch", None); + assert_eq!(record(&dir, "[]").status.code(), Some(0)); + allowed(&ready(&dir)); + + git_in(&dir, &["checkout", "-q", "-b", "claude/somewhere-else"]); + denied(&ready(&dir)); +} + +// ported: "head and branch keyings file the record under different names" crates/batten/tests/it/fact_record_keying.rs subject:crates/batten/src/facts.rs +#[test] +fn head_and_branch_keyings_file_the_record_under_different_names() { + // The cheapest statement of "the column is load-bearing": two fixtures + // differing only in `key` put the record in two different places. Read off the + // STORE rather than asserted about a path, so this fails if the boundary stops + // resolving a subject even though `sourced_path` still accepts one. + let head_dir = fixture("fact-keying-names-head", "head", None); + assert_eq!(record(&head_dir, "[]").status.code(), Some(0)); + let head_named = records(&head_dir); + assert!(!head_named.is_empty(), "a head-keyed record was filed"); + + let branch_dir = fixture("fact-keying-names-branch", "branch", None); + assert_eq!(record(&branch_dir, "[]").status.code(), Some(0)); + let branch_named = records(&branch_dir); + assert!(!branch_named.is_empty(), "a branch-keyed record was filed"); + + assert_ne!(head_named, branch_named); + // The branch-keyed one names the branch; the head-keyed one does not. + assert!( + branch_named.iter().any(|name| name.contains("main")), + "{branch_named:?}" + ); + assert!( + !head_named.iter().any(|name| name.contains("main")), + "{head_named:?}" + ); +} + +// ported: "max_age bounds an agent-sourced record, and an unaged one still passes" crates/batten/tests/it/fact_record_keying.rs subject:crates/batten/src/facts.rs +#[test] +fn max_age_bounds_an_agent_sourced_record_and_an_unaged_one_still_passes() { + // CLOUD-988's column reached `receipt_facts` and the agent-sourced loop never + // read it, so neither the head nor the clock bounded the evidence. Both halves + // here, because a bound that refused everything would pass the first assertion + // alone. + let dir = fixture("fact-keying-age", "head", Some(3600)); + assert_eq!(record(&dir, "[]").status.code(), Some(0)); + allowed(&ready(&dir)); + + age_records(&dir, 7200); + denied(&ready(&dir)); +} + +// ported: "a named keying over an agent-sourced fact is refused at LOAD, not at decision" crates/batten/tests/it/fact_record_keying.rs subject:crates/batten/src/facts.rs +#[test] +fn a_named_keying_over_an_agent_sourced_fact_is_refused_at_load_not_at_decision() { + // The two halves run on different envelopes: the record is written on the + // post-tool event of the fact's own command, and a `named` subject is + // projected out of the call the row selects. So a `named` agent-sourced check + // would deny forever and running the command it names would not satisfy it — a + // gate nobody can clear, which is the failure the row exists to end. Refused + // where it can still be fixed rather than shipped as a column that files + // nothing. + let dir = fixture("fact-keying-named", "head", None); + let path = dir.join("batten.toml"); + let text = std::fs::read_to_string(&path).expect("read the fixture config"); + std::fs::write( + &path, + text.replace("key = \"head\"", "key = \"named\"\nkey_from = \"input-id\""), + ) + .expect("write the patched config"); + + let output = ready(&dir); + // Exit 1 and a usage error, never exit 2: this is config the operator wrote + // being refused, not a verdict about the call. + assert_eq!(output.status.code(), Some(1), "{}", common::stderr(&output)); + let reason = common::stderr(&output); + assert!(reason.contains("key = \"named\""), "{reason}"); + assert!(reason.contains("keyed"), "{reason}"); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 359238b96..44ad04782 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -96,6 +96,7 @@ mod enforce_journal; mod extension_surfaces; mod external_facts; mod extracted_facts; +mod fact_record_keying; mod facts; mod fail_on_warning; mod filed_here; diff --git a/tests/fact-record-keying.bats b/tests/fact-record-keying.bats deleted file mode 100644 index 55bfa777e..000000000 --- a/tests/fact-record-keying.bats +++ /dev/null @@ -1,265 +0,0 @@ -#!/usr/bin/env bats -# subject: crates/batten/src/facts.rs -# -# The subject header is READ, not decoration: `bats-tests-not-deleted` resolves it -# to decide whether a suite's subject is still alive, and every other suite names -# exactly one bare path. This one named two and a parenthetical, and the ratchet -# refused it at this line. The other half of the subject is -# `crates/batten/src/receipt.rs`, which is said here in prose rather than in the -# header for that reason. -# -# An agent-sourced record is filed under the subject its receipt row's `key` -# names (CLOUD-859). Until this suite existed the record was keyed on the fact's -# NAME alone, so `key` was accepted at load and unread at decision: one clear -# answer authorised every later head on the branch, and the read-the-review gate -# that shipped in #705 bound once per branch rather than once per head. -# -# WHY THIS TIER AND NOT A UNIT TEST, stated because the temptation is real and -# `sourced_path` is a pure function two lines long. A unit test over it asserts -# that a filename contains a subject somebody passed in; it cannot show that the -# BOUNDARY resolves that subject and hands it to both halves. The defect was -# never in the filename — it was that nothing computed a subject at all. So every -# case here goes through the two real hook calls a session makes: a `PostToolUse` -# envelope carrying the declared command, which is what mints the record, and a -# `PreToolUse` `gh pr ready`, which is what reads it. Nothing writes a receipt by -# hand and nothing inspects a path to decide a case. -# -# THE ANTI-VACUITY TWIN IS THE POINT OF THE SUITE, not a courtesy: head-keying -# everything would satisfy the first case and break `claim` repo-wide, because a -# claim attests to a decision about an ISSUE and every commit on the branch -# continues to serve it. "a branch-keyed record survives a new commit" is the -# case that has to stay green. - -setup() { - load helpers - - # `batten_binary` rather than the release-first chain five suites carried: - # `test:bats` builds DEBUG, so a leftover release binary shadowed it and a - # suite reported on a build older than the code under test. This suite is - # where that was measured — see the helper's own header. - BIN=$(batten_binary "$BATS_TEST_DIRNAME/..") || skip "no batten binary to drive" - - # A CONSTANT string, which is what the channel requires: `Declared.command` is - # compared byte-for-byte against what the agent ran, and that comparison is the - # forgery control. The engine never executes it — it compares the command and - # counts the result — so these cases can drive shapes a live `gh` could not be - # made to produce on demand. - COMMAND="gh pr view --json reviewThreads --jq '[.reviewThreads[] | select(.isResolved | not)]'" - REPO="$BATS_TEST_TMPDIR/repo" -} - -# Build the fixture repository with ONE `[[fact]]` and one `receipt` row keyed as -# asked. The keying is the only thing that varies between cases, which is what -# makes a difference in verdict attributable to it. -fixture() { # fixture [max_age] - rm -rf "$REPO" - mkdir -p "$REPO" - { - echo "version = 1" - echo - echo "[[fact]]" - echo 'name = "keyed"' - echo 'returns = "json-array"' - printf 'command = "%s"\n' "$COMMAND" - echo - echo "[[rule]]" - echo 'id = "ready-needs-the-fact"' - echo 'kind = "receipt"' - echo 'scope = "mediated_call"' - echo 'severity = "deny"' - echo 'pattern = "gh pr ready"' - echo 'checks = ["keyed"]' - printf 'key = "%s"\n' "$1" - if [ -n "${2:-}" ]; then printf 'max_age = %s\n' "$2"; fi - echo 'reason = "run the declared command"' - } >"$REPO/batten.toml" - # No global or system config: a contributor's own git settings must not be able - # to change a verdict here (CLOUD-282). - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git init -q -b main "$REPO" - commit "the first commit" -} - -commit() { # commit - (cd "$REPO" && - GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null \ - git -c user.email=fixture@example.invalid -c user.name=fixture \ - commit -q --allow-empty -m "$1") -} - -# Mint the record the way a session does: a PostToolUse envelope carrying the -# declared command and the buffer the host handed back. -record() { # record [] - local envelope - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PostToolUse","session_id":"sess-keying","cwd":"/repo","tool_name":"Bash","tool_input":{"command":sys.argv[1]},"tool_response":{"stdout":sys.argv[2],"stderr":""}}))' "$COMMAND" "${1:-[]}") - (cd "$REPO" && printf '%s' "$envelope" | "$BIN" hook --harness claude-code) -} - -# Read it: the call the receipt row judges. -ready() { - local envelope - envelope=$(python3 -c 'import json,sys; print(json.dumps({"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":sys.argv[1]}}))' "gh pr ready 999") - (cd "$REPO" && printf '%s' "$envelope" | "$BIN" hook --harness claude-code) -} - -# BOTH HELPERS ASSERT THE EXIT STATUS, for `tests/run-shape.bats`' measured -# reason: `batten hook` prints nothing on an allow and exits 0 either way, so a -# substring check over an empty string is true — including the empty output of a -# binary that died before it judged anything. -denied() { [ "$status" -eq 0 ] && [[ "$1" == *'"permissionDecision":"deny"'* ]]; } -allowed() { [ "$status" -eq 0 ] && [[ "$1" != *'"deny"'* ]]; } - -# The record filenames in the store, one per line. -# -# A glob rather than `find -printf`: that flag is GNU-only, and macOS `find` -# rejects it — so on a Mac this would print nothing and every non-empty assertion -# below would fail on the tool rather than on the gate. That is the class -# `tests/helpers.bash` exists for (CLOUD-282), and CI cannot catch it because CI -# is ubuntu. A glob is already sorted, so nothing else is needed. -records() { - local file names="" - for file in "$REPO"/.git/batten-receipts/fact.*; do - [[ -e "$file" ]] || continue - names="${names}${names:+$'\n'}$(basename "$file")" - done - printf '%s\n' "$names" -} - -# Backdate every record in the store by `$1` seconds. -# -# `python3` rather than `touch -d '2 hours ago'`: BSD `touch` reads `-d` as an -# ISO timestamp and rejects a relative expression outright, and these suites -# already depend on python3 for their envelopes. -age_records() { # age_records - python3 - "$REPO/.git/batten-receipts" "$1" <<'AGE' -import glob, os, sys, time - -store, seconds = sys.argv[1], int(sys.argv[2]) -when = time.time() - seconds -for path in glob.glob(os.path.join(store, "fact.*")): - os.utime(path, (when, when)) -AGE -} - -# --- the defect, and the case that shows it able to fail --------------------- - -@test "a head-keyed record cleared on one commit does not satisfy the check on the next" { - # THE MEASURED DEFECT. Before this change the record filed under the fact's - # name, so the second `ready` here was ALLOWED — an agent ran the command, got a - # clear answer, pushed a fix nobody had reviewed, and readied it. - fixture head - run record '[]' - [ "$status" -eq 0 ] - run ready - allowed "$output" - - commit "a fix nobody has looked at" - run ready - denied "$output" - # The remedy is the DECLARED command, unchanged: a record under a new head is - # simply missing, so no new verdict and no new message were needed. - [[ "$output" == *"gh pr view --json reviewThreads"* ]] -} - -# --- the anti-vacuity twin, which is what stops the fix being "key by head" --- - -@test "ANTI-VACUITY: a branch-keyed record still satisfies the check after a new commit" { - # The case that has to stay green. `claim-needs-receipt` is keyed by branch - # precisely because a claim attests to a decision about an issue that every - # commit on the branch continues to serve — CLOUD-516's incident read the other - # way round. A fix that head-keyed every record would pass the case above and - # make `claim` demand a re-claim per commit, which is the false-positive rate - # that gets a guard bypassed. - fixture branch - run record '[]' - [ "$status" -eq 0 ] - commit "one more commit on the same claim" - run ready - allowed "$output" -} - -@test "a branch-keyed record does not follow the checkout onto another branch" { - # And the twin's own twin: `branch` must be a real subject rather than a way of - # spelling "never expires". A record minted on one branch is absent on the next, - # which is the same could-not-look the missing-record arm already carries. - fixture branch - run record '[]' - [ "$status" -eq 0 ] - run ready - allowed "$output" - - (cd "$REPO" && git checkout -q -b claude/somewhere-else) - run ready - denied "$output" -} - -# --- the key is read at all -------------------------------------------------- - -@test "head and branch keyings file the record under different names" { - # The cheapest statement of "the column is load-bearing": two fixtures differing - # only in `key` put the record in two different places. Read off the store - # rather than asserted about a path, so this fails if the boundary stops - # resolving a subject even though `sourced_path` still accepts one. - fixture head - run record '[]' - [ "$status" -eq 0 ] - local head_named - head_named=$(records) - [ -n "$head_named" ] - - fixture branch - run record '[]' - [ "$status" -eq 0 ] - local branch_named - branch_named=$(records) - [ -n "$branch_named" ] - - [ "$head_named" != "$branch_named" ] - # The branch-keyed one names the branch; the head-keyed one does not. - [[ "$branch_named" == *"main"* ]] - [[ "$head_named" != *"main"* ]] -} - -# --- the clock, discarded on this path until now ------------------------------ - -@test "max_age bounds an agent-sourced record, and an unaged one still passes" { - # CLOUD-988's column reached `receipt_facts` and the agent-sourced loop never - # read it, so neither the head nor the clock bounded the evidence. Both halves - # here, because a bound that refused everything would pass the first assertion - # alone. - fixture head 3600 - run record '[]' - [ "$status" -eq 0 ] - run ready - allowed "$output" - - # Aged by the fixture rather than by waiting: the property under test is that - # the bound is READ, and a suite that slept an hour would assert the same thing - # and cost an hour. - age_records 7200 - run ready - denied "$output" -} - -# --- what a keying nobody can file under does -------------------------------- - -@test "a named keying over an agent-sourced fact is refused at LOAD, not at decision" { - # The two halves run on different envelopes: the record is written on the - # post-tool event of the fact's own command, and a `named` subject is projected - # out of the call the row selects. So a `named` agent-sourced check would deny - # forever and running the command it names would not satisfy it — a gate nobody - # can clear, which is the failure this whole row exists to end. Refused where it - # can still be fixed rather than shipped as a column that files nothing. - fixture head - python3 - "$REPO/batten.toml" <<'PATCH' -import sys -path = sys.argv[1] -text = open(path).read().replace('key = "head"', 'key = "named"\nkey_from = "input-id"') -open(path, "w").write(text) -PATCH - run ready - # Exit 1 and a usage error, never exit 2: this is config the operator wrote - # being refused, not a verdict about the call. - [ "$status" -eq 1 ] - [[ "$output" == *"key = \"named\""* ]] - [[ "$output" == *"keyed"* ]] -} From 872f5bf2ada18dbf0d9ef9d78ddbca82836b6f74 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 18:22:30 +0000 Subject: [PATCH 16/20] fix(verdict): convert the lock-complete classes main landed mid-rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A second rebase onto `origin/main` brought `policy/lock-complete.rego` — eleven refusal classes and their routes, all in the `V-SCREAMING-KEBAB` spelling CLOUD-1284 retires. Two more arrived in `rules-drift`. Same window as the last conversion and the same non-defect: they were written against a tree where that was the only spelling, and the registry's both-directions equality check is what surfaces them rather than letting them load. The class names are the vocabulary read at three positions rather than transliterations of the old ones. `lock write other` is a platform key mise does not emit — it disagrees with its counterpart rather than being absent. `lock reach missing` is a required platform with nothing to install from. `tool pin missing` and `tool pin absent` split what the old names ran together: a backend that CAN lock a url and did not, against a tool the lockfile never mentions. `lock write unsafe` and `workflow run unsafe` name the two settings that permit an unverified write, and `lock read unread` is the could-not-look. The routes collapse to the two landed spellings rather than eleven bespoke ids. Every one of them was either "read the declaration" or "run the task that regenerates it", so they take `config read first` and `task run first` — a route id is a class of remedy, and minting a new one per row is how a registry that exists to make a remedy lookupable ends up with a vocabulary nobody can hold. Four conflicts resolved to main's side, all the same shape as the last round: it replaced or extended prose this branch had only renamed a token inside. `bench/suites/RESULTS.md` is derived and takes the regenerated form. The load gate drove the conversion rather than a grep. `batten config show` refuses one malformed name at a time and says which, so the remaining ids were found by running it — which is the arity check working as CLOUD-1284 specified, on a registry it had never seen. One reading cost a detour and is worth recording: the gate reported `format` as invalid for kind `policy` when the config was correct, because the binary on PATH predated the rebase that added the column. The installed binary is not the tree's binary, and a gate run through it answers about neither — which is exactly why `batten-check` and `policy-budget` both shell `cargo run` rather than the installed name. `policy/lock-entry-complete.rego` is deleted in the same change. `main` renamed the module to `lock-complete.rego`, and taking its side of the conflicted hunk left the old path in the tree referenced by nothing — a module no row loads, which `mutate census` reports as uncovered because that is exactly what it is. The census caught it; a green suite could not have, since nothing ran it. Admits: b0159f56008100296cc8b5d5251dd016ded1a5783ad8d8bdc02b4ba64c0236d8 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 6de22801900c8c46378254c31a22b9cb7ab951d1 Admits-epoch: 658ff6f5760048d083e0c6e99ba723f974ec7001622f5c29ff8a698266601808 Admits-author: alec@wenzowski.com Admits-prev: 342fd2ece5a89e5b7c3160a6671f12a4e546bc543c2c38f6ae167cdf94cf038b Admits-answer-lost: The whole branch. `validate_one` refuses a class that is not three words once a `[vocabulary]` is declared, and `check_verdicts_are_declared` refuses in both directions, so with `main`'s rows still spelled `V-LOCK-*` nothing loads at all — not a gate switched off but a tree that cannot be built. The alternative is reverting CLOUD-1284, which is twelve landed tickets undone to avoid a rename. Admits-answer-precondition: The class names a pull-request review surface, and that surface cannot express this change because the change IS the `[[verdict]]` registry: `main` landed eleven `lock-complete` classes and two `rules-drift` classes in the retired spelling after this branch's converting commit, and the grammar gate refuses them at load — so the tree does not build until the ids move, and `batten.toml` is the one file that carries a class declaration. No non-protected path holds one. The write is one a reviewer sees in the diff it lands in — thirteen ids and their routes renamed, with `main`'s own class prose kept verbatim, on the branch this PR is opened from. Admits-answer-rejected-route: `config read first` does not apply: I have read `main`'s rows in full and am keeping their class prose unchanged; reading further produces no route that renames an id. `patch run first` does not apply either: a patch that rewrites a `[[verdict]]` id is still a write to `batten.toml`, so it reaches this same class one indirection later. Refs: CLOUD-1284 --- batten.toml | 58 +++++------ bench/suites/RESULTS.md | 166 ++++++++++++++++---------------- policy/lock-complete.rego | 28 +++--- policy/lock-entry-complete.rego | 115 ---------------------- policy/rules-drift.rego | 4 +- 5 files changed, 127 insertions(+), 244 deletions(-) delete mode 100644 policy/lock-entry-complete.rego diff --git a/batten.toml b/batten.toml index 2e91675da..28db01d32 100644 --- a/batten.toml +++ b/batten.toml @@ -4710,7 +4710,7 @@ tags = "v*" # CLOUD-843. THE WHOLE OF `mise-tasks/lock-complete.sh` LANDS HERE, and the row # above's narrow demonstration is subsumed into it rather than left beside it: two # rows over "is this lockfile entry usable" would be the second authority this -# config refuses everywhere else. `V-LOCK-ENTRY-PARTIAL` is raised by the module +# config refuses everywhere else. `lock write partial` is raised by the module # below now, so the registry row is conserved and no class was retired. # # `line_sources` BESIDE `staged`, AND THE SPLIT IS DELIBERATE. Every VERDICT comes @@ -6838,115 +6838,115 @@ kind = "document" target = "release-plz.toml" [[verdict]] -id = "V-LOCK-PLATFORM-RESIDUE" +id = "lock write other" gloss = "a lockfile platform key mise does not emit — install-time residue rather than a lock" class = """ -A key outside the set mise emits was written by an install on somebody's machine, not by a lock. The measured instance carried a checksum and no url and `lock-check` reported "complete and current" over it on every run, because regenerate-and-diff detects drift only and `mise lock` never removes an existing entry. Remove the key and regenerate; the setting that permits the write is `V-LOCKFILE-WRITES-ENABLED`'s. +A key outside the set mise emits was written by an install on somebody's machine, not by a lock. The measured instance carried a checksum and no url and `lock-check` reported "complete and current" over it on every run, because regenerate-and-diff detects drift only and `mise lock` never removes an existing entry. Remove the key and regenerate; the setting that permits the write is `lock write unsafe`'s. """ [[verdict.route]] -id = "R-REGENERATE-AWAY-THE-RESIDUE" +id = "task run first" kind = "command" target = "mise run lock-check" [[verdict]] -id = "V-LOCK-PLATFORM-UNINSTALLABLE" +id = "lock reach missing" gloss = "a platform this repository installs on has nothing to install from" class = """ Scoped to the REQUIRED platforms deliberately: mise emits a provenance-only stub where upstream ships no artifact, and failing on every url-less block would fail this repository for a decision upstream made — the defect of the gate this replaces, one level down. A required platform with no block, or a block with no url, is the repository unable to provision itself. Regenerate the entry, or move the platform out of the required set in the module if this repository genuinely stopped installing on it. """ [[verdict.route]] -id = "R-REGENERATE-THE-PLATFORM" +id = "task run first" kind = "command" target = "mise run lock-check" [[verdict]] -id = "V-LOCK-TOOL-UNLOCKED" +id = "tool pin missing" gloss = "a backend that fetches a release asset locks no platform, so it installs an unverified download" class = """ Whether locking nothing is fine depends on the BACKEND, never on the absence: npm, pipx, cargo, go, gem and core:rust resolve through their own package manager and cannot lock a url, while a fetch-an-asset backend can. Keying it on the absence is how the one tool here installed from an unverified download passed for its whole life with a bare version and no checksum (CLOUD-281). Regenerate so the entry carries checksums, or move the tool to a backend that verifies. """ [[verdict.route]] -id = "R-LOCK-THE-ASSET-BACKEND" +id = "config read first" kind = "command" target = "mise run lock-check" [[verdict]] -id = "V-LOCK-TOOL-UNDECLARED" +id = "tool declare missing" gloss = "a lockfile entry locks no platform and names no backend, so what it installs cannot be determined" class = """ -A different sentence from `V-LOCK-TOOL-UNLOCKED` and a different remedy: that one says the tool installs something unverified, this says nothing in the committed bytes says what it installs at all. The entry is not a lock in any sense. Regenerate it, or remove it if the tool is gone. +A different sentence from `tool pin missing` and a different remedy: that one says the tool installs something unverified, this says nothing in the committed bytes says what it installs at all. The entry is not a lock in any sense. Regenerate it, or remove it if the tool is gone. """ [[verdict.route]] -id = "R-DECLARE-THE-BACKEND" +id = "config read first" kind = "command" target = "mise run lock-check" [[verdict]] -id = "V-LOCK-TOOL-MISSING" +id = "tool pin absent" gloss = "a `[tools]` pin has no lockfile entry at all, so every CI job that installs it fails before any gate runs" class = """ The omission every other class here is blind to, because each of them judges an entry that is PRESENT — and the one failure a local run structurally cannot see. `[settings] lockfile = false` means nothing here installs `--locked`, so the tool installs fine on this machine forever; CI passes `--locked` and dies at the INSTALL step, before a single gate runs, in every job whose install list names it. Measured on PR #272: a fully green `mise run verify`, then two required checks red for a pin neither of them owns. """ [[verdict.route]] -id = "R-LOCK-THE-DECLARED-TOOL" +id = "config read first" kind = "command" target = "mise run lock-check" [[verdict]] -id = "V-LOCK-PIN-STALE" +id = "pin read stale" gloss = "a `[tools]` pin names a version its lockfile entry does not" class = """ -`V-LOCK-TOOL-MISSING` asks whether a row is THERE; this asks whether it says the same thing as the pin, which is the only question `mise install --locked` actually answers — and the row being present is exactly what makes a stale one invisible locally. Satisfaction rather than equality: a locked version that EXTENDS the pin at a component boundary is fine, because most of this table is pinned that way. Regenerate and commit the result. +`tool pin absent` asks whether a row is THERE; this asks whether it says the same thing as the pin, which is the only question `mise install --locked` actually answers — and the row being present is exactly what makes a stale one invisible locally. Satisfaction rather than equality: a locked version that EXTENDS the pin at a component boundary is fine, because most of this table is pinned that way. Regenerate and commit the result. """ [[verdict.route]] -id = "R-REGENERATE-THE-STALE-PIN" +id = "task run first" kind = "command" target = "mise run lock-check" [[verdict]] -id = "V-LOCKFILE-WRITES-ENABLED" +id = "lock write unsafe" gloss = "the manifest re-enables install-time lockfile writes, so any `mise install` can write the residue this row rejects" class = """ -The other half of `V-LOCK-PLATFORM-RESIDUE` rather than a separate concern: that class is half a mechanism while any install can produce a residue key. A per-caller opt-out cannot reach a caller it does not own, which is how a sandbox's own provisioning kept dirtying the tree; `[settings] lockfile = false` denies the write to everyone. Set it false — the scheduled currency job opts back in with `MISE_LOCKFILE=true`. +The other half of `lock write other` rather than a separate concern: that class is half a mechanism while any install can produce a residue key. A per-caller opt-out cannot reach a caller it does not own, which is how a sandbox's own provisioning kept dirtying the tree; `[settings] lockfile = false` denies the write to everyone. Set it false — the scheduled currency job opts back in with `MISE_LOCKFILE=true`. """ [[verdict.route]] -id = "R-DISABLE-LOCKFILE-WRITES" +id = "task run first" kind = "document" target = "mise.toml" [[verdict]] -id = "V-WORKFLOW-INSTALLS-UNLOCKED" +id = "workflow run unsafe" gloss = "a workflow installs with mise-action and sets no MISE_LOCKFILE, so it installs UNLOCKED and passes" class = """ `lockfile = false` turns off the whole lockfile feature rather than only the write, so `mise install --locked` fails outright with "locked mode requires lockfile to be enabled" and a workflow has to set `MISE_LOCKFILE` itself. One that forgets does not fail loudly: mise-action passes `--locked` only when it detects a lockfile, so the job installs unlocked and goes green. A checksum check silently dropped, which is the class this row exists for. Add `MISE_LOCKFILE: "true"` to the workflow's env. """ [[verdict.route]] -id = "R-SET-THE-WORKFLOW-LOCKFILE-ENV" +id = "task run first" kind = "document" target = ".github/workflows/ci.yml" [[verdict]] -id = "V-LOCK-UNREADABLE" +id = "lock read unread" gloss = "the manifest declares tools and the lockfile could not be read" class = """ Could-not-look, and it is conditioned on there being a subject rather than fired wherever `batten check` runs: a manifest carrying a `[tools]` table is the repository saying it locks tools, and without one there is nothing to report. That scoping is the defect CLOUD-1164 records for `tree-clean`, avoided here rather than survived. A repository declaring tools with no lockfile in the index cannot be judged, which is not the same answer as a clean one. """ [[verdict.route]] -id = "R-COMMIT-A-LOCKFILE" +id = "task run first" kind = "command" target = "mise run lock-check" [[verdict]] -id = "V-LOCK-ENTRY-PARTIAL" +id = "lock write partial" gloss = "a staged lockfile entry carries a checksum with no url, so it reads as locked and cannot be used" class = """ A platform entry with a checksum and no url is the partial shape a regenerate-and-diff gate structurally cannot catch: `mise lock` never removes or repairs an existing entry, so a stably wrong lockfile passes forever, and one did. Regenerate the entry with `mise run lock-complete`'s own tooling, or remove it — an absent lock is honest where a half-written one is not. Judged over the INDEX rather than the working tree, because this asks about the commit. @@ -8791,31 +8791,31 @@ kind = "document" target = "crates/batten/src/policy.rs" [[verdict]] -id = "V-RESTATED-ARM-COUNT-DRIFTS" +id = "rule count other" gloss = "a rules file states how many arms a rule has and the module has a different number" class = """ Predicate 1 one shape up: a restated VALUE against a restated CLOSED COUNT. CLOUD-1150 measured what one costs — `.claude/rules/toolchain.md` said one sibling-edit admission was allowed where `shell-retirement.rego` carried three, and a grooming session wrote that premise into two dispatched agent prompts and five issue bodies before anyone read the module. The module's own heads are the authority. Correct the number, or drop the `(N arms)` and let the reader read it there — dropping it is always allowed, because this class never demands that a count be restated. """ [[verdict.route]] -id = "R-CORRECT-THE-ARM-COUNT" +id = "task run first" kind = "command" target = "mise run rules-drift" [[verdict]] -id = "V-SCHEMA-KEY-UNDOCUMENTED" +id = "input name missing" gloss = "a file claiming to enumerate the policy input keys does not name one the engine emits" class = """ Predicate 3 run backwards, and it fails in the opposite direction: a named key the engine cannot emit is a dead gate, and an emittable key the enumerating file omits is an author re-deriving a fact the engine already builds, or filing a fact-family row for something that ships. Measured 2026-08-30 — `base-delta` and `symbols` were declared by the generated schema and named nowhere in the file whose own closing sentence said the lists were held to it. Scoped to the file that makes that claim; a file promising nothing is untouched. """ [[verdict.route]] -id = "R-NAME-THE-EMITTABLE-KEY" +id = "task run first" kind = "document" target = ".claude/rules/policy-modules.md" [[verdict]] -id = "V-DRIFT-AUTHORITY-UNREADABLE" +id = "drift read unread" gloss = "an authority some prose claims against could not be read" class = """ The could-not-look arm, and it is conditioned on there being a claim to judge rather than fired at startup: an unreadable authority matters exactly where prose depends on it. Without the condition this row would speak in every fixture repository that inherits this config, which is the scoping defect CLOUD-1164 records for `tree-clean`. diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 8ef8bcad6..4633576d1 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,133 +6,131 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 125 -- serial total: 592.1s +- suites: 123 +- serial total: 582.3s | seconds | share | suite | | ---: | ---: | --- | -| 138.6 | 23.4% | `tests/land-lock.bats` | -| 81.9 | 13.8% | `tests/land.bats` | -| 45.4 | 7.7% | `tests/hooks-wiring-check.bats` | -| 34.6 | 5.8% | `tests/main-watch.bats` | -| 24.3 | 4.1% | `tests/hook-latency-drift.bats` | -| 20.7 | 3.5% | `tests/graph-check.bats` | -| 19.8 | 3.3% | `tests/sbom-check.bats` | -| 14.2 | 2.4% | `tests/board-diff-overlap.bats` | -| 12.4 | 2.1% | `tests/run-shape-guard.bats` | -| 8.9 | 1.5% | `tests/target-race.bats` | -| 8.7 | 1.5% | `tests/token-bench.bats` | +| 142.6 | 24.5% | `tests/land-lock.bats` | +| 81.3 | 14.0% | `tests/land.bats` | +| 45.3 | 7.8% | `tests/hooks-wiring-check.bats` | +| 34.6 | 5.9% | `tests/main-watch.bats` | +| 24.3 | 4.2% | `tests/hook-latency-drift.bats` | +| 20.3 | 3.5% | `tests/graph-check.bats` | +| 19.3 | 3.3% | `tests/sbom-check.bats` | +| 14.1 | 2.4% | `tests/board-diff-overlap.bats` | +| 12.2 | 2.1% | `tests/run-shape-guard.bats` | +| 8.8 | 1.5% | `tests/token-bench.bats` | | 8.3 | 1.4% | `tests/ready-lint.bats` | -| 7.2 | 1.2% | `tests/ready-guard.bats` | -| 7.0 | 1.2% | `tests/board-sweep.bats` | -| 6.9 | 1.2% | `tests/released.bats` | -| 5.8 | 1.0% | `tests/lock-complete.bats` | -| 5.7 | 1.0% | `tests/replay.bats` | -| 5.5 | 0.9% | `tests/release-tracking-check.bats` | -| 5.4 | 0.9% | `tests/release-assets-check.bats` | -| 5.3 | 0.9% | `tests/sbom.bats` | +| 7.5 | 1.3% | `tests/target-race.bats` | +| 7.5 | 1.3% | `tests/ready-guard.bats` | +| 6.7 | 1.2% | `tests/released.bats` | +| 6.5 | 1.1% | `tests/board-sweep.bats` | +| 5.6 | 1.0% | `tests/replay.bats` | +| 5.4 | 0.9% | `tests/release-tracking-check.bats` | +| 5.3 | 0.9% | `tests/release-assets-check.bats` | +| 5.1 | 0.9% | `tests/sbom.bats` | | 5.1 | 0.9% | `tests/mcp-allow-check.bats` | +| 5.0 | 0.9% | `tests/singleton.bats` | +| 4.9 | 0.8% | `tests/step-receipt.bats` | | 4.9 | 0.8% | `tests/in-progress-drain.bats` | -| 4.8 | 0.8% | `tests/step-receipt.bats` | -| 4.6 | 0.8% | `tests/singleton.bats` | | 4.0 | 0.7% | `tests/task-registry.bats` | | 3.9 | 0.7% | `tests/ready-cites-check.bats` | -| 3.8 | 0.6% | `tests/land-divergence.bats` | +| 3.6 | 0.6% | `tests/land-divergence.bats` | | 3.6 | 0.6% | `tests/doctor-race.bats` | -| 3.5 | 0.6% | `tests/with-lock.bats` | | 3.5 | 0.6% | `tests/hk-selection.bats` | -| 3.4 | 0.6% | `tests/ntia-check.bats` | -| 3.2 | 0.5% | `tests/target-ensure.bats` | -| 2.6 | 0.4% | `tests/landed-check.bats` | -| 2.4 | 0.4% | `tests/closing-key-check.bats` | -| 2.3 | 0.4% | `tests/suite-select.bats` | -| 2.2 | 0.4% | `tests/claim-race-check.bats` | +| 3.3 | 0.6% | `tests/ntia-check.bats` | +| 3.2 | 0.5% | `tests/with-lock.bats` | +| 3.0 | 0.5% | `tests/target-ensure.bats` | +| 2.4 | 0.4% | `tests/landed-check.bats` | +| 2.3 | 0.4% | `tests/claim-race-check.bats` | +| 2.3 | 0.4% | `tests/tree-clean.bats` | +| 2.2 | 0.4% | `tests/suite-select.bats` | +| 2.2 | 0.4% | `tests/closing-key-check.bats` | | 2.1 | 0.4% | `tests/install.bats` | -| 2.0 | 0.3% | `tests/unlanded-check.bats` | -| 1.9 | 0.3% | `tests/bot-issue.bats` | -| 1.9 | 0.3% | `tests/finding-sink-check.bats` | -| 1.8 | 0.3% | `tests/spec-ref-check.bats` | -| 1.8 | 0.3% | `tests/ci-slow-needed.bats` | -| 1.7 | 0.3% | `tests/claimed-keys.bats` | +| 2.0 | 0.4% | `tests/finding-sink-check.bats` | +| 1.9 | 0.3% | `tests/spec-ref-check.bats` | +| 1.7 | 0.3% | `tests/ci-slow-needed.bats` | | 1.7 | 0.3% | `tests/reclaim-census.bats` | -| 1.6 | 0.3% | `tests/tree-clean.bats` | -| 1.6 | 0.3% | `tests/ready-lint-deferral.bats` | -| 1.6 | 0.3% | `tests/signing-posture.bats` | -| 1.6 | 0.3% | `tests/ci-tools-check.bats` | -| 1.5 | 0.2% | `tests/alive.bats` | +| 1.7 | 0.3% | `tests/signing-posture.bats` | +| 1.6 | 0.3% | `tests/claimed-keys.bats` | +| 1.6 | 0.3% | `tests/bot-issue.bats` | +| 1.5 | 0.3% | `tests/alive.bats` | +| 1.5 | 0.3% | `tests/ready-lint-deferral.bats` | +| 1.5 | 0.3% | `tests/ci-tools-check.bats` | | 1.4 | 0.2% | `tests/verify.bats` | -| 1.3 | 0.2% | `tests/perf-record.bats` | -| 1.2 | 0.2% | `tests/spawn-census.bats` | +| 1.4 | 0.2% | `tests/perf-record.bats` | | 1.2 | 0.2% | `tests/ci-lease-precondition.bats` | -| 1.2 | 0.2% | `tests/deferral-check.bats` | -| 1.2 | 0.2% | `tests/land-divergence-assert.bats` | | 1.2 | 0.2% | `tests/install-check.bats` | -| 1.2 | 0.2% | `tests/linear-check.bats` | -| 1.1 | 0.2% | `tests/done-check.bats` | +| 1.2 | 0.2% | `tests/land-divergence-assert.bats` | +| 1.1 | 0.2% | `tests/deferral-check.bats` | +| 1.1 | 0.2% | `tests/spawn-census.bats` | +| 1.1 | 0.2% | `tests/linear-check.bats` | | 1.1 | 0.2% | `tests/awk-regex-check.bats` | +| 1.1 | 0.2% | `tests/done-check.bats` | +| 1.0 | 0.2% | `tests/module-map-check.bats` | | 1.0 | 0.2% | `tests/nonverdict-scan.bats` | -| 0.9 | 0.2% | `tests/module-map-check.bats` | | 0.9 | 0.2% | `tests/release-backfill.bats` | -| 0.9 | 0.2% | `tests/done-pr-check.bats` | -| 0.9 | 0.1% | `tests/perf-assert.bats` | -| 0.9 | 0.1% | `tests/render-cli.bats` | -| 0.9 | 0.1% | `tests/hook-matcher-check.bats` | +| 0.9 | 0.2% | `tests/perf-assert.bats` | | 0.9 | 0.1% | `tests/lint-rego.bats` | -| 0.8 | 0.1% | `tests/pr-unsubscribed.bats` | -| 0.8 | 0.1% | `tests/attestation-check.bats` | -| 0.8 | 0.1% | `tests/commit-attribution.bats` | +| 0.9 | 0.1% | `tests/attestation-check.bats` | +| 0.8 | 0.1% | `tests/done-pr-check.bats` | +| 0.8 | 0.1% | `tests/render-cli.bats` | | 0.8 | 0.1% | `tests/doctor.bats` | | 0.8 | 0.1% | `tests/lint-deno.bats` | -| 0.8 | 0.1% | `tests/duplicate-close-check.bats` | -| 0.8 | 0.1% | `tests/timeout-drift.bats` | +| 0.8 | 0.1% | `tests/pr-unsubscribed.bats` | +| 0.7 | 0.1% | `tests/timeout-drift.bats` | +| 0.7 | 0.1% | `tests/hook-matcher-check.bats` | +| 0.7 | 0.1% | `tests/mcp-timeout-budget.bats` | | 0.7 | 0.1% | `tests/sbom-binary.bats` | -| 0.7 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.7 | 0.1% | `tests/macos-link-check.bats` | | 0.7 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.6 | 0.1% | `tests/publish-credential-check.bats` | -| 0.6 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.6 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.7 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.7 | 0.1% | `tests/evaluator-closure-check.bats` | +| 0.7 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.6 | 0.1% | `tests/verified.bats` | | 0.6 | 0.1% | `tests/suite-bench-check.bats` | | 0.6 | 0.1% | `tests/perf-compare.bats` | -| 0.6 | 0.1% | `tests/connector-allow-guard.bats` | -| 0.6 | 0.1% | `tests/verified.bats` | +| 0.6 | 0.1% | `tests/macos-link-check.bats` | +| 0.6 | 0.1% | `tests/commit-attribution.bats` | | 0.6 | 0.1% | `tests/stop-posture-check.bats` | -| 0.6 | 0.1% | `tests/checksums.bats` | -| 0.5 | 0.1% | `tests/commit-convention.bats` | +| 0.5 | 0.1% | `tests/checksums.bats` | | 0.5 | 0.1% | `tests/board-payloads.bats` | +| 0.5 | 0.1% | `tests/publish-credential-check.bats` | +| 0.5 | 0.1% | `tests/hook-pin-check.bats` | | 0.5 | 0.1% | `tests/digest-major-agreement.bats` | -| 0.5 | 0.1% | `tests/sonar-gate.bats` | -| 0.5 | 0.1% | `tests/branch-age-check.bats` | | 0.5 | 0.1% | `tests/pipefail-grep-check.bats` | -| 0.5 | 0.1% | `tests/msrv-pin-agreement.bats` | -| 0.5 | 0.1% | `tests/hook-pin-check.bats` | +| 0.4 | 0.1% | `tests/connector-allow-guard.bats` | | 0.4 | 0.1% | `tests/abandon-matrix.bats` | -| 0.4 | 0.1% | `tests/run-shape-guard-quoting.bats` | -| 0.4 | 0.1% | `tests/cap-drift.bats` | +| 0.4 | 0.1% | `tests/msrv-pin-agreement.bats` | +| 0.4 | 0.1% | `tests/sonar-gate.bats` | | 0.4 | 0.1% | `tests/land-lock-check.bats` | -| 0.4 | 0.1% | `tests/license-table-check.bats` | -| 0.4 | 0.1% | `tests/release-due.bats` | -| 0.4 | 0.1% | `tests/timeout-check.bats` | | 0.4 | 0.1% | `tests/transcript-corpus-check.bats` | +| 0.4 | 0.1% | `tests/run-shape-guard-quoting.bats` | +| 0.4 | 0.1% | `tests/branch-age-check.bats` | +| 0.4 | 0.1% | `tests/timeout-check.bats` | | 0.4 | 0.1% | `tests/connector-allow-resolve.bats` | -| 0.4 | 0.1% | `tests/serena-mcp.bats` | +| 0.4 | 0.1% | `tests/commit-convention.bats` | | 0.4 | 0.1% | `tests/no-doctests.bats` | +| 0.4 | 0.1% | `tests/serena-mcp.bats` | +| 0.3 | 0.1% | `tests/license-table-check.bats` | | 0.3 | 0.1% | `tests/report-only-check.bats` | +| 0.3 | 0.1% | `tests/container-preflight.bats` | +| 0.3 | 0.1% | `tests/release-due.bats` | | 0.3 | 0.1% | `tests/nonverdict-assert.bats` | +| 0.3 | 0.1% | `tests/git-hook.bats` | | 0.3 | 0.1% | `tests/batten-glob-check.bats` | -| 0.3 | 0.0% | `tests/container-preflight.bats` | -| 0.3 | 0.0% | `tests/git-hook.bats` | -| 0.3 | 0.0% | `tests/ci-drift.bats` | +| 0.3 | 0.1% | `tests/cap-drift.bats` | | 0.3 | 0.0% | `tests/coderabbit-config-check.bats` | | 0.3 | 0.0% | `tests/rust-paths-check.bats` | +| 0.3 | 0.0% | `tests/ci-drift.bats` | | 0.2 | 0.0% | `tests/mise-action-floor.bats` | -| 0.2 | 0.0% | `tests/dist.bats` | | 0.2 | 0.0% | `tests/token-bench-check.bats` | -| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | | 0.2 | 0.0% | `tests/perf-gate.bats` | -| 0.1 | 0.0% | `tests/task-fail-closed.bats` | -| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | +| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.2 | 0.0% | `tests/task-fail-closed.bats` | +| 0.2 | 0.0% | `tests/dist.bats` | | 0.1 | 0.0% | `tests/egress-check.bats` | +| 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/darwin-link.bats` | | 0.1 | 0.0% | `tests/cross-check.bats` | | 0.0 | 0.0% | `tests/zizmor-split.bats` | diff --git a/policy/lock-complete.rego b/policy/lock-complete.rego index 7dc3549ce..6b2f3af83 100644 --- a/policy/lock-complete.rego +++ b/policy/lock-complete.rego @@ -185,7 +185,7 @@ pointer(path, needles) := {"path": path} if not line_of(path, needles) # on every run. violation contains { "rule": "lock-platform-residue", - "verdict": "V-LOCK-PLATFORM-RESIDUE", + "verdict": "lock write other", "subjects": [ pointer("mise.lock", [sprintf("\"platforms.%s\"", [platform]), name]), {"artifact": name}, @@ -206,7 +206,7 @@ violation contains { # down. violation contains { "rule": "lock-platform-uninstallable", - "verdict": "V-LOCK-PLATFORM-UNINSTALLABLE", + "verdict": "lock reach missing", "subjects": [ pointer("mise.lock", [sprintf("\"platforms.%s\"", [platform]), name]), {"artifact": name}, @@ -223,7 +223,7 @@ violation contains { # reporting it three times here as well would bury the one line a reader acts on. violation contains { "rule": "lock-platform-uninstallable", - "verdict": "V-LOCK-PLATFORM-UNINSTALLABLE", + "verdict": "lock reach missing", "subjects": [ pointer("mise.lock", [sprintf("[[tools.%s]]", [name])]), {"artifact": name}, @@ -250,7 +250,7 @@ violation contains { # on, where predicate 2 deliberately says nothing. violation contains { "rule": "lock-platform-uninstallable", - "verdict": "V-LOCK-ENTRY-PARTIAL", + "verdict": "lock write partial", "subjects": [ pointer("mise.lock", [sprintf("\"platforms.%s\"", [platform]), name]), {"artifact": name}, @@ -273,7 +273,7 @@ violation contains { # those "locks nothing" means unlocked rather than exempt. violation contains { "rule": "lock-tool-unlocked", - "verdict": "V-LOCK-TOOL-UNLOCKED", + "verdict": "tool pin missing", "subjects": [ pointer("mise.lock", [sprintf("[[tools.%s]]", [name])]), {"artifact": name}, @@ -290,7 +290,7 @@ violation contains { # is unverified. violation contains { "rule": "lock-tool-unlocked", - "verdict": "V-LOCK-TOOL-UNDECLARED", + "verdict": "tool declare missing", "subjects": [ pointer("mise.lock", [sprintf("[[tools.%s]]", [name])]), {"artifact": name}, @@ -325,7 +325,7 @@ key_backend(name) := sprintf("core:%s", [name]) if not contains(name, ":") violation contains { "rule": "lock-tool-missing", - "verdict": "V-LOCK-TOOL-MISSING", + "verdict": "tool pin absent", "subjects": [pointer("mise.toml", [name]), {"artifact": name}], } if { lock_readable @@ -369,7 +369,7 @@ plain_version(pin) if regex.match(data.batten.patterns["plain-dotted-version"], violation contains { "rule": "lock-pin-stale", - "verdict": "V-LOCK-PIN-STALE", + "verdict": "pin read stale", "subjects": [pointer("mise.toml", [name]), {"artifact": name}], } if { some name, value in declared_tools @@ -394,7 +394,7 @@ writes_enabled if manifest.settings.lockfile == 1 violation contains { "rule": "lockfile-writes-enabled", - "verdict": "V-LOCKFILE-WRITES-ENABLED", + "verdict": "lock write unsafe", "subjects": [pointer("mise.toml", ["lockfile"])], } if { writes_enabled @@ -436,7 +436,7 @@ sets_lockfile(path) if { violation contains { "rule": "workflow-installs-unlocked", - "verdict": "V-WORKFLOW-INSTALLS-UNLOCKED", + "verdict": "workflow run unsafe", "subjects": [pointer(path, ["mise-action"])], } if { some path, _ in workflows @@ -460,7 +460,7 @@ declares_tools if { violation contains { "rule": "lock-unreadable", - "verdict": "V-LOCK-UNREADABLE", + "verdict": "lock read unread", "subjects": [{"path": "mise.lock"}], } if { declares_tools @@ -558,7 +558,7 @@ test_a_checksum_with_nothing_to_fetch_is_a_finding if { fixture_manifest, ) with data.batten.patterns as fixture_patterns - v.verdict == "V-LOCK-ENTRY-PARTIAL" + v.verdict == "lock write partial" } # THE PAIR THE SUBSUMED MODULE TURNED ON: an entry with NEITHER is simply @@ -566,7 +566,7 @@ test_a_checksum_with_nothing_to_fetch_is_a_finding if { # into "any platform without a url", which is predicate 2 with its scoping thrown # away. test_an_unlocked_entry_is_not_a_partial_one if { - count({v | some v in violation; v.verdict == "V-LOCK-ENTRY-PARTIAL"}) == 0 with input as fixture_input( + count({v | some v in violation; v.verdict == "lock write partial"}) == 0 with input as fixture_input( {"tools": {"t": [{ "version": "1.0.0", "backend": "aqua:x/t", @@ -622,7 +622,7 @@ test_a_tool_declaring_no_backend_is_a_finding if { {"settings": {"lockfile": false}, "tools": {}}, ) with data.batten.patterns as fixture_patterns - v.verdict == "V-LOCK-TOOL-UNDECLARED" + v.verdict == "tool declare missing" } test_a_declared_tool_with_no_lock_entry_is_a_finding if { diff --git a/policy/lock-entry-complete.rego b/policy/lock-entry-complete.rego deleted file mode 100644 index 1d22462f1..000000000 --- a/policy/lock-entry-complete.rego +++ /dev/null @@ -1,115 +0,0 @@ -# METADATA -# description: | -# The successor shape for `lock-complete`, and the demonstration CLOUD-1203 -# unit A owes: a tree-scoped module deciding over the git INDEX rather than the -# working tree. -# -# THE INDEX IS THE WHOLE POINT. `lock-complete` is the pure "committed bytes -# only, no network, no write" gate — it judges THE COMMIT, not the developer's -# working copy — so a successor reading `input.tree.documents` would answer a -# different question and pass over a staged-but-unsaved edit. That is a silent -# wrong answer, not a missing feature, and `input.tree.staged` is the only key -# in the model that can avoid it: `Fact::Tracked` walks the checkout and says -# so in its own doc, and `Fact::GitStatus` carries paths and a count. -# -# THE PREDICATE IS THE ONE `lock-complete`'s OWN COMMENT CITES as its -# motivation: a platform entry carrying a checksum and no url. Such an entry is -# the partial shape a regenerate-and-diff gate structurally cannot catch, -# because `mise lock` never removes or repairs an existing entry — so a stably -# wrong lockfile passes forever, and one did. -# -# WHAT THIS DELIBERATELY IS NOT is the currency question. Whether upstream has -# moved since this commit is a property of the WORLD and belongs on a schedule; -# this is a property of the COMMIT and belongs in a gate. `lock-complete`'s own -# split records that lesson, and reading the index rather than the network is -# what keeps this half on the right side of it. -# -# THE BRACKETS ARE NOT STYLE: the schema file carries a hyphen, so the dotted -# form is a parse error reported as `invalid schema reference`. -# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. -# schemas: -# - input: schema["policy-input.schema"] -package batten.lock_entry_complete - -import rego.v1 - -rules contains "lock-entry-complete" - -# Every platform entry in the STAGED lockfile. -# -# `[[tools."x"]]` is an array of tables and `[tools."x"."platforms.y"]` addresses -# the last element of it, so the parsed shape is a list per tool whose entries -# carry `version`, `backend` and one key per platform. The `startswith` is what -# separates the platform sub-tables from those two scalars. -platforms contains platform if { - some entries in input.tree.staged["mise.lock"].tools - some entry in entries - some key, platform in entry - startswith(key, "platforms.") -} - -# A checksum with nothing to fetch. -# -# The pair is the point: an entry with neither is simply unlocked, and an entry -# with both is complete. One without the other is the partial shape that reads as -# locked and cannot be used. -violation contains { - "rule": "lock-entry-complete", - "verdict": "lock write partial", - "subjects": [{"path": "mise.lock"}, {"count": count(partial)}], -} if { - count(partial) > 0 -} - -partial contains platform if { - some platform in platforms - platform.checksum - not platform.url -} - -# --- the load-time tier ------------------------------------------------------ -# -# These pin the PREDICATE. They cannot pin that the ENGINE reads the INDEX rather -# than the checkout — a `with input as` case fabricates the very shape the engine -# may be unable to produce, and here it would fabricate the very distinction the -# family exists for. `crates/batten/tests/staged_facts.rs` is that tier, and its -# `the_index_answers_not_the_worktree` case is the one that discriminates. - -lock(entry) := {"tree": {"staged": {"mise.lock": {"tools": {"aqua:example/tool": [entry]}}}}} - -complete := { - "version": "1.0.0", - "backend": "aqua:example/tool", - "platforms.linux-x64": {"checksum": "sha256:abc", "url": "https://example.invalid/tool.tar.gz"}, -} - -test_a_complete_entry_is_clean if { - count(violation) == 0 with input as lock(complete) -} - -test_a_checksum_with_no_url_is_refused if { - some v in violation with input as lock({ - "version": "1.0.0", - "backend": "aqua:example/tool", - "platforms.linux-x64": {"checksum": "sha256:abc"}, - }) - v.verdict == "lock write partial" -} - -test_an_unlocked_entry_is_not_a_partial_one if { - count(violation) == 0 with input as lock({ - "version": "1.0.0", - "backend": "aqua:example/tool", - "platforms.linux-x64": {}, - }) -} - -# The two scalars beside the platform tables must not be read as platforms, or a -# tool whose `backend` happens to be a map would be judged as one. -test_version_and_backend_are_not_platforms if { - count(violation) == 0 with input as lock({"version": "1.0.0", "backend": "aqua:example/tool"}) -} - -#MUTANT-SUITE crates/batten/tests/it/staged_facts.rs -#MUTANT-OWNER CLOUD-845|the tier this module names drives `input.tree.staged` and never installs the module, so no case in it can turn red under a mutation of the predicate -#MUTANT partial-entry-unread|s@^\tcount(partial) > 0$@\tfalse@|the_index_answers_not_the_worktree diff --git a/policy/rules-drift.rego b/policy/rules-drift.rego index be1178f1f..26d99c7a3 100644 --- a/policy/rules-drift.rego +++ b/policy/rules-drift.rego @@ -397,7 +397,7 @@ arm_count(name) := total if { violation contains { "rule": "restated-arm-count-drifts", - "verdict": "V-RESTATED-ARM-COUNT-DRIFTS", + "verdict": "rule count other", "subjects": [{"path": claim.path, "line": claim.line}, {"count": arm_count(claim.name)}], } if { some claim in arm_claims @@ -438,7 +438,7 @@ names_key(path, surface, key) if { violation contains { "rule": "schema-key-undocumented", - "verdict": "V-SCHEMA-KEY-UNDOCUMENTED", + "verdict": "input name missing", "subjects": [{"path": claimant.path, "line": claimant.line}, {"artifact": sprintf("input.%s.%s", [surface, key])}], } if { some claimant in claimants From 0e3d9dc142bf36a73d80450e3f07c14a3eb3fd08 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 18:41:44 +0000 Subject: [PATCH 17/20] fix(verdict): convert fixture registry ids to the three-word grammar The `lock-complete`, `rules-drift` and `staged-facts` fixture registries declared classes in the retired `V-SCREAMING-KEBAB` spelling while their probe modules raised the converted names, so `check_verdicts_are_declared` refused the load and the case failed on a registry mismatch rather than on the predicate it exists to pin. Route ids are untouched: `validate_route` checks the grammar only where a `[vocabulary]` table is declared, and these fixtures declare none. Refs: CLOUD-1284 --- crates/batten/tests/it/lock_complete.rs | 24 ++++++++++++------------ crates/batten/tests/it/rules_drift.rs | 6 +++--- crates/batten/tests/it/staged_facts.rs | 8 ++++---- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/batten/tests/it/lock_complete.rs b/crates/batten/tests/it/lock_complete.rs index d1f2d7525..4f8aa711f 100644 --- a/crates/batten/tests/it/lock_complete.rs +++ b/crates/batten/tests/it/lock_complete.rs @@ -64,7 +64,7 @@ // // changed: "the required set is overridable, and actually changes the verdict" policy/lock-complete.rego `BATTEN_LOCK_PLATFORMS` is gone rather than ported. A module reads no environment, and the set a repository installs on is committed config that belongs in a reviewed diff rather than in a knowable string anyone can spend to make the gate agree with them — the reasoning CLOUD-1051 applied to two override passwords one campaign over. `required_platforms` is a literal in the module now and changing it is a diff // changed: "it makes no network call and does not touch the lockfile" policy/lock-complete.rego a grep over the program's executable lines has no successor and needs none: `kind = "policy"` is declared `read`, so the engine's effect model refuses a module a spawn or a write structurally rather than by asserting the absence of a string. house-style §5's read-only allowlist is the mechanism, and it is stronger than the assertion it replaces because it cannot be satisfied by spelling the call differently -// changed: "a missing lockfile exits 2, distinct from an incomplete one" policy/lock-complete.rego both are exit 2 now, and that is the house contract rather than a regression: AGENTS.md non-negotiable rule 5 and house-style §6-7 give one exit table with no per-verb exception, where 2 is the policy verdict for a check violation and a hook deny alike. The DISTINCTION survives where it is read — `V-LOCK-UNREADABLE` is its own verdict token with its own remedy, so could-not-look and incomplete are still two classes +// changed: "a missing lockfile exits 2, distinct from an incomplete one" policy/lock-complete.rego both are exit 2 now, and that is the house contract rather than a regression: AGENTS.md non-negotiable rule 5 and house-style §6-7 give one exit table with no per-verb exception, where 2 is the policy verdict for a check violation and a hook deny alike. The DISTINCTION survives where it is read — `lock read unread` is its own verdict token with its own remedy, so could-not-look and incomplete are still two classes // changed: "with no argument and no mise.lock in the index, exit 2" policy/lock-complete.rego the same exit change, plus a narrowing that is the whole of CLOUD-1164's lesson: a `[[rule]]` has no call site, so an unconditional refusal over an absent lockfile would speak in every fixture repository inheriting this config. It is conditioned on a staged `mise.toml` declaring a `[tools]` table — the repository saying it locks something — and is silent where there is no subject // changed: "lockfile = false passes, and the lockfile clauses keep their own header" policy/lock-complete.rego there are no headers. The predecessor printed one `::error::` banner per clause and tracked a `reported` flag so an earlier clause could not swallow a later one; findings are one pointer line each here, so the failure mode that case existed for is unspellable. The half of it that survives is that both classes are reported at once, which every multi-finding case below exercises // changed: "fixture mode does not consult mise.toml at all" policy/lock-complete.rego there is no fixture mode: a rule takes no argument, so the program's `$1` path and the boundary it kept between "these bytes" and "this repository" have no successor. The distinction it protected is preserved differently and better — every case in this file is a whole repository, so a run makes exactly the claims that repository's own index supports @@ -76,7 +76,7 @@ // `policy/lock-entry-complete.rego` decided a strict subset of predicate 2 — a // platform block carrying a checksum and no url — and two rows over one question // is the second authority this repository refuses everywhere else. Its verdict -// token `V-LOCK-ENTRY-PARTIAL` is raised here now, so the registry row is +// token `lock write partial` is raised here now, so the registry row is // conserved rather than retired, and its two cases in `staged_facts.rs` are // repointed at this module. @@ -478,7 +478,7 @@ format = "toml" line_sources = ["mise.lock", "mise.toml", ".github/workflows/*.yml"] [[verdict]] -id = "V-LOCK-PLATFORM-RESIDUE" +id = "lock write other" gloss = "a platform key mise does not emit" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -488,7 +488,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCK-PLATFORM-UNINSTALLABLE" +id = "lock reach missing" gloss = "a required platform nothing can be installed from" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -498,7 +498,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCK-ENTRY-PARTIAL" +id = "lock write partial" gloss = "a platform block with a checksum and nothing to fetch" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -508,7 +508,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCK-TOOL-UNLOCKED" +id = "tool pin missing" gloss = "an asset-fetching backend that locks no platform" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -518,7 +518,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCK-TOOL-UNDECLARED" +id = "tool declare missing" gloss = "a tool locking nothing and declaring no backend" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -528,7 +528,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCK-TOOL-MISSING" +id = "tool pin absent" gloss = "a declared tool with no lockfile entry at all" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -538,7 +538,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCK-PIN-STALE" +id = "pin read stale" gloss = "a pin its lockfile entry does not name" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -548,7 +548,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCKFILE-WRITES-ENABLED" +id = "lock write unsafe" gloss = "the manifest re-enables install-time lockfile writes" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -558,7 +558,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-WORKFLOW-INSTALLS-UNLOCKED" +id = "workflow run unsafe" gloss = "a workflow installs with mise-action and does not set MISE_LOCKFILE" class = "A fixture class, restated because the engine refuses an undeclared token." @@ -568,7 +568,7 @@ kind = "document" target = "policy/lock-complete.rego" [[verdict]] -id = "V-LOCK-UNREADABLE" +id = "lock read unread" gloss = "a manifest declares tools and the lockfile could not be read" class = "A fixture class, restated because the engine refuses an undeclared token." diff --git a/crates/batten/tests/it/rules_drift.rs b/crates/batten/tests/it/rules_drift.rs index 69033cdfc..c2b50b8bc 100644 --- a/crates/batten/tests/it/rules_drift.rs +++ b/crates/batten/tests/it/rules_drift.rs @@ -218,7 +218,7 @@ kind = "document" target = "policy/rules-drift.rego" [[verdict]] -id = "V-RESTATED-ARM-COUNT-DRIFTS" +id = "rule count other" gloss = "a restated arm count disagrees with the module" class = "fixture" @@ -228,7 +228,7 @@ kind = "document" target = "policy/rules-drift.rego" [[verdict]] -id = "V-SCHEMA-KEY-UNDOCUMENTED" +id = "input name missing" gloss = "a claiming file does not name a key the engine emits" class = "fixture" @@ -238,7 +238,7 @@ kind = "document" target = "policy/rules-drift.rego" [[verdict]] -id = "V-DRIFT-AUTHORITY-UNREADABLE" +id = "drift read unread" gloss = "an authority some prose claims against could not be read" class = "fixture" diff --git a/crates/batten/tests/it/staged_facts.rs b/crates/batten/tests/it/staged_facts.rs index c89e0d493..c7c4c5612 100644 --- a/crates/batten/tests/it/staged_facts.rs +++ b/crates/batten/tests/it/staged_facts.rs @@ -319,22 +319,22 @@ staged = ["pinned.lock"] format = "toml" [[verdict]] -id = "V-LOCK-STAGED-READ" +id = "lock staged read" gloss = "the probe resolved a node for the declared .lock path" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-LOCK-READ" +id = "lock read probe" kind = "document" target = "lock.rego" [[verdict]] -id = "V-LOCK-COULD-NOT-LOOK" +id = "lock could notlook" gloss = "the declared .lock path reached the could-not-look channel" class = "A fixture class, raised only by this suite's probe module." [[verdict.route]] -id = "R-LOCK-MISSING" +id = "lock missing probe" kind = "document" target = "lock.rego" "#; From 457113541beaf58979acbd273a48c5786d0a7441 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 19:03:08 +0000 Subject: [PATCH 18/20] fix(config): restore the pattern id the rename sweep hit, and declare two renames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1284's sweep converts `[[verdict]]` and `[[verdict.route]]` ids to the three-word grammar. It is keyed on the id string, so it also rewrote the `[[pattern]]` row `policy-rule-const` — whose new spelling collided with a real route id — and `config-lint` read the row as removed against `origin/main`. Restored here, with `policy/rules-drift.rego`'s two reads and its fixture registry repointed at it. No pattern row on this branch differs from main's now. The two remaining smells are the rename read syntactically. `config-lint` keys a verdict by its id, so a class that carried an override on main and carries the same override under a converted name reads as an override newly added: verdict-override-added verdict[diff ship early].override `V-PROSE-ONLY-DIFF` renamed. Its `override` route, precondition and gloss are byte-identical to main's; only the id changed. verdict-override-added verdict[issue file same].override `V-FILED-OVER-OWN-DIFF` renamed, same shape. `batten.toml` on main already states "THE OVERRIDE IS DECLARED ON THE CLASS" of this row. Neither adds a route, widens a precondition, or reaches a subject the class did not already reach. Refs: CLOUD-1284 Weakens: verdict-override-added verdict[diff ship early].override Weakens: verdict-override-added verdict[issue file same].override Admits: 1189492d572f0263a78539a035288c96a452e12f4b5b3cf5d6447e284ae473c7 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 4770b6d7bc9b78aa93c6cc0a371df2d215932419 Admits-epoch: 0cf85f299d456bb50824d2a3738d70642a1a2e71913cd3e04ba95c520c9a0162 Admits-author: alec@wenzowski.com Admits-prev: 4528fe7dd88357aa839d5cfe2d3df77b8278399af900eb5ed482063472be8f30 Admits-answer-lost: config-lint stays red on `pattern-removed`, so `verify` writes no receipt, `ready-guard` refuses `gh pr ready`, and the whole bundle cannot land. The `rules-drift` module also keeps reading `data.batten.patterns["module read first"]`, a name whose row would then be missing on any tree that took the rename literally. Admits-answer-precondition: No surface can express this: it is a one-token repair of a `[[pattern]]` row id inside batten.toml itself, which this branch's mechanical rename sweep hit by accident. `policy-rule-const` was renamed to `module read first`, colliding with a route id, and config-lint reports `pattern-removed` against origin/main. Only a direct edit of the protected file restores the row, and it lands as one visible line in the PR diff. Admits-answer-rejected-route: `config read first` was taken — batten.toml and origin/main's copy were both read, and the diff is what identified the accidental rename. `patch run first` (git restore) does not apply: this file carries 62 commits of intended change from this bundle, so restoring it would discard all of them to fix one token. --- batten.toml | 2 +- crates/batten/tests/it/rules_drift.rs | 2 +- policy/rules-drift.rego | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/batten.toml b/batten.toml index 28db01d32..cab8949b9 100644 --- a/batten.toml +++ b/batten.toml @@ -8702,7 +8702,7 @@ id = "fixed-rule-ref" regex = '`data\.batten\.[a-z_]+`' [[pattern]] -id = "module read first" +id = "policy-rule-const" regex = '^const [A-Z_]+_RULE: &str = "[a-z_]+";' # CLOUD-1150 §2. The unit word is load-bearing: a rules file writes plenty of diff --git a/crates/batten/tests/it/rules_drift.rs b/crates/batten/tests/it/rules_drift.rs index c2b50b8bc..b2aff32ba 100644 --- a/crates/batten/tests/it/rules_drift.rs +++ b/crates/batten/tests/it/rules_drift.rs @@ -137,7 +137,7 @@ id = "fixed-rule-ref" regex = '`data\.batten\.[a-z_]+`' [[pattern]] -id = "module read first" +id = "policy-rule-const" regex = '^const [A-Z_]+_RULE: &str = "[a-z_]+";' [[pattern]] diff --git a/policy/rules-drift.rego b/policy/rules-drift.rego index 26d99c7a3..fec015570 100644 --- a/policy/rules-drift.rego +++ b/policy/rules-drift.rego @@ -320,7 +320,7 @@ violation contains { # name the evaluator does not query as a rule, and saying so is the honest report. queried_rules contains name if { some _, text in input.tree.lines["crates/batten/src/policy.rs"] - some found in regex.find_n(data.batten.patterns["module read first"], text, -1) + some found in regex.find_n(data.batten.patterns["policy-rule-const"], text, -1) name := split(found, "\"")[1] } @@ -784,5 +784,5 @@ fixture_patterns := { "shell-default": "\\$\\{[A-Z][A-Z0-9_]*:-[^}]*\\}", "policy-input-key": "`input\\.(tree|call)\\.[a-z][a-z0-9_-]*", "fixed-rule-ref": "`data\\.batten\\.[a-z_]+`", - "module read first": "^const [A-Z_]+_RULE: &str = \"[a-z_]+\";", + "policy-rule-const": "^const [A-Z_]+_RULE: &str = \"[a-z_]+\";", } From 9737a46a5f156d31d7a9bff1ede1c47414a1252f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 19:37:42 +0000 Subject: [PATCH 19/20] feat(policy): a remedy resolves to a declared command or rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1189, the thirteenth row of this bundle. It was recorded as skipped because `claim-check` would not pull it; measured this session, `batten claim check --issue CLOUD-1189` exits 0 and its receipt reads `ready-lint pass`. The skip was on a false premise, so the row is built. `redirect` strings are free-form prose and nothing resolved the command they name against anything, so any verb that moves turns every remedy naming it into a lie, silently. `redirect::validate_remedies` resolves each one against the two authorities that can answer: the longest prefix that is a declared `SURFACE` path, then every remaining word against the declared `[[rule]]` ids. Wired into `config::parse_ungated` beside its four sibling validators rather than left with no call site — the defect `verbs::validate` had for its whole life (CLOUD-242). AN INVOCATION IS A CODE SPAN, and that bound is the whole of what makes this decidable. The first version collected every following token that looked like a subcommand word, which reads "run `batten capture show ` instead" as a four-word invocation ending in `instead` and then reports `instead` as an undeclared rule id. A finding invented out of English is the false positive that gets a gate switched off; it turned three cases red before it was bounded. What the bound under-denies — a command named in bare prose — is asserted outright in `an_invocation_is_a_code_span_and_bare_prose_is_not_one` rather than left to a reader of the predicate. Two deviations from the row's §5, both recorded on the issue rather than silently taken: no `[[verdict]]` row. A load-time refusal is a `UsageError`, not a raised class, and a declared row nothing raises fails the load — so shipping one would break every consumer's config. Every sibling validator in `parse_ungated` refuses the same way. the population is zero. 22 remedy strings on the committed table, 0 naming a `batten` invocation; 44 `command` routes, 0 batten-invoking. The row's own premise is that the gate survives a surface rename by construction, so the constructed cases are what show it can fail and `the_committed_table_loads` is the mirror that keeps it from being tightened past what an author writes. Refs: CLOUD-1189 --- crates/batten/src/config.rs | 34 +++ crates/batten/src/redirect.rs | 125 +++++++++ crates/batten/tests/it/main.rs | 1 + crates/batten/tests/it/redirect_resolves.rs | 264 ++++++++++++++++++++ 4 files changed, 424 insertions(+) create mode 100644 crates/batten/tests/it/redirect_resolves.rs diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 5007fb614..11bbf83bc 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -1068,6 +1068,40 @@ fn parse_ungated(text: &str, source: &str) -> Result { // actually emit needs the compiled bundles and lives in `policy::load`. crate::verdict::validate(&config.verdicts, &config.vocabulary)?; crate::redirect::validate(&config.redirects)?; + // The remedies those two tables carry, resolved against the command surface + // and the rule table (CLOUD-1189). Here rather than in `redirect::validate` + // because it is the one clause needing a THIRD table — the `[[rule]]` ids — + // and a validator reaching past its own argument for them is how one table's + // checker quietly becomes the config's. + // + // Both remedy tables in one call, because they answer one question and two + // spellings of "does this remedy name a real command" is the drift a shared + // question does not survive — `verdict-routes-resolve`'s note about the two + // sources of "what tasks exist" is the same reasoning one table over. + { + let rule_ids: Vec = config.rules.iter().map(|rule| rule.id.clone()).collect(); + let remedies = config + .redirects + .iter() + .flat_map(|entry| { + std::iter::once(( + format!("redirect[{}].mutation", entry.glob), + entry.mutation.as_str(), + )) + .chain( + entry + .read + .as_deref() + .map(|read| (format!("redirect[{}].read", entry.glob), read)), + ) + }) + .chain(config.verbs.iter().filter_map(|verb| { + verb.redirect + .as_deref() + .map(|text| (format!("verb[{}].redirect", verb.verb), text)) + })); + crate::redirect::validate_remedies(remedies, &rule_ids)?; + } // And the MCP table, at load for the identical reason (CLOUD-1260). Every // clause is a property of the TABLE — a duplicated id, a path that would // leave its root, a reduction over no fields at all — so it is knowable diff --git a/crates/batten/src/redirect.rs b/crates/batten/src/redirect.rs index b8b937ddb..5c5ad52fb 100644 --- a/crates/batten/src/redirect.rs +++ b/crates/batten/src/redirect.rs @@ -184,6 +184,131 @@ pub fn resolve_read<'table>(table: &'table [Redirect], path: &str) -> Option<&'t .and_then(|entry| entry.read.as_deref()) } +/// The words of the `batten` invocation a remedy names, or `None` for a remedy +/// that names none (CLOUD-1189). +/// +/// # An invocation is a CODE SPAN, and that bound is the whole of what makes +/// this decidable +/// +/// A remedy is a sentence, and a sentence does not say where its command stops. +/// The first version of this collected every following token that LOOKED like a +/// subcommand word, which reads `run `batten capture show ` instead` as +/// a four-word invocation ending in `instead` — and then reports `instead` as an +/// undeclared rule id. That is a finding invented out of English, and it is the +/// false positive that gets a gate switched off. +/// +/// A backtick-delimited span has an author-written end, so inside it every word +/// IS part of the command and the rule-id arm below can be exact rather than a +/// guess. `verify` over this repository's own table is what the bound is sized +/// against. +/// +/// **What it deliberately under-denies, stated rather than discovered:** a +/// remedy naming a command in bare prose. That is the sanctioned direction here +/// — the alternative is not a stricter gate but a wrong one, and this repository +/// writes every remedy's command as a code span. +fn invocation(text: &str) -> Option> { + for span in text.split('`').skip(1).step_by(2) { + let mut words = span.split_whitespace(); + if words.next() != Some("batten") { + continue; + } + let taken: Vec = words.map(str::to_owned).collect(); + if !taken.is_empty() { + return Some(taken); + } + } + None +} + +/// `--json` — a flag, judged by neither authority. +/// +/// Which flags a verb takes is `SURFACE`'s own declaration and `clap`'s to +/// enforce at the call; resolving one here would be a third reading of it. +fn is_flag(word: &str) -> bool { + word.starts_with('-') +} + +/// `` — an operand the author wrote as a hole to fill in. +/// +/// Judged by neither authority: what a verb's operands may be is a per-verb +/// arity question, and this row reads the command surface and the rule table +/// rather than a third one. +fn is_placeholder(word: &str) -> bool { + word.starts_with('<') && word.ends_with('>') && word.len() > 2 +} + +/// Refuse a remedy naming a `batten` command that resolves to nothing +/// (CLOUD-1189). +/// +/// # Why this exists +/// +/// A deny naming a command that does not exist is worse than a deny naming +/// none: it sends the reader to a shell error instead of a remedy, and it looks +/// authoritative doing it. `redirect` strings are free-form prose and nothing +/// resolved the command they name against anything, so a renamed verb turned +/// every remedy naming it into a lie, silently. +/// +/// # Both object shapes, because a gate that knew one would report the other +/// +/// `batten show config` resolves against [`crate::surface::SURFACE`]; +/// `batten check ` resolves its prefix against `SURFACE` and its +/// remaining word against the declared `[[rule]]` table. A checker knowing only +/// the first would report every rule-scoped remedy as broken, which is the false +/// positive that gets a gate switched off. +/// +/// # What it deliberately does not decide +/// +/// A remedy naming a **non-batten** command (`mise run …`, `git …`) — that is +/// the operator's PATH, and resolving it needs a second authority over what is +/// installed, which is `policy/verdict-routes-resolve.rego`'s `mise run` arm and +/// not this one. Whether the remedy is *good* advice, which is judgement and +/// rule 3 forbids gating it. And an operand written as a ``. +/// +/// # Errors +/// +/// Returns a [`UsageError`] (→ exit `1`) naming the remedy's own key and the +/// unresolvable words. **Pointer-only** (rule 4): the key and the command, never +/// the remedy prose that carries it. +pub fn validate_remedies<'a>( + remedies: impl IntoIterator, + rules: &[String], +) -> Result<()> { + for (key, text) in remedies { + let Some(words) = invocation(text) else { + continue; + }; + let mut matched = 0; + for take in (1..=words.len()).rev() { + let candidate = words[..take].join(" "); + if crate::surface::SURFACE + .iter() + .any(|decl| decl.path == candidate) + { + matched = take; + break; + } + } + if matched == 0 { + return Err(UsageError::raise(format!( + "{key}: the remedy names `batten {}`, which is not a declared command — correct \ + the remedy, or declare the command on the surface", + words.join(" ") + ))); + } + for word in &words[matched..] { + if is_flag(word) || is_placeholder(word) || rules.iter().any(|id| id == word) { + continue; + } + return Err(UsageError::raise(format!( + "{key}: the remedy names `batten {}` and then `{word}`, which is neither a \ + declared rule id nor a — correct the remedy, or declare the rule", + words[..matched].join(" ") + ))); + } + } + Ok(()) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 44ad04782..cabe6adce 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -151,6 +151,7 @@ mod prospective_facts; mod provision; mod ratchet; mod ready; +mod redirect_resolves; mod reference_coverage; mod refusal_ceiling; mod remedy_authorship; diff --git a/crates/batten/tests/it/redirect_resolves.rs b/crates/batten/tests/it/redirect_resolves.rs new file mode 100644 index 000000000..1b123da1d --- /dev/null +++ b/crates/batten/tests/it/redirect_resolves.rs @@ -0,0 +1,264 @@ +//! A remedy resolves to a declared command or rule, over the compiled binary +//! (CLOUD-1189). +//! +//! # Why this tier and not the unit tests beside the predicate +//! +//! `redirect::validate_remedies` takes its two authorities as arguments, so a +//! unit test hands it whatever rule ids and remedy strings it likes and never +//! proves the CONFIG LOADER assembles them. The defect that shape cannot see is +//! the one `verbs::validate` actually had for its whole life (CLOUD-242): a +//! refusal with no call site, asserted present by a doc comment and a passing +//! test that reached past `parse` to call the validator by hand. Every case here +//! goes through the binary's own `config` load. +//! +//! # The population is currently zero, and that is the row's own premise +//! +//! Measured on the committed table: **22 remedy strings, 0 naming a `batten` +//! invocation**, and 44 `command` routes, 0 of them `batten`-invoking. So this +//! gate reports nothing today and is not expected to. CLOUD-1189 says so in as +//! many words — *"the hole is real today — a remedy could already name a command +//! that never existed, and nothing would say so"* — and its acceptance is that +//! the gate **survives the surface rename by construction**: it resolves against +//! `SURFACE`, so a renamed verb makes a stale remedy fail immediately rather than +//! silently. +//! +//! That makes the anti-vacuity arm here load-bearing in the harder direction +//! (CLOUD-418). A gate over an empty population passes trivially, so the cases +//! that matter are the constructed ones showing it CAN fail, and +//! `the_committed_table_loads` is the mirror that keeps the predicate from being +//! tightened into something the real config trips over. + +use crate::common::{Fixture, run, stderr}; + +/// A config declaring one redirect row whose remedy is `mutation`. +fn config_with(mutation: &str) -> String { + format!( + r#"version = 1 + +[[rule]] +id = "a-declared-rule" +kind = "forbid" +scope = "tree" +glob = "**/*.md" +pattern = "nothing-matches-this" +severity = "deny" + +[[redirect]] +glob = "guarded/**" +mutation = "{mutation}" +"# + ) +} + +/// (a) A remedy naming a verb the surface does not declare is reported. +/// +/// The row's own worked case: a verb that moves turns every remedy naming it +/// into a lie, silently. `show config` is the spelling CLOUD-1184's rename +/// retired, so this is the shape rather than an invented one. +#[test] +fn a_remedy_naming_an_undeclared_command_is_refused() { + let dir = Fixture::new("redirect-resolves-undeclared") + .config(&config_with("run `batten show config` instead")) + .build(); + let output = run(&dir, &["config", "show"]); + assert!( + !output.status.success(), + "a remedy naming a command the surface does not declare must not load" + ); + let text = stderr(&output); + assert!( + text.contains("redirect[guarded/**].mutation"), + "the refusal names the remedy's own key: {text}" + ); + assert!( + text.contains("show config"), + "the refusal names the unresolvable command: {text}" + ); + // AND IT IS THIS REFUSAL, not any refusal. A fixture config that merely + // failed to parse would satisfy a bare `!success` assertion, and the first + // draft of this file did exactly that — `glob` typed as an array turned three + // cases red and would have left these two green for the wrong reason. + assert!( + text.contains("not a declared command"), + "the refusal is the remedy resolver's, not a parse error: {text}" + ); + // POINTER-ONLY (rule 4): the key and the command, never the remedy prose + // that carried them. + assert!( + !text.contains("instead"), + "the refusal must not echo the remedy's prose: {text}" + ); +} + +/// (b) The anti-vacuity mirror — a declared command resolves and is silent. +/// +/// Without this a predicate that refused every `batten` invocation would pass +/// the case above, which is the arm CLOUD-418 exists for. +#[test] +fn a_remedy_naming_a_declared_command_is_clean() { + let dir = Fixture::new("redirect-resolves-declared") + .config(&config_with("run `batten config show` instead")) + .build(); + let output = run(&dir, &["config", "show"]); + assert!( + output.status.success(), + "a remedy naming a declared command must load: {}", + stderr(&output) + ); +} + +/// (b') The second object shape: a declared rule id after a declared verb. +/// +/// A gate that only knew `SURFACE` would report this as broken, which is the +/// false positive that gets a gate switched off — so the arm is asserted rather +/// than assumed from the code reading. +#[test] +fn a_remedy_naming_a_declared_rule_id_is_clean() { + let dir = Fixture::new("redirect-resolves-rule-id") + .config(&config_with("run `batten check a-declared-rule` instead")) + .build(); + let output = run(&dir, &["config", "show"]); + assert!( + output.status.success(), + "a remedy naming a declared rule id must load: {}", + stderr(&output) + ); +} + +/// And the discriminator for that arm: an UNdeclared word after a declared verb +/// is still reported. +/// +/// Without this, "accept anything after a resolved prefix" would pass the case +/// above — the rule-id arm would be decorative rather than deciding. +#[test] +fn a_remedy_naming_an_undeclared_rule_id_is_refused() { + let dir = Fixture::new("redirect-resolves-unknown-rule") + .config(&config_with("run `batten check no-such-rule` instead")) + .build(); + let output = run(&dir, &["config", "show"]); + assert!( + !output.status.success(), + "a remedy naming a rule the table does not declare must not load" + ); + let text = stderr(&output); + assert!( + text.contains("no-such-rule"), + "the refusal names the unresolvable word: {text}" + ); + assert!( + text.contains("neither a declared rule id"), + "the refusal is the rule-id arm's, not a parse error: {text}" + ); +} + +/// A `` operand is judged by neither authority. +/// +/// What a verb's operands may be is a per-verb arity question, and this row +/// reads the command surface and the rule table rather than a third one. +#[test] +fn a_placeholder_operand_is_not_judged() { + let dir = Fixture::new("redirect-resolves-placeholder") + .config(&config_with("run `batten capture show ` instead")) + .build(); + let output = run(&dir, &["config", "show"]); + assert!( + output.status.success(), + "a placeholder operand must not be resolved against anything: {}", + stderr(&output) + ); +} + +/// (c) A remedy naming a non-batten command is left alone. +/// +/// That is the operator's PATH, and resolving it needs a second authority over +/// what is installed — which is `policy/verdict-routes-resolve.rego`'s `mise +/// run` arm and not this one. +#[test] +fn a_remedy_naming_a_non_batten_command_is_not_judged() { + for remedy in [ + "run `mise run land` instead", + "use `git restore` to put it back", + ] { + let dir = Fixture::new("redirect-resolves-foreign") + .config(&config_with(remedy)) + .build(); + let output = run(&dir, &["config", "show"]); + assert!( + output.status.success(), + "a non-batten remedy must not be judged here: {}", + stderr(&output) + ); + } +} + +/// A flag is judged by neither authority. +/// +/// Which flags a verb takes is `SURFACE`'s own declaration and `clap`'s to +/// enforce at the call; resolving one here would be a third reading of it. +#[test] +fn a_flag_is_not_judged() { + let dir = Fixture::new("redirect-resolves-flag") + .config(&config_with("run `batten config show --json` instead")) + .build(); + let output = run(&dir, &["config", "show"]); + assert!( + output.status.success(), + "a flag must not be resolved against the rule table: {}", + stderr(&output) + ); +} + +/// THE BOUND, ASSERTED RATHER THAN LEFT TO THE CODE READING: an invocation is a +/// code span, so bare prose naming the binary is not one. +/// +/// This is the arm that under-denies, and it is the sanctioned direction. The +/// first version of this gate had no such bound: it collected every following +/// token that looked like a subcommand word, so `run `batten capture show +/// ` instead` was read as a four-word invocation ending in `instead`, +/// and `instead` was reported as an undeclared rule id. A finding invented out +/// of English is the false positive that gets a gate switched off, and it turned +/// three cases in this file red before it was bounded. +/// +/// Both halves are asserted here, because the sentence in `invocation`'s doc +/// comment is a claim about behaviour and a claim about behaviour needs a case. +#[test] +fn an_invocation_is_a_code_span_and_bare_prose_is_not_one() { + // No code span at all: nothing is claimed, however the sentence reads. + let prose = Fixture::new("redirect-resolves-prose") + .config(&config_with( + "this is refused before batten writes anything", + )) + .build(); + assert!( + run(&prose, &["config", "show"]).status.success(), + "prose after the word `batten` is not a subcommand path" + ); + // And the under-deny stated outright: the same undeclared verb that IS + // refused inside a span is not refused outside one. Recorded so a later + // reader tightening this knows exactly which case they are changing. + let bare = Fixture::new("redirect-resolves-bare") + .config(&config_with("run batten show config instead")) + .build(); + assert!( + run(&bare, &["config", "show"]).status.success(), + "a command named in bare prose is deliberately not judged" + ); +} + +/// (d) THE ANTI-VACUITY MIRROR THAT MATTERS: this repository's own committed +/// table loads. +/// +/// A gate whose first firing is a false positive gets an exception written for +/// it, and the exception is what rots. `config show` over the real root is the +/// cheapest whole-table assertion available, and it is the one that would go red +/// if the predicate were tightened past what an author actually writes. +#[test] +fn the_committed_table_loads() { + let root = crate::common::at_root("."); + let output = run(&root, &["config", "show"]); + assert!( + output.status.success(), + "the committed remedy table must resolve: {}", + stderr(&output) + ); +} From 707ab41a374fb776fdd199bd09b4f6409808b3b9 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 19:56:59 +0000 Subject: [PATCH 20/20] fix(trust): a verdict rename is not a hatch newly added MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-1308. `verdict_override_entries` collected the id of every class declaring an `override` route and `weakenings` reported the set difference, so a class renamed with its override untouched left the old id absent and the new one present — and the new one was reported as a hatch newly added. The removal side is deliberately unreported (deleting a class is fail-closed, since a module raising an undeclared token fails the load), so nothing cancelled it out. Measured on this branch, which renames every class in the registry: two of 105 rows carry an override and both were reported. `V-PROSE-ONLY-DIFF` -> `diff ship early` and `V-FILED-OVER-OWN-DIFF` -> `issue file same`, route and precondition byte-identical to main's. THE KEY IS THE PRECONDITION RATHER THAN THE ID, and that is the same question this kind already asks. `VerdictOverrideAdded` refuses to judge whether one precondition is looser than another and still does — nothing here compares two conditions for strength. It compares them for IDENTITY, which is what tells a hatch that moved from a hatch that is new: a hatch IS the condition it states, so a class whose override states a precondition some base class also stated is that hatch relocated, whatever either is called. The discriminating pair is the whole case, and the second half is what keeps the fix from becoming a blanket allow: a rename with an unchanged precondition is silent, and a rename that ALSO rewrites the precondition is still reported. Without the second, "never report a class whose id is new" would pass the first and switch the kind off. WITHDRAWN BY THIS COMMIT: the two `Weakens:` trailers on cc49e0ea and the two matching clauses on CLOUD-1284's Ready block. `config lint` against origin/main now reports 0 smells rather than 2 admitted, so both admissions record a weakening that did not happen and neither is needed. They are left in that commit's message rather than rebased out — the history is 68 commits deep and the trailers are inert once nothing raises the smell — and the clauses are removed from the row, which is where a future reader looks. HOW THIS WAS FOUND, because the route matters more than the defect: it was filed as CLOUD-1308 rather than fixed, and `filed-over-own-diff` refused that — correctly. Its first declared route is "close the row you filed and fix it in this diff", and the fix is this file rather than the ~105 tombstone rows the filed row priced it at. Refs: CLOUD-1308 --- crates/batten/src/trust.rs | 126 +++++++++++++++++--- crates/batten/tests/it/redirect_resolves.rs | 4 +- 2 files changed, 113 insertions(+), 17 deletions(-) diff --git a/crates/batten/src/trust.rs b/crates/batten/src/trust.rs index 386e40392..468724279 100644 --- a/crates/batten/src/trust.rs +++ b/crates/batten/src/trust.rs @@ -1665,18 +1665,55 @@ fn entry_weakenings(base: &Config, working: &Config) -> Vec { // The refusal vocabulary (CLOUD-1050). Only the ADDED direction, and only // the override route: see `VerdictOverrideAdded` for why removal is // fail-closed and why rewording is not policy-bearing. + // + // A RENAME IS NOT A HATCH (CLOUD-1308). The comparison was keyed on the class + // id alone, so a class renamed with its override untouched left the old id + // absent and the new one present, and the new one was reported as a hatch + // newly added. The removal side is deliberately unreported — deleting a class + // is fail-closed, since a module raising an undeclared token fails the load — + // so nothing in the comparison cancelled it out. + // + // Measured on CLOUD-1284's own branch, which renames every class in the + // registry: two of 105 rows carry an override, and both were reported. + // `V-PROSE-ONLY-DIFF` → `diff ship early` and `V-FILED-OVER-OWN-DIFF` → + // `issue file same`, route and precondition byte-identical to the base's. + // + // The key is the PRECONDITION rather than the id, and that is the same + // question this kind already asks rather than a new one. `VerdictOverrideAdded` + // records that whether one precondition is looser than another is the + // judgement this module refuses to make — but whether a class went from + // having no hatch to having one is a predicate, and a hatch is IDENTIFIED by + // the condition it states. So a working class whose override states a + // precondition some base class also stated is that hatch relocated, whatever + // either is called. + // + // What it under-reports, stated rather than discovered: a genuinely new class + // copying a deleted class's precondition verbatim in the same commit. That is + // the same hatch under a new name by any reading a reviewer would give it, and + // it is the narrow direction — a class that opens a hatch nobody had before + // has no base precondition to match and is still reported, which is the case + // the kind exists for. { + let base_entries = verdict_override_conditions(base); // Rendered key paths, because `added_entries` takes them already // rendered — see its own note for why. - let render = |set: BTreeSet| -> Vec { - set.into_iter() - .map(|id| format!("verdict[{id}].override")) + let render = |entries: &[(String, String)]| -> Vec { + entries + .iter() + .map(|(id, _)| format!("verdict[{id}].override")) .collect() }; + // The base side keeps every entry: a hatch that survived under its own + // name is not "added" either way, and narrowing both sides would compare + // two filtered sets and answer a third question. + let unmatched: Vec<(String, String)> = verdict_override_conditions(working) + .into_iter() + .filter(|(_, condition)| !base_entries.iter().any(|(_, prior)| prior == condition)) + .collect(); found.extend(added_entries( WeakeningKind::VerdictOverrideAdded, - &render(verdict_override_entries(base)), - &render(verdict_override_entries(working)), + &render(&base_entries), + &render(&unmatched), )); } @@ -1912,23 +1949,43 @@ fn pattern_entries(config: &Config) -> Vec { config.patterns.iter().map(|row| row.id.clone()).collect() } -/// Every `[[verdict]]` class that declares an `override` route (CLOUD-1050). +/// Every `[[verdict]]` class that declares an `override` route, paired with the +/// **precondition that route states** (CLOUD-1050, keyed by condition since +/// CLOUD-1308). /// -/// The id alone, because that is the object the comparison decides over: a class -/// either offers a hatch or it does not, and which precondition it states is the -/// judgement `VerdictOverrideAdded` records this module refusing to make. -fn verdict_override_entries(config: &Config) -> BTreeSet { - config +/// The id is what the finding POINTS AT and the precondition is what the +/// comparison keys on, and the split is the whole of CLOUD-1308's fix. Keyed on +/// the id alone, a renamed class read as a hatch newly added — the old id absent, +/// the new one present, and the removal side deliberately unreported. +/// +/// This does not start judging whether one precondition is looser than another; +/// that is still the judgement `VerdictOverrideAdded` refuses to make, and +/// nothing here compares two conditions for strength. It compares them for +/// IDENTITY, which is what tells a hatch that moved from a hatch that is new. +/// +/// Byte-sorted by id so the reported order is stable (§6), and a class declaring +/// two override routes contributes each — `verdict::validate` refuses a class +/// whose only route is an override, never one with two. +fn verdict_override_conditions(config: &Config) -> Vec<(String, String)> { + let mut found: Vec<(String, String)> = config .verdicts .iter() - .filter(|entry| { + .flat_map(|entry| { entry .routes .iter() - .any(|route| route.kind == crate::verdict::RouteKind::Override) + .filter(|route| route.kind == crate::verdict::RouteKind::Override) + .map(move |route| { + ( + entry.id.clone(), + route.precondition.clone().unwrap_or_default(), + ) + }) }) - .map(|entry| entry.id.clone()) - .collect() + .collect(); + found.sort(); + found.dedup(); + found } /// The ids of a table, collected so [`removed_entries`] can compare them. @@ -3180,6 +3237,45 @@ mod tests { assert!(weakenings(&base, &base).is_empty()); } + /// A RENAME IS NOT A HATCH, and the pair is the whole case (CLOUD-1308). + /// + /// Keyed on the class id, a rename left the old id absent and the new one + /// present, and the removal side is deliberately unreported — so the new name + /// was reported as a hatch newly added. Measured on CLOUD-1284's own branch, + /// which renames every class in the registry: two of 105 rows carry an + /// override and both were reported, and both were then admitted with a + /// `Weakens:` trailer recording a weakening that had not happened. + /// + /// **The second half is what keeps the fix from becoming a blanket allow.** + /// Without it, "never report a class whose id is new" would pass the first + /// assertion and switch the kind off entirely. + #[test] + fn a_renamed_class_keeps_its_hatch_and_a_new_condition_is_still_reported() { + let base = config(&verdict_row("a class probe", true)); + // Renamed, override untouched: the same hatch under a new name. + let renamed = config(&verdict_row("some class probe", true)); + assert!( + weakenings(&base, &renamed).is_empty(), + "a rename with an unchanged precondition is not a hatch newly added" + ); + // Renamed AND the condition rewritten: a hatch nobody had before, which + // is exactly the case this kind exists for. Reported. + let widened = config(&verdict_row("some class probe", true).replace( + "you can state why the gate should not stand here", + "any reason at all", + )); + assert_eq!( + only(&base, &widened), + Weakening::new( + WeakeningKind::VerdictOverrideAdded, + "verdict[some class probe].override", + "absent", + "present", + ), + "a rename may not smuggle a new precondition through" + ); + } + /// The two edits that are NOT weakenings, asserted so the narrow reading is /// a property of the code rather than of the doc comment above it. #[test] diff --git a/crates/batten/tests/it/redirect_resolves.rs b/crates/batten/tests/it/redirect_resolves.rs index 1b123da1d..4afc085cf 100644 --- a/crates/batten/tests/it/redirect_resolves.rs +++ b/crates/batten/tests/it/redirect_resolves.rs @@ -125,8 +125,8 @@ fn a_remedy_naming_a_declared_rule_id_is_clean() { ); } -/// And the discriminator for that arm: an UNdeclared word after a declared verb -/// is still reported. +/// And the discriminator for that arm: a word the rule table does NOT declare, +/// after a declared verb, is still reported. /// /// Without this, "accept anything after a resolved prefix" would pass the case /// above — the rule-id arm would be decorative rather than deciding.