diff --git a/.batten/asked.jsonl b/.batten/asked.jsonl index cd1bf55a9..6bb36147f 100644 --- a/.batten/asked.jsonl +++ b/.batten/asked.jsonl @@ -6,3 +6,5 @@ {"question":"Is the family above the set you meant, and what should happen next?","options":[{"label":"Fix the board state","description":"Check CLOUD-2037, CLOUD-2017 and CLOUD-122 against the tree, and move any with no PR behind it back to Backlog with a comment."},{"label":"Pick up the open rows","description":"Plan a branch that works through the open error and advisory prose rows, starting with CLOUD-2037, CLOUD-2017 and CLOUD-1327."},{"label":"Different batch","description":"I meant specific tickets filed together. I'll give a key or a phrase from one of them."}],"answer":"Fix the board state","at":1791005849,"head":"4ed344a3941cf1e9bcc37918eedf555f6c119986"} {"question":"Dispatch two sessions, one PR each: Bundle A brief:43e89098ea7db7ba6b3d7acf3326c250067b7faf6dd3d1f1fe39baf4b4a6a666 (hook.rs/refusal.rs/lib.rs: CLOUD-1728, 1826, 1996, 1806, 1893, 1470, 2075, 2078, 1583, 2079) and Bundle B brief:79ee5e255f39abeaf60752e5f347a6fde1e7863e1ce0aed48c0a4d2a74feed41 (config caps and commit-msg classes: CLOUD-1960, 1642, 1643)?","options":[{"label":"Approve dispatch","description":"Start both sessions with these exact briefs. Each claims its rows in order, commits once per row, and lands one PR."},{"label":"Revise briefs","description":"Hold the dispatch; tell me what to change."}],"answer":"Approve dispatch","at":1791029074,"head":"4ed344a3941cf1e9bcc37918eedf555f6c119986"} {"question":"Which permission mode should the two child sessions start in?","options":[{"label":"Auto (Recommended)","description":"They build and land unattended; the PRs and board are the record."},{"label":"Plan","description":"Each child parks for your approval in the web UI before building each row."}],"answer":"Auto (Recommended)","at":1791029074,"head":"4ed344a3941cf1e9bcc37918eedf555f6c119986"} +{"question":"Admit the `[refusal]` ceiling raise? Gate `config-lint` refuses `batten.toml:refusal.max_tokens refusal-ceiling-raised` and `batten.toml:refusal.first_sighting_max_tokens refusal-ceiling-raised`: raising a committed per-line ceiling is a weakening, admitted only by your recorded answer. Why it should not stand: CLOUD-2075's decided mechanism puts labels, every route and the override request on every firing, and no renderer reads the ceiling any more — it only reports. The values (24→96, 64→176) are the measured widest lines, rounded up to a multiple of 16, as the row specifies. Cost if wrong: each refusal line costs up to ~4x the old context per firing (worst measured pointer 84 tokens, full arm 159). I caused this by changing the renderer.","options":[{"label":"Admit the raise","description":"Record the admission; the ceilings stay at 96 and 176 and land proceeds."},{"label":"Refuse","description":"Do not admit; CLOUD-2075 does not land until the ceiling is redesigned."}],"answer":"Admit the raise","at":1791043777,"head":"2f23e7fd7e442fdacae28ce805fdcc3993947e8f"} +{"question":"Admit `refusal-ceiling-raised refusal.max_tokens` and `refusal-ceiling-raised refusal.first_sighting_max_tokens`? (Re-asked: my previous question spelled these pairs in the wrong order, so the ledger could not match your answer to them. Nothing else changed.) Gate `config-lint` reports the [refusal] ceilings raised from 24 to 96 and 64 to 176 as weakenings. Why the raise should stand: CLOUD-2075 puts labels, every route and the override request on every firing; no renderer reads these keys any more, they only report; the values are the measured widest lines (pointer 84, full 159 estimated tokens) rounded up to a multiple of 16. Cost if wrong: each refusal line costs up to ~4x the old context per firing.","options":[{"label":"Admit the raise","description":"Record the admission for both pairs; land proceeds with the ceilings at 96 and 176."},{"label":"Refuse","description":"Do not admit; CLOUD-2075 does not land until the ceilings are redesigned."}],"answer":"Admit the raise","at":1791044811,"head":"2f23e7fd7e442fdacae28ce805fdcc3993947e8f"} diff --git a/.serena/memories/core.md b/.serena/memories/core.md index d069855f0..b3f051332 100644 --- a/.serena/memories/core.md +++ b/.serena/memories/core.md @@ -31,6 +31,9 @@ Read on demand, never all of them. - `mem:workflow/sonar-scope` — Sonar refuses your branch; reading or changing `sonar-gate`; a Sonar verdict looks wrong on a SHA; before treating a `final` failure as trunk's, or a check-run's annotations as the whole finding list. +- `mem:workflow/container-is-yours` — **every call is refused** (an engine/pin + skew, `engine-cannot-adjudicate`), or about to tell the human to run, install + or restart anything: no human can reach this container, and the exits are yours. - `mem:session-transcript-access` — asked to read chat history or another session; before probing a session API or credential. - `mem:github-access` — any GitHub op; before claiming the toolchain or CI diff --git a/.serena/memories/workflow/container-is-yours.md b/.serena/memories/workflow/container-is-yours.md new file mode 100644 index 000000000..9b9646052 --- /dev/null +++ b/.serena/memories/workflow/container-is-yours.md @@ -0,0 +1,44 @@ +# The container is yours alone + +**No human can run a command in this container, open a shell in it, or restart +its session for you.** The operator sees chat and the PR; they cannot reach the +filesystem, the binaries or the processes. So a step that needs a command is +yours whatever state the container is in. Telling the human to run something +here, or to "restart the session", hands back the one kind of step nobody else +can take. It is the purest form of AGENTS.md's punt, and `turn ask early` +(`policy/turn-ask.rego`) names it (CLOUD-2117). + +## Measured + +PR #1102, 2026-10-05: `land` replayed onto a `main` whose `batten.toml` pinned a +newer engine than the installed one. The hook refused every call +`engine-cannot-adjudicate`, including the `./install.sh` its own remedy named. +Two final messages then told the operator to run the install or restart the +session. The operator caught both; the gate was silent on both. + +## When every call is refused, the exits are still yours + +Work down this list; never stop at "blocked" and never hand it over. + +1. **Read the refusal's own Fix clause.** The unloadable-config floor + (`recoverable_without_rules`, `crates/batten/src/lib.rs`) always admits a + `Read`, an inert search (`Grep`/`Glob`), and an `Edit`/`Write` of + `batten.toml` or `batten.local.toml`. +2. **A pin newer than the binary: run `batten engine update`, spelled whole.** + Since CLOUD-2116 the floor admits exactly that command, and the skew refusal + names it. +3. **Before that fix is installed, the pin line itself is the exit.** Edit the + pin to the build the hook is running, so the engine loads for one step; do + the repair (install the tree's build, replay onto `main`); then edit the pin + line back so no commit carries it. Measured 2026-10-07, and two traps on it: + - **A non-hook `batten` self-updates to the pin at startup** (CLOUD-2063). + While the pin is edited down, any task that runs `batten` downgrades + `~/.local/bin/batten` to that pin. Install the build you want LAST, after + every task that ran under the edited pin. + - **Do the shell steps in one command.** Each command is judged before it + runs, so `git stash && batten land replay main && git stash pop` passes on + one load; a later command meets whatever pin the tree then holds. +4. **A config fault: repair the file with the admitted `Edit`**, then carry on. +5. If none of these opens a route, the gate is wrongly refusing: repair it + (AGENTS.md) and file it. Say what is blocked and what you are doing about it, + never what the human should do. diff --git a/.serena/memories/workflow/landing-loop.md b/.serena/memories/workflow/landing-loop.md index 8cc082e35..acd65bd00 100644 --- a/.serena/memories/workflow/landing-loop.md +++ b/.serena/memories/workflow/landing-loop.md @@ -190,7 +190,8 @@ exist in no other clone, so `history-drop` reads the reset as discarding work an stops it. Measured 2026-09-19, and the class's own override did not open it: a `history drop unpushed` admission was requested, answered and spent TWICE — once against the commit sha, once against the subject its refusal line prints — and -the reset was refused unchanged after each. CLOUD-1871 owns that gap. The rebase +the reset was refused unchanged after each. Fixed by CLOUD-1826: paste the +pointers the refusal prints (`1 `), not the sha alone. The rebase went through on the first try. Take the second anyway, on the rare tree where the guard lets it, and two things diff --git a/AGENTS.md b/AGENTS.md index c7ef40475..e4524f967 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,14 +45,14 @@ decision; the change is outside the scope you were asked — the gates authorize STEPS of agreed work, never whether it is agreed (CLOUD-431, bypass `BATTEN_CLAIM_CHECK_BYPASS`); or an action is destructive and _not_ gated (force-pushing `main`, deleting history, an out-of-band release); a lease-push or -harness denial never is. **Each stops the -fix, never the record**: what you decline to fix, you file. **A WRONGLY refusing -gate is a defect, not an answer** — repair it and carry on this session; ticketing -one is a punt in gate's clothing (CLOUD-597/615). **A punt is any deferral you -could have closed**, a predicate not a list: a block reported as a decision (a -block is a bug); "that's your call" on what your evidence settles; an authorized -action offered, or handed to a human to run; an unbuilt mechanism awaited over -the instance in hand; your own landed work spared. Can do it, do it; can't, file it. +harness denial never is. **Each stops the fix, never the record**: what you +decline to fix, you file. **A WRONGLY refusing gate is a defect, not an answer** — +repair it this session; ticketing one is a punt in gate's clothing +(CLOUD-597/615). **A punt is any deferral you could have closed**, a predicate not +a list: a block reported as a decision (a block is a bug); "your call" on what your +evidence settles; an action offered, or handed to a human — **no human can run a +command in your container** (CLOUD-2117); a mechanism awaited over the instance in +hand; your landed work spared. Can do it, do it; can't, file it. **An override ask is ONE yes/no on the override, never a menu of routes** (CLOUD-680). It carries the refusing gate and its verdict string, what the gate diff --git a/batten.toml b/batten.toml index a7444c96c..cb5a5f03c 100644 --- a/batten.toml +++ b/batten.toml @@ -601,7 +601,8 @@ sha256 = "b2c822742e8cbf355ba0cb4cc690c3cd8fdc9ec1916c8148f27bd9098cb7aee4" # # `reason` is required on a shape rule, unlike on a file rule: a mediated deny # reaches a model as the entire explanation, so a refusal that named only its id -# would be un-actionable (CLOUD-122). The crate appends the bypass hatch. +# would be un-actionable (CLOUD-122). It is the row's remedy, which the refusal +# reaches through `batten policy rule ''`. # THESE FOUR WOULD DECLARE `bypass_env = "BATTEN_GH_GUARD_BYPASS"` AND DO NOT YET # (CLOUD-437, deferred to CLOUD-1027). They are the ported `gh-guard`, so that # name is TRUE of them where it was a fossil everywhere else, and declaring it @@ -616,12 +617,11 @@ sha256 = "b2c822742e8cbf355ba0cb4cc690c3cd8fdc9ec1916c8148f27bd9098cb7aee4" # one. Asserting it in the change that performs it is the exact shape §8 refuses. # # So until that row is groomed, these four declare no per-row hatch. That is a -# statement about the column, NOT a remedy: the residual global hook hatch still -# technically reaches them only because their class, `call name refused`, -# declares no override route yet (CLOUD-1806), and CLOUD-1357 makes every class -# that DOES declare one unsuppressible by it. A reader looking for the way past a -# refusal reads the class's routes with `batten policy explain ""` and, -# where one is an override, walks `batten override request`/`spend`. This comment +# statement about the column, NOT a remedy: their class, `call name refused`, +# declares an override route (CLOUD-1806). A reader looking for the way past a +# refusal reads the row's remedy with `batten policy rule ''`, and where that +# remedy cannot perform the change, walks `batten override request`/`spend` — +# the recorded way through, bound to the row id at the current commit. This comment # used to say these rows "take the general" hatch "like every other row", which an # agent read as advice and proposed for a class it could not have suppressed. [[rule]] @@ -2047,9 +2047,18 @@ regex = "(?i)worth (noting|flagging|mentioning|naming)|one thing (I would|I['’ # and `would you like me to` fired 0 times and stay OUT — an unwitnessed # literal is the invention CLOUD-323 forbids. CASE-SENSITIVE `Your call,` on # purpose: sentence-initial is the offer; mid-sentence was the report. +# +# THE CONTAINER'S OWN STEP, HANDED OVER (CLOUD-2117). The container is the +# agent's alone and no human can run a command in it, so a turn telling the +# human to run one or to restart the session hands back the one kind of step +# nobody else can take. Witnessed twice on PR #1102, 2026-10-05, both silent +# here: "Once you run `…` or restart the session, I'll push" and "To unblock, +# either: run `…`, or restart the session". Both literals are those sentences' +# own words; `restart the session` cannot be the agent's own act, which is why +# it needs no pronoun. [[pattern]] id = "deferral-offered" -regex = "Your call, |(?i:waiting on your call|both your call|a checkpoint with you)" +regex = "Your call, |(?i:waiting on your call|both your call|a checkpoint with you|once you run|restart the session)" # CLOUD-323's two measured deferral shapes in a PR body, which `record derive # deferral --input shape=deferral-shape` matches per paragraph outside code @@ -4823,6 +4832,31 @@ severity = "deny" scope = "tree" no_fix_reason = "class 3 (a port behind `batten`): the installer installs the binary and nothing else; a toolchain, a package manager or a shell profile belongs behind `batten` once it is on PATH — a `[[provision]]` row or a `[[startup]]` row, both of which a gate can read" +# AND WHO MAY CALL IT, the half the row above never held (CLOUD-2123). The +# installer's ONE job is putting a `batten` on a host that has none; once one +# exists, every install, update and repair goes through the binary +# (`batten engine update`) or a tool it provisioned. `session:batten` called +# `./install.sh` on every session start over a binary already on PATH, fetching +# the LATEST release, and a pin naming any other build then refused every call — +# measured twice on PR #1102 (2026-10-05, 2026-10-07). +# +# THE PREDICATE IS ANY CALL FROM A TASK, because no task can be the bootstrap: +# `mise` is itself a `[[provision]]` row batten installs, so a host running a +# `mise` task already has a binary. A call is the installer inside a quoted +# string — a `run` value or an argv entry; prose names it in backticks, which +# this does not read. CI runners start with no binary, so the workflows' installs +# are genuine bootstraps and sit outside this glob. +#MUTANT-SUITE crates/batten/tests/it/program_run_loose.rs +#MUTANT argv-call-unread|s@^regex = '"\[^"`.*$@regex = 'run\\s*=\\s*"\\./install\\.sh'@|a_task_calling_the_installer_is_refused_and_prose_is_not +[[rule]] +id = "program run loose" +kind = "forbid" +glob = "mise.toml" +regex = '"[^"`\n]*\./install\.sh' +severity = "deny" +scope = "tree" +no_fix_reason = "class 3 (a port behind `batten`): which bootstrap guard a task needs is a judgement about the host it runs on; once a binary exists the route is `batten engine update`" + # A GRANT THAT MATCHES NOTHING IS THE DEAD-GATE CLASS, ONE SURFACE OVER, AND ITS # ONLY SYMPTOM IS A PERSON BEING ASKED (CLOUD-1455). # @@ -5700,64 +5734,41 @@ files = [".serena/memories/*.md", ".serena/memories/**/*.md"] max_bytes_per_file = 40960 max_tokens = 110000 -# What ONE emitted mediated refusal line may cost (CLOUD-1286). +# What ONE emitted mediated refusal line may cost (CLOUD-1286, re-declared by +# CLOUD-2075). # -# 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. +# This bounds the POINTER arm — what every firing after the first in a context +# emits. CLOUD-2075 put both labels, the subjects, every route (override routes +# included, as the ready request that admits) and the `policy rule` hop on that +# arm, because a pointer a ruling needs is never shed. So the number is the +# measured widest pointer the tree emits, rounded up to a multiple of 16, and +# nothing reads it at runtime: `refusal_ceiling.rs`'s corpus case REPORTS an +# over-ceiling line rather than any renderer truncating one. Measured 2026-10-03 +# with `every_arm_the_corpus_emits_is_within_its_declared_ceiling` and the +# routed-read and render-bench cases (estimated tokens, `budget.rs`'s len/4): # -# 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). +# 84 pointer branch write unsafe (render bench, two routes + override) +# 78 pointer path read routed (longest committed memory name) +# 49 pointer the Bash corpus (`tool run loose`) # # Declared here rather than as a constant in `crates/batten` for the reason # non-negotiable rule 1 gives: a consumer whose harness renders wider cannot move # a number compiled into the engine. [refusal] -max_tokens = 24 -# What ONE FIRST SIGHTING may cost (CLOUD-1637). -# -# The key above prices prose a reader has already met. This prices the one firing -# where they have not: the same line plus the class gloss and every non-override -# route, which is what turns a three-word pointer into something actionable. Two -# keys rather than one number doing both jobs, because the quantities differ by -# design — and bounding the long arm by the short arm's 24 would be a ceiling no -# line that arm can compose could ever meet, which `refusal::validate` now refuses -# outright rather than leaving as an arm that silently never renders. -# -# 64 IS READ OFF A MEASUREMENT PLUS THE DECLARED MAXIMA, not chosen. Fired in a -# fixture whose sightings store started empty, 2026-09-08, one command per -# distinct refusing row (estimated tokens, `budget.rs`'s len/4): -# -# 36 verdict read dropped verdict-not-discarded ... read rules/toolchain.md -# 35 tool run loose no-tool-substitution ... read rules/scanning.md -# 34 trunk push forced no-force-push ... run git push --force-with-lease -# 28 call name refused no-bare-cargo ... read batten.toml -# -# So the tree emits 36 at its widest today. The headroom above that is not slack: -# `GLOSS_MAX` admits a 120-character gloss, which is ~30 estimated tokens on its -# own, and a class may declare several routes. 64 clears every line this tree can -# spell while still catching a route list that has run away — the only thing this -# ceiling sheds, since the gloss is undroppable and the first route is the floor. -# A ceiling that fired on correct output would be switched off by the first person -# it fired on (CLOUD-418), and `refusal_ceiling.rs` asserts the corpus stays under -# it in the same direction the repeat arm's anti-vacuity case does. -# -# NOT AN `[epoch]` FLOOR BUMP, and that is a decision rather than an omission. -# `min_batten_version` is compared against the RUNNING build, which in this -# repository is the one built from this tree, so naming the next release makes the -# repository refuse its own config for the whole life of the PR (measured -# 2026-08-11: floor 0.0.62 against a 0.0.61 build, exit 1). The floor can only -# name a version that already exists, so the raise belongs at the release. What -# `[epoch]` actually wants — an older binary REFUSING this key rather than -# ignoring it — `deny_unknown_fields` on `[refusal]` already delivers. -first_sighting_max_tokens = 64 +max_tokens = 96 +# What ONE FULL ARM may cost (CLOUD-1637, re-declared by CLOUD-2075). +# +# The full arm is the pointer arm plus ` — ` and the definition: the class gloss +# (or an undeclared row's reason), the ROW'S OWN REMEDY, each override's +# precondition and the `policy explain` hop. It is delivered once per context per +# compaction cycle. Measured 2026-10-03 the same way as the key above: +# +# 159 full tool run loose / tool select other (the row's reason is the bulk) +# +# 176 is that rounded up to a multiple of 16. `refusal::validate` still refuses a +# value at or below `max_tokens`. NOT AN `[epoch]` FLOOR BUMP, for the reason the +# history of this key gives: the floor can only name a release that exists. +first_sighting_max_tokens = 176 # What the whole advisory CHANNEL may cost on one boundary (CLOUD-896). # @@ -5851,6 +5862,13 @@ family = "unix" # `mise run hook-cost`, which is why the figure ships as a command rather than as # a number in an issue body. # +# READ PER COMPACTION CYCLE (CLOUD-2075). A finding's full arm is delivered once +# per context per cycle, and a `SessionStart` opens the next cycle — so +# `hookcost::measure` counts repeats per `SessionStart`-bounded segment, judges a +# labelled finding by its key rather than by the bytes around it, and never counts +# a pointer arm: pointing at the first copy is what a repeat is supposed to do. +# Unlabelled output keeps the per-segment `(hook, digest)` reading. +# # Declared here rather than in `crates/batten` for non-negotiable rule 1's # reason: how loud a repository's hooks may be is a property of that repository's # hooks, and a consumer cannot move a number compiled into the engine. @@ -8251,6 +8269,19 @@ severity = "warn" id = "unanswered-human-calls" count = "unanswered-human-calls" +# A code-host call is handed the memory documenting this host's GitHub access +# (CLOUD-1470). Measured twice: 2026-08-31 an `add_repo` detour the memory says is +# blocked (CLOUD-1259), and 2026-09-05 six turns spent telling the owner the +# lander could not run before `mise run land` drove the loop first try. `warn`, +# so the call is allowed and the pointer rides it on the advisory channel. +[[rule]] +id = "forge read first" +no_fix_reason = "class 3 (a call rewrite): the subject is a tool call refused before it runs, so nothing persists for a command to repair; the refusal's remedy text is the route" +kind = "policy" +scope = "mediated_call" +module = "policy/forge-read-first.rego" +severity = "warn" + # A weaker spelling of a task this project already defines (CLOUD-856, the # successor shape for `run-shape-guard`'s `cargo-substitutes-for-a-task`). # @@ -13397,6 +13428,16 @@ id = "module read first" kind = "document" target = "policy/answer-the-operator.rego" [[verdict]] +id = "forge read first" +gloss = "a code-host call; read the memory documenting this host's GitHub access first" +class = """ +A call reaching the code host was made with the memory documenting this host's GitHub access unread at the moment it mattered: on 2026-08-31 a session took an `add_repo` detour that memory says is blocked, and on 2026-09-05 one spent six turns claiming the lander could not run before `mise run land` drove the loop first try. The pointer rides the allowed call; the remedy is to read the memory before concluding the host is unreachable. +""" +[[verdict.route]] +id = "memory read first" +kind = "document" +target = ".serena/memories/github-access.md" +[[verdict]] id = "forge check red" gloss = "the forge judged this commit and its fan-in check did not pass" class = """ diff --git a/bench/refusal-render/RESULTS.md b/bench/refusal-render/RESULTS.md index 56f8bb31e..b6a125294 100644 --- a/bench/refusal-render/RESULTS.md +++ b/bench/refusal-render/RESULTS.md @@ -8,34 +8,34 @@ Generated by `mise run refusal-render-bench`. A REPORT, never a gate: no task in Characters are what is emitted. The token column is `budget::estimate_tokens`, the estimator `policy-budget` already holds instruction files to, rather than a new approximation minted for this table. -**The declared `[refusal] max_tokens` is 24, and on this tree it withholds NOTHING**: every emitted figure below equals its unbounded one, so the ceiling is declared and inert over these classes rather than shaping the numbers. The `unbounded` columns are kept because that is a fact about today's registry, not a property of the bound. +**Nothing is shed.** No renderer takes a ceiling: `[refusal]`'s keys are measured by the corpus case and report an over-ceiling line rather than truncating one, so every figure below is the whole arm. ## `branch write unsafe` (rule `leased-push`) -| strategy | residency | first sighting | emitted characters | emitted tokens | unbounded characters | unbounded tokens | -| ---------------------- | --------- | -------------- | ------------------ | -------------- | -------------------- | ---------------- | -| `Current` | `Cold` | true | 243 | 61 | 243 | 61 | -| `Current` | `Warm` | false | 31 | 7 | 31 | 7 | -| `FullEveryTime` | `Cold` | true | 243 | 61 | 243 | 61 | -| `FullEveryTime` | `Warm` | true | 243 | 61 | 243 | 61 | -| `FirstFullThenCompact` | `Cold` | true | 243 | 61 | 243 | 61 | -| `FirstFullThenCompact` | `Warm` | false | 31 | 7 | 31 | 7 | +| strategy | residency | first sighting | emitted characters | emitted tokens | +| ---------------------- | --------- | -------------- | ------------------ | -------------- | +| `Current` | `Cold` | true | 654 | 164 | +| `Current` | `Warm` | false | 339 | 84 | +| `FullEveryTime` | `Cold` | true | 654 | 164 | +| `FullEveryTime` | `Warm` | true | 654 | 164 | +| `FirstFullThenCompact` | `Cold` | true | 654 | 164 | +| `FirstFullThenCompact` | `Warm` | false | 339 | 84 | ## `tool run loose` (rule `no-tool-substitution`) -| strategy | residency | first sighting | emitted characters | emitted tokens | unbounded characters | unbounded tokens | -| ---------------------- | --------- | -------------- | ------------------ | -------------- | -------------------- | ---------------- | -| `Current` | `Cold` | true | 123 | 31 | 123 | 31 | -| `Current` | `Warm` | false | 35 | 8 | 35 | 8 | -| `FullEveryTime` | `Cold` | true | 123 | 31 | 123 | 31 | -| `FullEveryTime` | `Warm` | true | 123 | 31 | 123 | 31 | -| `FirstFullThenCompact` | `Cold` | true | 123 | 31 | 123 | 31 | -| `FirstFullThenCompact` | `Warm` | false | 35 | 8 | 35 | 8 | +| strategy | residency | first sighting | emitted characters | emitted tokens | +| ---------------------- | --------- | -------------- | ------------------ | -------------- | +| `Current` | `Cold` | true | 232 | 58 | +| `Current` | `Warm` | false | 123 | 30 | +| `FullEveryTime` | `Cold` | true | 232 | 58 | +| `FullEveryTime` | `Warm` | true | 232 | 58 | +| `FirstFullThenCompact` | `Cold` | true | 232 | 58 | +| `FirstFullThenCompact` | `Warm` | false | 123 | 30 | ## The margin -- **`branch write unsafe`** — a warm repeat emits 31 characters (7 tokens) today against 243 (61 tokens) delivered in full every time: **212 characters saved per repeat firing**. Unbounded, the same comparison is 31 against 243: **212 characters** — what a residency protocol would have to deliver, and withhold, per firing. -- **`tool run loose`** — a warm repeat emits 35 characters (8 tokens) today against 123 (31 tokens) delivered in full every time: **88 characters saved per repeat firing**. Unbounded, the same comparison is 35 against 123: **88 characters** — what a residency protocol would have to deliver, and withhold, per firing. +- **`branch write unsafe`** — a warm repeat emits 339 characters (84 tokens) today against 654 (164 tokens) delivered in full every time: **315 characters saved per repeat firing**. +- **`tool run loose`** — a warm repeat emits 123 characters (30 tokens) today against 232 (58 tokens) delivered in full every time: **109 characters saved per repeat firing**. **Every class here pays a real margin**, so a residency protocol has something to withhold on each of them: a first sighting carries the class's own explanation and a repeat carries the bare line. That was not true of every renderer this benchmark has measured — a first sighting used to append `command` routes ONLY, which left a document-route class rendering the identical line cold and warm — so the margin is a property of the renderer under measurement rather than of the strategy table. diff --git a/bench/tokens/RESULTS.md b/bench/tokens/RESULTS.md index 28433532a..5fee33d8d 100644 --- a/bench/tokens/RESULTS.md +++ b/bench/tokens/RESULTS.md @@ -43,8 +43,8 @@ count for the task is the step count above. | arm | steps | bytes | est. tokens | USD / 1k tasks (fresh) | USD / 1k tasks (cache read) | exit | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | baseline | 1 | 2705 | 677 | 1.3540 | 0.1354 | 0 | -| batten | 1 | 805 | 202 | 0.4040 | 0.0404 | 2 | -| **ratio** | | **3.36×** | **3.35×** | | | | +| batten | 1 | 945 | 237 | 0.4740 | 0.0474 | 2 | +| **ratio** | | **2.86×** | **2.86×** | | | | ### build-warning — exec output predicate diff --git a/crates/batten/examples/refusal-render-bench.rs b/crates/batten/examples/refusal-render-bench.rs index 5ed22e0e5..9a1b22ec1 100644 --- a/crates/batten/examples/refusal-render-bench.rs +++ b/crates/batten/examples/refusal-render-bench.rs @@ -35,12 +35,10 @@ fn main() -> anyhow::Result<()> { // baseline is the crate version plus these declared class ids. let config = batten::config::load(Path::new("batten.toml"))?; let registry = batten::policy::registry_for(&config.verdicts)?; - // The committed `[refusal]` ceiling travels too, because it is part of the - // shipped rendering contract: a first sighting whose routes would take the - // line over it falls back to the compact form, and a bench that passed `None` - // would report a rendering the harness never emits. - let records = refusal_render(®istry, config.refusal.as_ref())?; - let report = refusal_render_report(&records, config.refusal.as_ref()); + // No ceiling travels: no renderer takes one (CLOUD-2075), so the shipped + // rendering is the whole arm. + let records = refusal_render(®istry)?; + let report = refusal_render_report(&records); let dir = Path::new("bench/refusal-render"); std::fs::create_dir_all(dir)?; diff --git a/crates/batten/src/admission.rs b/crates/batten/src/admission.rs index 572898ffe..4dcabf8b6 100644 --- a/crates/batten/src/admission.rs +++ b/crates/batten/src/admission.rs @@ -786,21 +786,14 @@ pub fn consume( /// The subject a caller typed, in the spelling an admission binds (CLOUD-1997). /// -/// A refusal whose subjects are artifacts binds them joined by `,` -/// (`refusal::admission_subject`), while its pointer line prints them joined by -/// a space — so `turn mint ahead` refuses as `… verify commit …` and binds -/// `verify,commit`. A reader copies the line; five admissions spent that way -/// were honoured by nothing. So a subject carrying whitespace and no path -/// separator is read as the artifact list the line printed, and rejoined. -/// A path subject is returned untouched: a path is bound as itself. +/// [`crate::verdict::bound_subject`] is the one spelling function, and the +/// binding side (`refusal::admission_bindings`) spells what a refusal prints +/// through it too (CLOUD-1826) — so a subject pasted off the line binds as the +/// refusal does. Its `/` exception exists for the tree surface, whose admission +/// is matched against `finding.path` verbatim. #[must_use] -//MUTANT rendered-subject-unmatched|s@^ trimmed.split_whitespace().collect::>().join(",")$@ trimmed.to_owned()@|a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does pub fn subject_as_bound(subject: &str) -> String { - let trimmed = subject.trim(); - if trimmed.contains('/') || !trimmed.contains(char::is_whitespace) { - return trimmed.to_owned(); - } - trimmed.split_whitespace().collect::>().join(",") + crate::verdict::bound_subject(subject) } /// The five fields a caller can know without holding the record. diff --git a/crates/batten/src/advisory.rs b/crates/batten/src/advisory.rs index 06fa63d9a..912cfeaa9 100644 --- a/crates/batten/src/advisory.rs +++ b/crates/batten/src/advisory.rs @@ -20,6 +20,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use crate::refusal::Refusal; use crate::severity::AdvisoryTier; /// One producer's contribution, with the latency its content demands. @@ -33,17 +34,49 @@ use crate::severity::AdvisoryTier; pub struct Advice { /// How soon this must be answered. pub tier: AdvisoryTier, - /// The pointer text, already composed by its producer. + /// The pointer text, already composed by its producer. Empty on a classed + /// entry until `sight_advice` renders its `finding` at emission. pub text: String, + /// The finding this entry projects, rendered only when it is emitted, so + /// advice dropped beside a verdict is never marked seen (CLOUD-2075). + pub finding: Option>, + /// A classed entry carries a pointer the ruling needs, so no ceiling + /// suppresses it; only unclassed text is admitted by tier. + pub classed: bool, } impl Advice { - /// One entry. + /// One unclassed entry. #[must_use] pub fn new(tier: AdvisoryTier, text: impl Into) -> Advice { Advice { tier, text: text.into(), + finding: None, + classed: false, + } + } + + /// One classed entry, rendered through the finding projection at emission. + #[must_use] + pub fn finding(tier: AdvisoryTier, refusal: Refusal) -> Advice { + Advice { + tier, + text: String::new(), + finding: Some(Box::new(refusal)), + classed: true, + } + } + + /// An already-rendered, already-marked classed entry: the eager re-delivery + /// a compaction's `SessionStart` carries. + #[must_use] + pub fn delivered(tier: AdvisoryTier, text: impl Into) -> Advice { + Advice { + tier, + text: text.into(), + finding: None, + classed: true, } } } @@ -129,6 +162,13 @@ pub fn suppressed_line(suppressed: usize, ceiling: usize) -> String { /// the only thing said — a report about a report. The overflow is still counted, /// so the reader learns the ceiling is too small for its own content rather than /// hearing silence. +/// +/// # A classed entry is never suppressed +/// +/// It carries a pointer the ruling needs (CLOUD-2075): the ceiling bounds only +/// text that carries no class, and a classed entry over it is still emitted. +//MUTANT-SUITE crates/batten/src/advisory.rs +//MUTANT classed-advice-suppressed|s@^ if entry.classed {$@ if false {@|a_classed_entry_is_never_suppressed_at_the_ceiling #[must_use] pub fn admit(entries: Vec, ceiling: Option<&Channel>) -> Emission { let Some(ceiling) = ceiling else { @@ -145,6 +185,10 @@ pub fn admit(entries: Vec, ceiling: Option<&Channel>) -> Emission { let mut admitted: Vec = Vec::new(); let mut suppressed = 0; for entry in ordered { + if entry.classed { + admitted.push(entry); + continue; + } let candidate = joined_with(&admitted, &entry); if admitted.is_empty() || crate::budget::estimate_tokens(&candidate) <= ceiling.max_tokens { admitted.push(entry); @@ -285,4 +329,26 @@ mod tests { ); assert_eq!(emission.text, "alpha\n\nbeta"); } + + #[test] + fn a_classed_entry_is_never_suppressed_at_the_ceiling() { + // CLOUD-2075: a classed entry carries a pointer the ruling needs, so the + // ceiling bounds only unclassed text — which is still counted. + let emission = admit( + vec![ + Advice::delivered(AdvisoryTier::Warning, "w".repeat(80)), + Advice::delivered(AdvisoryTier::Advisory, "a".repeat(80)), + entry(AdvisoryTier::Caution, &"c".repeat(80)), + ], + Some(&Channel { max_tokens: 1 }), + ); + assert!(emission.text.contains(&"w".repeat(80)), "{}", emission.text); + assert!(emission.text.contains(&"a".repeat(80)), "{}", emission.text); + assert!( + !emission.text.contains(&"c".repeat(80)), + "{}", + emission.text + ); + assert_eq!(emission.suppressed, 1); + } } diff --git a/crates/batten/src/bypass.rs b/crates/batten/src/bypass.rs index 4744c5ada..38ed9b5b4 100644 --- a/crates/batten/src/bypass.rs +++ b/crates/batten/src/bypass.rs @@ -291,6 +291,7 @@ pub fn scan(stream: &Stream) -> Vec { // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this // predicate has nothing to say about it. | Event::HookOutput { .. } + | Event::SessionBoundary | Event::AssistantText => {} } } diff --git a/crates/batten/src/completion.rs b/crates/batten/src/completion.rs index 5d5cf1508..660736151 100644 --- a/crates/batten/src/completion.rs +++ b/crates/batten/src/completion.rs @@ -270,6 +270,8 @@ pub fn signal(stream: &Stream) -> Option { // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this // predicate has nothing to say about it. | Event::HookOutput { .. } + // Machinery too (CLOUD-2075): a session start is a cycle boundary. + | Event::SessionBoundary | Event::AssistantText => {} } } diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index d53c509b6..3961ccedc 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -2892,6 +2892,22 @@ mod tests { use super::*; + /// CLOUD-1728. `COMMIT_HOOKS` stays the runtime authority for which git + /// hooks batten links; the declared git vocabulary must register exactly + /// those, read through the public accessor every consumer reaches. + #[test] + fn the_git_vocabulary_registers_exactly_the_commit_hooks() { + let registered: std::collections::BTreeSet = crate::hook::HookSource::Git + .vocabulary() + .into_iter() + .filter(|(_, d)| matches!(d, crate::hook::HookDisposition::Registered { .. })) + .map(|(name, _)| name) + .collect(); + let authority: std::collections::BTreeSet = + COMMIT_HOOKS.iter().map(|h| (*h).to_owned()).collect(); + assert_eq!(registered, authority); + } + /// CLOUD-1683. The declared table is read in every spelling it really uses — /// a plain string pin, an inline table, and a backend-prefixed quoted key — /// because the difference between them is entirely on the right-hand side. diff --git a/crates/batten/src/drain.rs b/crates/batten/src/drain.rs index d9353e741..c751e329f 100644 --- a/crates/batten/src/drain.rs +++ b/crates/batten/src/drain.rs @@ -91,24 +91,21 @@ //! that ran and found nothing. That is the false green this engine exists to //! catch, in a place nobody would look. //! -//! # Emission is bounded twice, and the two bounds measure different things +//! # Emission is reported twice, and never shed (CLOUD-2075) //! -//! CLOUD-82's contract, and the reason [`cycle`] selects before it renders: a -//! payload the agent cannot read is not information. +//! Every in-scope identity is a pointer the ruling needs, so no bound withholds +//! one; each REPORTS instead. //! -//! * The **per-rule cardinality cap** ([`DrainConfig::cardinality_cap`]) bounds -//! how many distinct identities one rule may spend lines on. A rule over it -//! contributes one `rule R: K+ findings` summary line and no entries, and the -//! identities it withheld are journalled as [`NotShown::OverCardinalityCap`]. -//! That is a statement about the **rule** — a check firing on eleven distinct -//! identities inside one changed scope is a rule-health signal, not a to-do -//! list — which is why it is the reason that feeds CLOUD-78's sampled review. -//! * The **token budget** ([`DrainConfig::token_budget`]) bounds the payload as -//! a whole, measured with [`crate::budget::estimate_tokens`] rather than a -//! second estimator. What it drops is journalled as -//! [`NotShown::DrainSuppressed`], because that is a statement about **this -//! boundary**: the finding is unchanged, the drain simply had no room for it -//! this time, and the next drain reconsiders it. +//! * The **per-rule cardinality cap** ([`DrainConfig::cardinality_cap`]): a rule +//! over it adds `rule '': findings, over the cardinality cap of ` +//! beside its entries. That is a statement about the **rule** — a rule-health +//! signal, not a to-do list. +//! * The **token budget** ([`DrainConfig::token_budget`]), measured with +//! [`crate::budget::estimate_tokens`]: a payload over it closes with +//! `budget: tokens over the declared `. +//! +//! The scope filter is the one withholding left, journalled as +//! [`NotShown::DrainSuppressed`]. //! //! Between the two, lines are ordered **salient-first** — by tier, then rule, //! then fingerprint. The occurrence count is deliberately *not* a sort key: @@ -506,20 +503,11 @@ pub struct Drained { /// The pointer lines to emit, ordered salient-first and deterministically, /// so the payload is byte-stable. pub lines: Vec, - /// Identities the scope filter withheld. + /// Identities the scope filter withheld: the ONE withholding left + /// (CLOUD-2075), because an out-of-scope finding is not a pointer the change + /// in hand needs. The cardinality cap, the token budget and the flap filter + /// now report and never withhold. pub scope_filtered: Vec, - /// Identities withheld because their rule was over the cardinality cap. A - /// property of the rule, and so the reason rule-health telemetry reads. - pub capped: Vec, - /// Identities withheld because the payload had no room for them **this - /// boundary**. A property of the payload, and so retried on the next drain. - pub over_budget: Vec, - /// Identities withheld because they are flapping and have spent their - /// re-emit budget for the window (CLOUD-165). A property of the SIGNAL, which - /// is a third thing again: the scope filter is about the tree, the cap about - /// the rule, the budget about this payload, and this about whether the - /// identity's own history makes another line informative. - pub flap_suppressed: Vec, /// Flapping identities per rule, for the rule-health annotation. Pointer-only: /// a rule id and a count, never a finding's content. pub flapping: BTreeMap, @@ -546,18 +534,17 @@ struct Surfaced<'a> { /// One thing the payload can say: a pointer, or a rule's cardinality summary. /// -/// Both are subject to the token budget, which is why they are one type — a -/// summary line that escaped the clamp would be a payload the budget did not -/// actually bound. +/// One type because both are ordered together; neither is ever dropped +/// (CLOUD-2075): the summary REPORTS a rule over its cap beside every entry. #[derive(Debug, Clone, Copy)] enum Item<'a> { /// One identity's pointer line. Entry(Surfaced<'a>), - /// One rule's summary, standing for the identities the cap withheld. + /// One rule's summary, reporting that it is over the cardinality cap. Summary { rule: &'a str, tier: AdvisoryTier, - withheld: usize, + count: usize, }, } @@ -580,15 +567,6 @@ impl Item<'_> { Item::Summary { rule, tier, .. } => (std::cmp::Reverse(*tier), rule, String::new()), } } - - /// How many findings this item stands for, for the withheld count the budget - /// line reports. - const fn weight(&self) -> usize { - match self { - Item::Entry(_) => 1, - Item::Summary { withheld, .. } => *withheld, - } - } } /// Render one line for a record, given the instance to point at and what the @@ -602,7 +580,8 @@ impl Item<'_> { /// A count that **rose** renders as `old->new`: the identity is the same, and /// the delta is the news. A count that fell renders plainly — a ratchet is not a /// re-raise, because re-raising on incremental fixing punishes the fix. The line -/// stays four space-separated fields whichever branch is taken. +/// stays one labelled rule and three space-separated fields whichever branch is +/// taken: `rule '' at [:] ` (CLOUD-2075). fn render_line(record: &FindingRecord, instance: &Instance, previous: Option) -> String { let count = match instance.occurrences { Observation::Observed(count) => match previous { @@ -615,25 +594,26 @@ fn render_line(record: &FindingRecord, instance: &Instance, previous: Option format!("{}:{line}", instance.path), None => instance.path.clone(), }; + let rule = crate::refusal::label(crate::refusal::Label::Rule, &record.rule); format!( - "{} {} {at} {count}", - record.identity.fingerprint.to_hex(), - record.rule + "{rule} at {} {at} {count}", + record.identity.fingerprint.to_hex() ) } -/// The one line a rule over the cardinality cap gets, in place of its entries. -fn cap_summary(rule: &str, cap: usize) -> String { - format!("rule {rule}: {cap}+ findings") +/// The line a rule over the cardinality cap adds BESIDE its entries. +fn cap_summary(rule: &str, count: usize, cap: usize) -> String { + let rule = crate::refusal::label(crate::refusal::Label::Rule, rule); + format!("{rule}: {count} findings, over the cardinality cap of {cap}") } -/// The one line closing a payload the token budget clamped. -fn budget_summary(withheld: usize) -> String { - format!("budget: {withheld} findings withheld") +/// The line closing a payload over the token budget. It reports; nothing was +/// withheld (CLOUD-2075). +fn budget_summary(over: usize, budget: usize) -> String { + format!("budget: {over} tokens over the declared {budget}") } -/// Whether `lines` plus `candidate` — and the closing summary a later clamp -/// would owe — still fits the budget. +/// Whether `lines` plus `candidate` still fits the budget. /// /// Measured over the joined payload with [`crate::budget::estimate_tokens`], /// which is the same estimator `[budget]` gates instruction files with. A second @@ -676,28 +656,18 @@ pub fn cycle( log: &[journal::Entry], ) -> Drained { let assessment = emission::assess(log, config.flap_window, config.flap_percent); - let selected = select(records, changed, context, &assessment, config.emit_cap); + let selected = select(records, changed, context); // The state lines are taken BEFORE the cap consumes the surfaced set, so the - // digest covers every identity this cycle looked at rather than only the ones - // that got a line. See [`state_lines`] for why that is the difference between - // a report id and a set hash. + // digest covers every identity this cycle looked at. See [`state_lines`] for + // why that is the difference between a report id and a set hash. let state = state_lines(&selected.shown); - let (items, capped) = cap(selected.shown, config.cardinality_cap); + let items = cap(selected.shown, config.cardinality_cap); let clamped = clamp(&items, config, previous); - let result_id = result_fingerprint( - &clamped, - &state, - &capped, - &selected.scope_filtered, - &selected.flap_suppressed, - ); + let result_id = result_fingerprint(&clamped, &state, &selected.scope_filtered); Drained { lines: clamped.lines, scope_filtered: selected.scope_filtered, - capped, - over_budget: clamped.over_budget, flapping: flapping_by_rule(records, &assessment), - flap_suppressed: selected.flap_suppressed, duplicates: selected.duplicates, counts: clamped.counts, result_id, @@ -758,33 +728,18 @@ fn state_lines(shown: &BTreeMap>) -> Vec { } /// The digest a repeat is recognised by: the payload, plus every state-bearing -/// fact behind it, plus how much was withheld and why. +/// fact behind it, plus how much the scope filter withheld. /// -/// The withheld *counts* are in the input because a finding moving between the -/// two withholding reasons is a state change the payload cannot show — the lines -/// are identical whether a rule was capped or its entries clamped, and the two -/// mean different things to the rate that reads them. +/// The withheld count is in the input because a scope withholding is a state +/// change the payload cannot show. fn result_fingerprint( clamped: &Clamped, state: &[String], - capped: &[FindingRecord], scope_filtered: &[FindingRecord], - flap_suppressed: &[FindingRecord], ) -> String { let mut input = clamped.lines.clone(); input.extend(state.iter().cloned()); - // The flap count joins the tuple for the same reason the other three are in - // it, and the omission would have been the worse bug: a flap suppression is - // invisible in the lines, so a cycle that withheld a newly-flapping identity - // would digest identically to the one before it and the `resultId` - // short-circuit would report `unchanged` about a payload that had changed. - input.push(format!( - "withheld {} {} {} {}", - scope_filtered.len(), - capped.len(), - clamped.over_budget.len(), - flap_suppressed.len() - )); + input.push(format!("withheld {}", scope_filtered.len())); drain_result_fingerprint(&input).to_hex() } @@ -793,27 +748,21 @@ fn result_fingerprint( struct Selected<'a> { shown: BTreeMap>, scope_filtered: Vec, - flap_suppressed: Vec, duplicates: usize, } /// Stage one: the one instance per identity worth pointing at. /// -/// The emission policy is applied **here**, after the scope filter and before the -/// instance pick, and the position is chosen rather than convenient. This is the -/// last point at which a withheld identity can still be carried out as a record -/// for journalling — after `cap` it has been folded into a summary line and after -/// `state_lines` it is already inside the digest, so a filter downstream of either -/// would be a suppression the store never learns about. +/// **A flapping identity is shown** (CLOUD-2075): its pointer is one the ruling +/// needs, so the emission policy no longer withholds it — it is still counted +/// in [`Drained::flapping`], which is the report. `emit_cap` stays accepted and +/// unread. fn select<'a>( records: &'a [FindingRecord], changed: &BTreeSet, context: Option<&Context>, - assessment: &emission::Assessment, - emit_cap: usize, ) -> Selected<'a> { let mut scope_filtered = Vec::new(); - let mut flap_suppressed = Vec::new(); // Keyed by identity, which is what makes "suppressed and counted" the // structure rather than a rule applied afterwards: a second record for one // identity cannot occupy a second entry. @@ -836,16 +785,6 @@ fn select<'a>( scope_filtered.push(record.clone()); continue; } - // The signal filter (CLOUD-165). It reads the identity's own history off - // the journal and decides nothing about the finding's state: the record - // below is unchanged, its instances still say what the last scan saw, and - // its disposition is whatever the agent gave it. - if let emission::Emission::Withhold(_) = - assessment.decide(&record.identity.fingerprint.to_hex(), emit_cap) - { - flap_suppressed.push(record.clone()); - continue; - } let Some(instance) = context .and_then(|context| record.instance(context)) .or_else(|| record.instances.first()) @@ -863,20 +802,19 @@ fn select<'a>( Selected { shown, scope_filtered, - flap_suppressed, duplicates, } } -/// Stage two: collapse every rule that surfaced more distinct identities than it -/// may spend entries on, and carry what it withheld out for journalling. +/// Stage two: report every rule that surfaced more distinct identities than its +/// cardinality cap, BESIDE its entries (CLOUD-2075). /// /// Grouped by rule over `shown`, whose iteration is by fingerprint hex, so both /// the grouping and every group's contents are a function of the SET. The result -/// is sorted salient-first, which is the order the clamp then spends the budget -/// in — dropping the least salient first is what makes a truncated payload the -/// most useful one that fits. -fn cap(shown: BTreeMap>, cap: usize) -> (Vec>, Vec) { +/// is sorted salient-first. +//MUTANT-SUITE crates/batten/src/drain.rs +//MUTANT capped-pointers-withheld|s@^ items.extend(surfaced.into_iter().map(Item::Entry));$@ items.extend(surfaced.into_iter().take(cap).map(Item::Entry));@|a_rule_over_the_cardinality_cap_still_shows_every_pointer +fn cap(shown: BTreeMap>, cap: usize) -> Vec> { let mut per_rule: BTreeMap<&str, Vec>> = BTreeMap::new(); for surfaced in shown.into_values() { per_rule @@ -885,116 +823,68 @@ fn cap(shown: BTreeMap>, cap: usize) -> (Vec>, Vec .push(surfaced); } - let mut capped: Vec = Vec::new(); let mut items: Vec> = Vec::new(); for (rule, surfaced) in per_rule { if surfaced.len() > cap { - // The summary carries the strongest tier the rule surfaced, so - // collapsing a rule cannot bury it below a weaker rule's entries. + // The summary carries the strongest tier the rule surfaced, so it + // sorts ahead of the rule's own entries rather than below them. let tier = surfaced .iter() .map(|entry| entry.record.tier) .max() .unwrap_or(AdvisoryTier::Advisory); - capped.extend(surfaced.iter().map(|entry| entry.record.clone())); items.push(Item::Summary { rule, tier, - withheld: surfaced.len(), + count: surfaced.len(), }); - } else { - items.extend(surfaced.into_iter().map(Item::Entry)); } + items.extend(surfaced.into_iter().map(Item::Entry)); } items.sort_by(|left, right| left.key().cmp(&right.key())); - (items, capped) + items } -/// What the clamp emitted, what it had no room for, and the counts it told the -/// agent — which become the next drain's re-raise anchor. +/// What the clamp emitted and the counts it told the agent — which become the +/// next drain's re-raise anchor. struct Clamped { lines: Vec, - over_budget: Vec, counts: BTreeMap, } -/// Stage three: spend the token budget salient-first, and say how much went -/// unsaid. -/// -/// Greedy, reserving room for the closing summary line a later drop would owe. -/// Reserving against the *remaining* weight is what makes the bound hold rather -/// than nearly hold: the line that finally gets written can only be shorter than -/// the one that was budgeted for. +/// Stage three: emit every line salient-first, and REPORT a payload over the +/// token budget rather than withholding from it (CLOUD-2075). +//MUTANT budget-pointers-withheld|s@^ lines.push(candidate);$@ if within(\&lines, \&candidate, None, config.token_budget) { lines.push(candidate); }@|an_over_budget_drain_still_shows_every_pointer fn clamp(items: &[Item<'_>], config: &DrainConfig, previous: &BTreeMap) -> Clamped { - let suffix = suffix_weights(items); let mut lines: Vec = Vec::new(); let mut counts: BTreeMap = BTreeMap::new(); - let mut over_budget: Vec = Vec::new(); - let mut withheld = 0; - let mut clamped = false; - - for (index, item) in items.iter().enumerate() { - if !clamped { - let candidate = match item { - Item::Entry(surfaced) => { - let key = surfaced.record.identity.fingerprint.to_hex(); - render_line( - surfaced.record, - surfaced.instance, - previous.get(&key).copied(), - ) + for item in items { + let candidate = match item { + Item::Entry(surfaced) => { + let key = surfaced.record.identity.fingerprint.to_hex(); + // Only an observed count anchors the next drain's re-raise: a + // rule that did not run said nothing about how many. + if let Observation::Observed(count) = surfaced.instance.occurrences { + counts.insert(key.clone(), count); } - Item::Summary { rule, .. } => cap_summary(rule, config.cardinality_cap), - }; - let reserve = suffix - .get(index + 1) - .filter(|remaining| **remaining > 0) - .map(|remaining| budget_summary(*remaining)); - if within(&lines, &candidate, reserve.as_deref(), config.token_budget) { - if let Item::Entry(surfaced) = item { - // Only an observed count anchors the next drain's re-raise: - // a rule that did not run said nothing about how many. - if let Observation::Observed(count) = surfaced.instance.occurrences { - counts.insert(surfaced.record.identity.fingerprint.to_hex(), count); - } - } - lines.push(candidate); - continue; + render_line( + surfaced.record, + surfaced.instance, + previous.get(&key).copied(), + ) } - clamped = true; - } - withheld += item.weight(); - if let Item::Entry(surfaced) = item { - over_budget.push(surfaced.record.clone()); - } - } - - if clamped { - // The reserve above budgeted for this line; it is written only if it - // still fits, because a first item too large to keep leaves no room - // that was ever checked. - let summary = budget_summary(withheld); - if within(&lines, &summary, None, config.token_budget) { - lines.push(summary); - } - } - - Clamped { - lines, - over_budget, - counts, + Item::Summary { rule, count, .. } => cap_summary(rule, *count, config.cardinality_cap), + }; + lines.push(candidate); } -} - -/// How many findings each suffix of `items` stands for, so the clamp can reserve -/// room for the closing line it might owe. One entry longer than `items`, whose -/// last element is zero: past the end nothing remains to withhold. -fn suffix_weights(items: &[Item<'_>]) -> Vec { - let mut weights: Vec = vec![0; items.len() + 1]; - for (index, item) in items.iter().enumerate().rev() { - weights[index] = weights[index + 1].saturating_add(item.weight()); + if let Some((last, head)) = lines.split_last() + && !within(head, last, None, config.token_budget) + { + let cost = crate::budget::estimate_tokens(&lines.join("\n")); + let over = cost.saturating_sub(config.token_budget); + lines.push(budget_summary(over, config.token_budget)); } - weights + Clamped { lines, counts } } /// The directory holding one wake-state file per session, under a bound store. @@ -1111,13 +1001,9 @@ pub fn record_suppressions( /// Journal every identity this cycle withheld, under the reason it was withheld /// for. /// -/// One call rather than three at the boundary, because the *pairing* of a -/// withheld set with its reason is a fact about the emission contract and not -/// about the caller: the cap is a property of the rule and feeds rule-health -/// telemetry, where the scope filter and the token clamp are properties of this -/// boundary — the finding is unchanged and the next drain reconsiders it. A -/// caller free to pair them differently could put a transient suppression into -/// the number CLOUD-78's sampled review reads as rule health. +/// One call at the boundary, because the *pairing* of a withheld set with its +/// reason is a fact about the emission contract and not about the caller. Only +/// the scope filter withholds now (CLOUD-2075). /// /// Returns how many entries were actually written, which is what tells the /// caller whether a fold is worth running. @@ -1126,31 +1012,15 @@ pub fn record_suppressions( /// /// Returns an error when a shard cannot be appended to. pub fn journal_suppressions(store_dir: &Path, shard: &str, cycle: &Drained) -> Result { - let mut appended = record_suppressions( + // The scope filter is the one withholding left (CLOUD-2075): the cap, the + // budget and the flap filter report and withhold nothing, so they have no + // suppression to journal. + record_suppressions( store_dir, shard, &cycle.scope_filtered, NotShown::DrainSuppressed, - )?; - appended += record_suppressions( - store_dir, - shard, - &cycle.capped, - NotShown::OverCardinalityCap, - )?; - appended += record_suppressions( - store_dir, - shard, - &cycle.over_budget, - NotShown::DrainSuppressed, - )?; - appended += record_suppressions( - store_dir, - shard, - &cycle.flap_suppressed, - NotShown::FlapSuppressed, - )?; - Ok(appended) + ) } /// Journal every identity this payload actually emitted. @@ -1281,9 +1151,6 @@ mod tests { Drained { lines: Vec::new(), scope_filtered: Vec::new(), - capped: Vec::new(), - over_budget: Vec::new(), - flap_suppressed: Vec::new(), flapping: BTreeMap::new(), duplicates: 0, counts: BTreeMap::new(), @@ -1779,7 +1646,7 @@ mod tests { assert_eq!( drained.lines, vec![format!( - "{} r src/a.rs:1 1", + "rule 'r' at {} src/a.rs:1 1", one.identity.fingerprint.to_hex() )] ); @@ -1815,53 +1682,40 @@ mod tests { } #[test] - fn a_rule_over_the_cardinality_cap_renders_one_summary_line_and_never_k_entries() { - // §7 (b). K+1 distinct identities for one rule collapse to exactly one - // pointer-only summary line — never K entries, which is the failure this - // cap exists to prevent: a rule firing everywhere spending the agent's - // whole payload on itself. + fn a_rule_over_the_cardinality_cap_still_shows_every_pointer() { + // CLOUD-2075 §7 case 18. Eleven identities of one rule under the default + // cap of 10: eleven labelled entries plus the summary that REPORTS the + // rule over its cap. The cap withholds nothing. let config = DrainConfig { - cardinality_cap: 3, - ..generous() + token_budget: usize::MAX, + ..DrainConfig::default() }; - let scope = changed(&["src/a.rs"]); - - let under = cycle( - &spread("r", 3), - &scope, - None, - &config, - &BTreeMap::new(), - &[], - ); - assert_eq!(under.lines.len(), 3, "at the cap, every identity speaks"); - assert!(under.capped.is_empty()); - - let over = cycle( - &spread("r", 4), - &scope, + let drained = cycle( + &spread("r", 11), + &changed(&["src/a.rs"]), None, &config, &BTreeMap::new(), &[], ); - assert_eq!( - over.lines, - vec!["rule r: 3+ findings".to_owned()], - "one line for the rule, and no entries at all" - ); - assert_eq!(over.capped.len(), 4, "all four are withheld BY THE CAP"); + let entries = drained + .lines + .iter() + .filter(|line| line.starts_with("rule 'r' at ")) + .count(); + assert_eq!(entries, 11, "{:?}", drained.lines); assert!( - over.counts.is_empty(), - "nothing was shown, so nothing is remembered as having been shown" + drained.lines.contains(&format!( + "rule 'r': 11 findings, over the cardinality cap of {}", + config.cardinality_cap + )), + "{:?}", + drained.lines ); } #[test] fn the_cap_is_per_rule_so_one_noisy_rule_never_silences_a_quiet_one() { - // The cap is a statement about a rule's health, so it must not be - // reachable by a rule's neighbours: a second rule with one finding still - // gets its pointer. let config = DrainConfig { cardinality_cap: 2, ..generous() @@ -1876,83 +1730,69 @@ mod tests { &BTreeMap::new(), &[], ); - assert_eq!(drained.lines.len(), 2); + assert_eq!(drained.lines.len(), 7, "{:?}", drained.lines); assert!( drained .lines - .contains(&"rule noisy: 2+ findings".to_owned()) + .contains(&"rule 'noisy': 5 findings, over the cardinality cap of 2".to_owned()) ); assert!( - drained.lines.iter().any(|line| line.contains(" quiet ")), + drained + .lines + .iter() + .any(|line| line.starts_with("rule 'quiet' ")), "the quiet rule keeps its pointer: {:?}", drained.lines ); } #[test] - fn the_rendered_payload_stays_at_or_under_the_configured_token_budget() { - // §7 (a), both halves. The clamped payload is at or under the budget, - // and the SAME assertion over the unclamped set fails — without which - // this test could pass on a fixture that never approached the bar. - const BUDGET: usize = 60; - let records = spread("r", 40); - let scope = changed(&["src/a.rs"]); - - let unclamped = cycle(&records, &scope, None, &generous(), &BTreeMap::new(), &[]); - assert!( - crate::budget::estimate_tokens(&render(&unclamped)) > BUDGET, - "the fixture must actually overflow, or the clamp is untested" - ); - - let clamped = cycle( + fn an_over_budget_drain_still_shows_every_pointer() { + // CLOUD-2075 §7 case 19. The budget reports; it never withholds. + let records = spread("r", 3); + let drained = cycle( &records, - &scope, + &changed(&["src/a.rs"]), None, &DrainConfig { - token_budget: BUDGET, + token_budget: 8, ..generous() }, &BTreeMap::new(), &[], ); + let entries = drained + .lines + .iter() + .filter(|line| line.starts_with("rule 'r' at ")) + .count(); + assert_eq!(entries, 3, "{:?}", drained.lines); + let last = drained.lines.last().expect("a closing line"); assert!( - crate::budget::estimate_tokens(&render(&clamped)) <= BUDGET, - "over budget: {:?}", - render(&clamped) - ); - assert!( - !clamped.over_budget.is_empty(), - "and something was actually withheld" - ); - assert_eq!( - clamped.lines.last().map(String::as_str), - Some(format!("budget: {} findings withheld", clamped.over_budget.len()).as_str()), - "the payload says how much it did not say: {:?}", - clamped.lines + last.starts_with("budget: ") && last.ends_with(" tokens over the declared 8"), + "{last}" ); + assert_eq!(drained.counts.len(), 3); } #[test] - fn a_zero_budget_says_nothing_rather_than_saying_one_thing() { - // The honest bottom of the range. A budget that cannot afford even the - // closing summary emits nothing at all — and still carries every - // withheld identity out for journalling, so silence is recorded rather - // than merely observed. - let records = spread("r", 3); + fn a_payload_within_its_budget_carries_no_budget_line() { let drained = cycle( - &records, + &spread("r", 2), &changed(&["src/a.rs"]), None, - &DrainConfig { - token_budget: 0, - ..generous() - }, + &generous(), &BTreeMap::new(), &[], ); - assert!(drained.lines.is_empty()); - assert_eq!(drained.over_budget.len(), 3); - assert!(drained.counts.is_empty()); + assert!( + drained + .lines + .iter() + .all(|line| !line.starts_with("budget: ")), + "{:?}", + drained.lines + ); } #[test] @@ -1974,7 +1814,10 @@ mod tests { &previous, &[], ); - assert_eq!(drained.lines, vec![format!("{key} r src/a.rs:1 500->501")]); + assert_eq!( + drained.lines, + vec![format!("rule 'r' at {key} src/a.rs:1 500->501")] + ); assert_eq!( drained.counts.get(&key).copied(), Some(501), @@ -2000,7 +1843,10 @@ mod tests { &previous, &[], ); - assert_eq!(drained.lines, vec![format!("{key} r src/a.rs:1 10")]); + assert_eq!( + drained.lines, + vec![format!("rule 'r' at {key} src/a.rs:1 10")] + ); } #[test] @@ -2029,7 +1875,7 @@ mod tests { &[], ); assert!( - quiet.lines[0].contains(" warning-rule "), + quiet.lines[0].starts_with("rule 'warning-rule' "), "the stronger tier leads: {:?}", quiet.lines ); @@ -2039,7 +1885,7 @@ mod tests { let before = vec![escalating.clone(), urgent.clone()]; let shouted = cycle(&before, &scope, None, &generous(), &BTreeMap::new(), &[]); assert!( - shouted.lines[0].contains(" warning-rule "), + shouted.lines[0].starts_with("rule 'warning-rule' "), "nine thousand occurrences buy no position: {:?}", shouted.lines ); @@ -2050,29 +1896,6 @@ mod tests { ); } - #[test] - fn a_capped_or_clamped_identity_is_never_remembered_as_something_the_agent_saw() { - // The remembered counts are the anchor for "what was it last told", so - // an identity withheld this boundary must not enter them: it would make - // the NEXT drain's re-raise silent, because the delta would be measured - // against a number nobody ever read. - let config = DrainConfig { - cardinality_cap: 1, - token_budget: usize::MAX, - ..DrainConfig::default() - }; - let drained = cycle( - &spread("r", 4), - &changed(&["src/a.rs"]), - None, - &config, - &BTreeMap::new(), - &[], - ); - assert_eq!(drained.lines, vec!["rule r: 1+ findings".to_owned()]); - assert!(drained.counts.is_empty()); - } - #[test] fn a_silent_drain_leaves_the_remembered_counts_where_they_were() { // A payload the `resultId` short-circuit swallowed told the agent @@ -2097,19 +1920,13 @@ mod tests { } #[test] - fn the_capped_and_the_clamped_are_withheld_for_different_recorded_reasons() { - // The two bounds measure different things, and the store has to be able - // to tell them apart: the cap is a property of the RULE and feeds - // rule-health telemetry, where the clamp is a property of THIS payload - // and the finding is reconsidered next boundary. One reason for both - // would put a transient suppression into the rule-health number. + fn only_the_scope_filter_withholds_and_only_it_is_journalled() { + // CLOUD-2075: the cap and the budget report and withhold nothing, so the + // one suppression left to journal is the out-of-scope finding. let dir = std::env::temp_dir().join(format!("batten-reasons-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - // One rule over the cap, one rule under it whose entries the clamp then - // has no room for, and one code-anchored finding outside the changed - // scope. Three withheld sets, three reasons, one cycle. let mut records = spread("noisy", 4); records.extend(spread("quiet", 2)); records.push(record(FindingKind::Code, "elsewhere", "src/z.rs", "TODO")); @@ -2125,29 +1942,9 @@ mod tests { &BTreeMap::new(), &[], ); - assert_eq!(drained.capped.len(), 4, "the noisy rule, by the cap"); assert_eq!(drained.scope_filtered.len(), 1, "the one outside the diff"); - assert!( - !drained.over_budget.is_empty(), - "and the clamp took at least one of the quiet rule's entries" - ); - let capped: BTreeSet = drained - .capped - .iter() - .map(|record| record.identity.fingerprint.to_hex()) - .collect(); - assert!( - drained - .over_budget - .iter() - .all(|record| !capped.contains(&record.identity.fingerprint.to_hex())), - "the two sets are disjoint, so no identity is journalled under two reasons" - ); - assert_eq!( - journal_suppressions(&dir, "shard", &drained).unwrap(), - drained.capped.len() + drained.scope_filtered.len() + drained.over_budget.len(), - "every withheld identity is recorded, once" - ); + assert_eq!(drained.counts.len(), 6, "every in-scope identity was shown"); + assert_eq!(journal_suppressions(&dir, "shard", &drained).unwrap(), 1); let _ = std::fs::remove_dir_all(&dir); } @@ -2180,10 +1977,9 @@ mod tests { } #[test] - fn a_finding_moving_between_withholding_reasons_changes_the_result_id() { - // Same shape, one level out: the payload cannot show whether a rule was - // capped or its entries clamped, and the two mean different things to the - // rate that reads them. An identical `unchanged` for both would lose it. + fn a_cap_report_and_a_budget_report_change_the_result_id() { + // The cap and the budget report on their own lines (CLOUD-2075), so a + // payload over one is never the same report as a payload over the other. let records = spread("r", 4); let scope = changed(&["src/a.rs"]); let capped = cycle( diff --git a/crates/batten/src/engine.rs b/crates/batten/src/engine.rs index 647fcd708..02ce8a560 100644 --- a/crates/batten/src/engine.rs +++ b/crates/batten/src/engine.rs @@ -133,6 +133,14 @@ pub fn declared(text: &str) -> Option { table.get("engine")?.clone().try_into().ok() } +/// The engine's own repair for a pin this build does not satisfy (CLOUD-2116). +/// +/// One spelling for both halves: [`check`]'s refusal names it, and the hook's +/// floor over an unloadable config admits exactly it. A remedy naming a +/// consumer's installer was one the floor refused, so a skewed container could +/// run nothing at all, including the line its own refusal printed. +pub const REPAIR: &str = "batten engine update"; + //MUTANT-SUITE crates/batten/tests/it/engine_pin.rs //MUTANT pin-unread|s@^ let Some(pin) = declared(text) else {$@ let Some(pin) = None:: else {@|a_release_pin_naming_another_build_is_refused_with_its_install //MUTANT stamp-trusted-when-absent|s@^ if stamp.as_deref() != Some(digest) {$@ if stamp.is_some() \&\& stamp.as_deref() != Some(digest) {@|a_source_pin_without_a_stamp_is_refused @@ -158,7 +166,7 @@ pub fn check(text: &str, source: &str, stamp: impl FnOnce() -> Option) - if tag != running { return Err(UsageError::raise(format!( "{source} pins batten {tag} and this engine is {running}: install the pin \ - with `BATTEN_VERSION={tag} ./install.sh`" + with `{REPAIR}`" ))); } Ok(()) @@ -169,7 +177,7 @@ pub fn check(text: &str, source: &str, stamp: impl FnOnce() -> Option) - let running = stamp.as_deref().unwrap_or("unstamped"); return Err(UsageError::raise(format!( "{source} pins the engine built from source {digest} and this engine is \ - {running}: build and stamp it with `mise run install:local`" + {running}: build and stamp it with `{REPAIR}`" ))); } Ok(()) diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index d65285bb5..cfe9f192b 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -585,6 +585,11 @@ pub struct Capabilities { /// ordinary permission flow, which is what happens today and is the one /// degradation that cannot surprise anyone. pub preapprove: PreapproveReach, + /// Whether a `PostToolUse` document can replace the tool output the model + /// reads (CLOUD-2075): the boundary that cuts an already-seen finding in a + /// CLI's output to its pointer. Declared only as measured (CLOUD-1961's + /// class); every host is `Unknown` until a live probe answers for it. + pub rewrites_tool_output: Declaration, /// Whether a stop-family event can veto completion. /// /// **`false` on every surveyed host, Claude included** — all of them can only @@ -874,6 +879,9 @@ pub enum Capability { /// END and expresses its grouping through [`Capability::ALL`] and /// [`Capability::DISPATCH`] instead. Preapprove, + /// [`Capabilities::rewrites_tool_output`] (CLOUD-2075). Appended at the END + /// for the reason the two rows above give. + RewritesToolOutput, } impl Capability { @@ -882,6 +890,7 @@ impl Capability { Capability::Ask, Capability::Advisory, Capability::Preapprove, + Capability::RewritesToolOutput, Capability::StopVetoesCompletion, Capability::TimeoutFailsOpen, Capability::NeedsFailClosedConfig, @@ -900,6 +909,7 @@ impl Capability { Capability::Ask, Capability::Advisory, Capability::Preapprove, + Capability::RewritesToolOutput, Capability::StopVetoesCompletion, Capability::TimeoutFailsOpen, Capability::NeedsFailClosedConfig, @@ -933,6 +943,7 @@ impl Capability { Capability::Ask => "ask", Capability::Advisory => "advisory", Capability::Preapprove => "preapprove", + Capability::RewritesToolOutput => "rewrites-tool-output", Capability::StopVetoesCompletion => "stop-vetoes-completion", Capability::TimeoutFailsOpen => "timeout-fails-open", Capability::NeedsFailClosedConfig => "needs-fail-closed-config", @@ -1163,6 +1174,7 @@ impl Capabilities { Capability::Ask => self.ask.declared, Capability::Advisory => self.advisory.declared, Capability::Preapprove => self.preapprove.declared, + Capability::RewritesToolOutput => self.rewrites_tool_output, Capability::StopVetoesCompletion => measured(self.stop_vetoes_completion), Capability::TimeoutFailsOpen => measured(self.timeout_fails_open), Capability::NeedsFailClosedConfig => measured(self.needs_fail_closed_config), @@ -1573,6 +1585,7 @@ impl Harness { honoured_on: &["PreToolUse"], declared: Declaration::Yes, }, + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1651,6 +1664,7 @@ impl Harness { // two events, so the host clearly HAS a permission dialogue — what // the evidence does not answer is whether anything skips it. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: true, @@ -1685,6 +1699,7 @@ impl Harness { // they sit in, so no document can be emitted without guessing an // envelope, and a guessed envelope reads as no decision at all. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: true, needs_fail_closed_config: false, @@ -1750,6 +1765,7 @@ impl Harness { // collapsing them would be the guess `Declaration` exists to // refuse. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1774,6 +1790,7 @@ impl Harness { // "parsed but not supported yet", which says nothing either way // about a grant. preapprove: PreapproveReach::unreachable(Declaration::Unknown), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1801,6 +1818,7 @@ impl Harness { // so the shape IS the answer rather than a gap in somebody's // documentation. preapprove: PreapproveReach::unreachable(Declaration::No), + rewrites_tool_output: Declaration::Unknown, stop_vetoes_completion: false, timeout_fails_open: false, needs_fail_closed_config: false, @@ -1931,6 +1949,230 @@ impl Harness { } } +/// Where an adjudication event reaches batten (CLOUD-1728). +/// +/// CLOUD-777's invariant — batten hooked exactly once on every available hook +/// surface — was derived for the harness alone, because [`Event`] enumerates the +/// harness and nothing enumerated the other three. A source with no declared +/// vocabulary cannot be checked for completeness, so each was rediscovered from a +/// symptom. This is the outer index: four sources, each with a vocabulary and a +/// disposition per name. +/// +/// Named `HookSource`, never bare `Source`: `resolve::Source` and `race::Source` +/// exist, and `SessionStart`'s `source` field (startup/resume/clear/compact) is a +/// different axis entirely. +/// +/// **It ports nothing.** No runtime path calls [`HookSource::vocabulary`]; its +/// value is the completeness test over it, and its first consumer is CLOUD-2075's +/// emitter census, which iterates [`HookSource::ALL`] rather than keeping its own +/// list of where batten's text reaches an agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookSource { + /// The engine invoked directly; registration is the install itself. + Cli, + /// A vendor's agent harness — [`Harness`] crossed with [`Event`]. + Harness, + /// The git hook surface, runner-agnostic: hk today, but lefthook, husky or a + /// bare `core.hooksPath` are the same surface. + Git, + /// The forge, read by ETag-conditional polling. Not a process hook. + Ci, +} + +/// What batten does about one name in a [`HookSource`]'s vocabulary. +/// +/// Named `HookDisposition`, never bare `Disposition`: +/// `findings::Disposition` and `selfwrite::Disposition` are public already. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HookDisposition { + /// Batten is registered for it; `check` is the declared verb that checks the + /// registration on a live checkout. + Registered { + /// A `surface::SURFACE` path. + check: &'static str, + }, + /// Deliberately not registered; `reason` is a stable kebab id. + Unsupported { + /// Why, as a kebab id rather than prose. + reason: &'static str, + }, +} + +#[rustfmt::skip] +const BY_GATE: HookDisposition = HookDisposition::Registered { check: "doctor gate" }; +const BY_HOOKS: HookDisposition = HookDisposition::Registered { + check: "doctor hooks", +}; +const BY_MEDIATOR: HookDisposition = HookDisposition::Registered { + check: "doctor mediator", +}; +const BY_FORGE: HookDisposition = HookDisposition::Registered { + check: "doctor forge", +}; + +const fn unsupported(reason: &'static str) -> HookDisposition { + HookDisposition::Unsupported { reason } +} + +/// githooks(5) for git 2.43, one row per name, alphabetical. +/// +/// **A SNAPSHOT**: a hook a later git adds is not caught automatically. The two +/// registered rows must equal `doctor::COMMIT_HOOKS`, which stays the runtime +/// authority for which git hooks batten links. +/// +/// Every reason is a stable id: `after-the-fact` refuses nothing that has not +/// already happened (the [`Event::PostTool`] argument), `server-side` runs on a +/// remote, `adjudicated-elsewhere` is the push — decided at the mediated call and +/// by `land` — and `no-predicate` is a hook nothing here has a rule for yet. +/// `pre-merge-commit` stays `no-predicate`: fast-forward landing makes no merge +/// commits. Registering a further hook is per-source work, not this table's. +pub const GIT_HOOKS: &[(&str, HookDisposition)] = &[ + ("applypatch-msg", unsupported("no-predicate")), + ("commit-msg", BY_GATE), + ("fsmonitor-watchman", unsupported("not-a-gate")), + ("p4-changelist", unsupported("no-predicate")), + ("p4-post-changelist", unsupported("after-the-fact")), + ("p4-pre-submit", unsupported("no-predicate")), + ("p4-prepare-changelist", unsupported("no-predicate")), + ("post-applypatch", unsupported("after-the-fact")), + ("post-checkout", unsupported("after-the-fact")), + ("post-commit", unsupported("after-the-fact")), + ("post-index-change", unsupported("after-the-fact")), + ("post-merge", unsupported("after-the-fact")), + ("post-receive", unsupported("server-side")), + ("post-rewrite", unsupported("after-the-fact")), + ("post-update", unsupported("server-side")), + ("pre-applypatch", unsupported("no-predicate")), + ("pre-auto-gc", unsupported("no-predicate")), + ("pre-commit", BY_GATE), + ("pre-merge-commit", unsupported("no-predicate")), + ("pre-push", unsupported("adjudicated-elsewhere")), + ("pre-rebase", unsupported("no-predicate")), + ("pre-receive", unsupported("server-side")), + ("prepare-commit-msg", unsupported("no-predicate")), + ("proc-receive", unsupported("server-side")), + ("push-to-checkout", unsupported("server-side")), + ("reference-transaction", unsupported("no-predicate")), + ("sendemail-validate", unsupported("no-predicate")), + ("update", unsupported("server-side")), +]; + +/// What batten reads from the forge, and the two ingress shapes it refuses to be +/// (CLOUD-204: no server, and a webhook's silence is not success). +const CI_READS: &[(&str, HookDisposition)] = &[ + ("poll:pull-request", BY_FORGE), + ("poll:check-run", BY_FORGE), + ("poll:workflow-run", BY_FORGE), + ("webhook", unsupported("no-server")), + ("socket", unsupported("no-server")), +]; + +fn owned(table: &[(&str, HookDisposition)]) -> Vec<(String, HookDisposition)> { + table + .iter() + .map(|(name, d)| ((*name).to_owned(), *d)) + .collect() +} + +//MUTANT source-unenumerated|s@^ HookSource::Git,$@@|every_hook_source_is_enumerated_with_a_nonempty_vocabulary +//MUTANT git-vocabulary-emptied|s@^ HookSource::Git => owned(GIT_HOOKS),$@ HookSource::Git => Vec::new(),@|the_git_vocabulary_is_githooks5_with_one_disposition_each +//MUTANT check-verb-undeclared|s@^const BY_GATE: HookDisposition = HookDisposition::Registered { check: "doctor gate" };$@const BY_GATE: HookDisposition = HookDisposition::Registered { check: "doctor commit-gate" };@|every_registered_disposition_names_a_declared_verb +//MUTANT harness-registration-ignored|s@^ } else if registered.contains(event) {$@ } else if false {@|the_harness_vocabulary_agrees_with_wiring_registrations +//MUTANT commit-msg-demoted|s@^ ("commit-msg", BY_GATE),$@ ("commit-msg", unsupported("no-predicate")),@|the_git_vocabulary_registers_exactly_the_commit_hooks +impl HookSource { + /// Every source. The `match` in [`HookSource::vocabulary`] is exhaustive, so + /// a fifth source does not compile until it declares a vocabulary. + pub const ALL: &'static [HookSource] = &[ + HookSource::Cli, + HookSource::Harness, + HookSource::Git, + HookSource::Ci, + ]; + + /// The stable token. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + HookSource::Cli => "cli", + HookSource::Harness => "harness", + HookSource::Git => "git", + HookSource::Ci => "ci", + } + } + + /// Every name this source defines, each with its one disposition. + /// + /// The harness half is DERIVED from [`Wiring::registrations`], never + /// restated; the CLI half is every `surface::SURFACE` path as declared. + #[must_use] + pub fn vocabulary(self) -> Vec<(String, HookDisposition)> { + match self { + HookSource::Cli => crate::surface::SURFACE + .iter() + .map(|row| (row.path.to_owned(), BY_MEDIATOR)) + .collect(), + HookSource::Harness => harness_vocabulary(), + HookSource::Git => owned(GIT_HOOKS), + HookSource::Ci => owned(CI_READS), + } + } + + /// How a finding this source emits is rendered over a context's life + /// (CLOUD-2075). Only the harness knows which context it prints into; the + /// others print the full arm on every firing. + #[must_use] + pub const fn finding_lifecycle(self) -> Lifecycle { + match self { + HookSource::Harness => Lifecycle::Sighted, + HookSource::Cli | HookSource::Git | HookSource::Ci => Lifecycle::PrintedFull, + } + } +} + +/// Whether a source's findings get the once-per-cycle full arm (CLOUD-2075). +//MUTANT harness-lifecycle-unsighted|s@^ HookSource::Harness => Lifecycle::Sighted,$@ HookSource::Harness => Lifecycle::PrintedFull,@|every_hook_source_declares_its_finding_lifecycle +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lifecycle { + /// Full once per context per compaction cycle, the pointer otherwise. + Sighted, + /// The full arm on every firing: the source cannot name its reader. + PrintedFull, +} + +/// `:` for every harness and every event that is a moment. +fn harness_vocabulary() -> Vec<(String, HookDisposition)> { + let mut entries = Vec::new(); + for harness in Harness::ALL { + let wiring = harness.wiring(); + let registered: Vec = wiring + .as_ref() + .map(|w| { + w.registrations(*harness) + .into_iter() + .map(|(event, _)| event) + .collect() + }) + .unwrap_or_default(); + for event in Event::ALL + .iter() + .filter(|event| **event != Event::Unrecognized) + { + let disposition = if wiring.is_none() { + unsupported("no-wiring-surface") + } else if registered.contains(event) { + BY_HOOKS + } else { + unsupported("not-wired") + }; + entries.push(( + format!("{}:{}", harness.as_str(), event.as_str()), + disposition, + )); + } + } + entries +} + /// The lifecycle events the core normalizes, whatever a host spells them. /// /// A vocabulary enum with a `const ALL`, the shape [`Harness`], @@ -2303,7 +2545,9 @@ pub struct Envelope { /// formal: a command's stdout can carry anything, so this is the likeliest /// field in the envelope to hold a secret. It is decided OVER and never /// reproduced — not in a deny message, not in a `-J` document, and not under - /// the state root. + /// the state root. The one exception is [`tool_output_document`], which + /// hands it back to the host it came from with only finding lines cut to + /// their pointer (CLOUD-2075). /// /// Carried as the raw [`Value`] rather than a projection because the shape is /// per-tool and only partly surveyed: an MCP tool returns a content-block @@ -2372,8 +2616,17 @@ pub struct Envelope { /// "writes are available": [`Envelope::writes_available`] is where that /// direction is decided, once. pub mode: Option, + /// The subagent the payload came from, when one is the reader (Claude Code's + /// `agent_id`). Part of the sightings CONTEXT (CLOUD-2075). + pub agent: Option, + /// Why a `SessionStart` fired (`startup`, `resume`, `clear`, `compact`), + /// when the host says. [`COMPACT_SOURCE`] re-delivers rather than forgets. + pub start_source: Option, } +/// The `SessionStart` source a compaction carries (CLOUD-2075). +pub const COMPACT_SOURCE: &str = "compact"; + /// The mode a host names when the turn may propose but not perform. /// /// Claude Code's spelling, and the only one measured. A host that spells it @@ -2404,6 +2657,24 @@ impl Envelope { } } +//MUTANT session-less-shared|s@^ let session = self.session.as_deref()?;$@ let session = self.session.as_deref().unwrap_or("unnamed");@|a_session_less_payload_is_full_on_every_firing +impl Envelope { + /// The reader a sighting belongs to: the session, plus the agent id when a + /// subagent is the reader (CLOUD-2075). + /// + /// `None` when the payload names no session: a payload that cannot name its + /// reader must not share a key with other readers, so a session-less firing + /// is full on every firing and marks nothing. + #[must_use] + pub fn context(&self) -> Option { + let session = self.session.as_deref()?; + Some(match self.agent.as_deref() { + Some(agent) => format!("{session}\u{1f}{agent}"), + None => session.to_owned(), + }) + } +} + /// The payload fields a shell hook may ask for by name. /// /// A FIXED ALLOWLIST, never a caller-supplied JSON path (CLOUD-479). The @@ -2886,18 +3157,35 @@ pub struct Repair { pub subject: Option, } +//MUTANT label-spelled-outside-projection|s@^ let rule = label(Label::Rule, \&self.rule);$@ let rule = format!("rule '{}'", self.rule);@|no_finding_label_is_spelled_outside_the_projection impl Repair { - /// The record line, in [`crate::waiver::Suppressed`]'s shape. - /// - /// Same shape deliberately: a reader who has learned to read one audit line - /// on this channel should not need a second grammar for the other. Class - /// first, then the row, then the subject when there is one. + /// The record line, labelled so a reader can tell the class from the row + /// (CLOUD-2075): `verdict '' rule ''[ at ] repaired + /// verdict ''`. #[must_use] pub fn line_text(&self) -> String { - let class = crate::verdict::Native::CallFixSilent.id(); + use crate::refusal::{Label, label}; + let class = label(Label::Verdict, crate::verdict::Native::CallFixSilent.id()); + let rule = label(Label::Rule, &self.rule); + let repaired = label(Label::Verdict, &self.repaired); match self.subject.as_deref() { - Some(subject) => format!("{class} {subject} {} {}", self.rule, self.repaired), - None => format!("{class} {} {}", self.rule, self.repaired), + Some(subject) => format!("{class} {rule} at {subject} repaired {repaired}"), + None => format!("{class} {rule} repaired {repaired}"), + } + } +} + +impl Decision { + /// Apply `map` to the refusal a `Deny` or `Ask` carries (CLOUD-2075). + #[must_use] + pub fn map_refusal(self, map: impl FnOnce(Refusal) -> Refusal) -> Decision { + match self { + Decision::Deny(refusal) => Decision::Deny(map(refusal)), + Decision::Ask(refusal) => Decision::Ask(map(refusal)), + other @ (Decision::Allow + | Decision::Waived(_) + | Decision::Preapproved(_) + | Decision::Repaired(_)) => other, } } } @@ -3048,9 +3336,21 @@ pub fn decode(harness: Harness, raw: &str) -> Option { .find_map(|key| value.get(*key).and_then(Value::as_str)) .filter(|mode| !mode.is_empty()) .map(ToOwned::to_owned), + // CLOUD-2075: the sightings context and the SessionStart source. + agent: present_str(&value, "agent_id"), + start_source: present_str(&value, "source"), }) } +/// One non-empty string member of a decoded payload, owned. +fn present_str(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(ToOwned::to_owned) +} + /// Normalize a host's event spelling, applying that host's rename table. /// /// The converged spellings live in [`Event::normalize`] — Claude Code's, which @@ -3190,7 +3490,7 @@ pub struct Policy { /// Message composition only — it never decides whether the gate fires, which /// is why it sits beside `protected` rather than inside it and why no /// raise-only clamp applies to it. - redirects: Vec, + pub(crate) redirects: Vec, /// The agent-sourced facts this repository declares (CLOUD-776). /// /// Carried on the policy for the reason `verbs` and `protected` are: the @@ -4530,281 +4830,6 @@ fn command_line_gates(policy: &Policy, envelope: &Envelope, receipts: &ReceiptFa } } -/// The text a host reads for one refusal — the deny's whole projection. -/// -/// **No hatch sentence, on any arm.** The general hatch is removed from the -/// engine, and a row's own `bypass_env` is not advertised either: a refusal's -/// ways through are its declared routes, which `batten policy explain ` -/// prints, and an advertised variable is the cheapest concrete thing in reach -/// whether or not it is the right one (CLOUD-680). -/// # CLOUD-1286: a declared refusal emits its line and stops -/// -/// Everything below this paragraph applies to a refusal with NO declared class. -/// A declared one emits ` ` and nothing else — no `Refused by` -/// prefix, no gloss, no `Fix:` clause, and **no hatch sentence**, which is -/// CLOUD-437's defect finally removed rather than narrowed: it was identical on -/// every deny, so it was pure per-firing cost carrying no per-firing -/// information. The way through a class is its declared routes, and -/// `batten policy explain ` prints all of them; the hatch is a fact about -/// mediation that `crate::hook`'s own module header states once, where it costs -/// nothing to have already read. -/// -/// The `path write refused` arm below therefore also goes: its whole purpose was -/// to surface an override route the `Fix:` clause could not reach, and `explain` -/// now reaches every route including that one. Composing an -/// `override request` command line per firing was ~40 tokens spent to save one -/// lookup. -/// # CLOUD-1386: the ROUTE comes back, because it was never the constant part -/// -/// The paragraph above is right that a sentence identical on every deny is pure -/// per-firing cost, and the hatch stays gone for exactly that reason. It was -/// wrong to take the class's first `command` route with it. That route is not -/// constant — it differs per class, it is the shortest thing that turns a refusal -/// into the next action, and `Refusal::from_class` has already resolved it by the -/// time this runs. Dropping it made every declared refusal a bare noun phrase. -/// -/// MEASURED, and the cost was not a lookup. A session pushed with a bare -/// `--force-with-lease`, read back `branch write unsafe leased-push`, and had no -/// way to tell that the class refuses one SPELLING and that -/// `--force-with-lease=:` is allowed — which the class says outright, -/// one `explain` away. It concluded instead that the landing lease gates pushing, -/// reported that to its reviewer as a design defect, and argued for changing a -/// rule that was working correctly. The remedy existed, was declared, and was one -/// clause short of arriving. -/// -/// # ONCE PER SESSION, WHICH IS THE SHAPE BOTH EARLIER VERSIONS MISSED -/// -/// The cost CLOUD-1286 measured is a REPEAT cost: prose that arrives on every -/// firing of a class a reader has already read. The value is a FIRST-SIGHTING -/// value: a reader who has never seen this class cannot act on a bare token. Both -/// are true, and neither "always" nor "never" can hold both. -/// -/// So the class travels the first time it fires in a session and never again. -/// `first_sighting` is the caller's answer — the boundary consults a -/// session-scoped record and marks it, exactly as `expire_wiring_record` treats -/// `SessionStart` as the session identity. This function stays pure and stays the -/// one place the two renderings are chosen between. -/// -/// **A fresh session and a compacted one are the same reader**, and the host says -/// so: compaction fires `SessionStart` again (Claude Code's `source` is -/// `"compact"`, beside `"startup"`, `"resume"` and `"clear"`). The record is -/// cleared on every `SessionStart` whatever its source, so a compacted context — -/// which may no longer hold the paragraph — is told again. The guarantee is per -/// context rather than per reader, and it errs toward saying it again rather than -/// assuming it was retained; that is also why the source needs no parsing here. -/// -/// # EVERY route, because "the first one" is a choice nobody made -/// -/// The first-sighting arm renders all of the class's routes rather than the one -/// `Fix:` carries. `Fix:` takes the first because it renders on every firing and -/// a list there is the repeat cost above. Once per session that budget is not in -/// force, and picking by declaration order is not a summary — it is one -/// alternative selected arbitrarily. `leased-push` is the measurement: it declares -/// the rebase first and `--force-with-lease=:` second, and the second is -/// the one that answers the reader who just hit it. Rendering the first alone is -/// what produced the defect report this row exists for. -/// -/// # AND EVERY KIND, WHICH IS WHERE THE ARM WAS STILL SILENT (CLOUD-1637) -/// -/// "Every route" meant every `command` route, because `Refusal::from_class` -/// filled `routes` from `verdict::command_routes`. A class whose routes are all -/// `document` or `issue` therefore resolved to an empty list, this function took -/// the empty-routes early return, and the FIRST sighting emitted the same bare -/// line as a repeat. The gloss rendered on no arm at all. -/// -/// It is not an edge: 112 of 162 consumer classes and 32 of 39 vendored ones -/// declare no `command` route, so 144 of 201 classes were bare on every firing. -/// Measured live on 2026-09-08 — `tool run loose`, `verdict read dropped`, -/// `verdict carry other` and `call name refused` each fired with nothing but a -/// token, and two of them fired twice because the first firing taught nothing. -/// A three-word token is a POINTER TO A DEFINITION, and the definition was never -/// delivered; the research this row cites is unanimous that a fail signal with no -/// explanation adds nothing over baseline, and that an opaque refusal is the -/// condition under which an agent fabricates a rationale instead. -/// -/// So the arm renders ` — ; `, and the -/// routes are every non-override route rendered by kind — `run` what the agent -/// executes, `read` what it opens, `see` what it takes to the tracker. -/// [`crate::verdict::sighting_routes`] owns that mapping as an exhaustive match -/// with no wildcard arm, so a kind added later cannot be dropped silently. -/// -/// # THE RULE ID IS ON BOTH ARMS, AND `Fix::Run` IS ON NEITHER -/// -/// Two ids exist and they dereference through different verbs: the class token -/// through `batten policy explain `, and the rule id through -/// `batten policy rule `. The id is not redundant with the class — 66 -/// `[[rule]]` rows declare no class of their own and raise their kind's native -/// one, so fourteen `shape` rows all raise `call name refused` and the id is the -/// only thing that says which fired. It stays on the repeat arm too, which is -/// also what keeps the repeat a byte prefix of the first sighting. -/// -/// **What goes is the consumer `reason` reaching the line as [`Fix::Run`].** It -/// used to lead the routes clause, on the argument that a consumer's `redirect` -/// knows something the class does not. That argument does not survive what a -/// `reason` actually is: prose, written as "what to do instead" for a reader with -/// no budget pressure. `an-update-owes-a-recent-read`'s is ~700 characters and -/// ENDS by naming a different rule, so the emitted line grew a second row's id — -/// exactly what CLOUD-1286 removed the prefix to prevent, and -/// `board_receipts::an_update_is_not_row_ones_business` is what said so. -/// -/// The `reason` has a destination and this is not it: `batten policy rule ` -/// prints the refusing row's own remedy, which is where the specific answer lives -/// when the class gloss is generic. `call name refused`'s gloss says only "the -/// mediated call matches a command shape the config refuses"; that a board row is -/// read through `batten mcp call Linear get_issue` is `no-raw-issue-read`'s -/// `reason`, reachable only through the id. So [`Fix::Run`] stays a dedup key and -/// a member of `render`'s long form, and is never a rendered member of this -/// clause. -/// -/// # THE CEILING GOVERNS BOTH ARMS, EACH BY ITS OWN KEY -/// -/// The once-per-session change re-pointed `refusal_ceiling` at the SECOND firing, -/// on the sound argument that `[refusal] max_tokens` was always about repeat cost. -/// The consequence was not sound: it left the FIRST sighting bounded by nothing at -/// all, in the same commit that made the first sighting the long one. Bounding it -/// by `max_tokens` is not the repair either — the first sighting is the repeat -/// line plus a gloss and a route, so the short arm's number is a ceiling the long -/// arm can never meet. `[refusal] first_sighting_max_tokens` is the second key, -/// and [`crate::refusal::validate`] refuses one at or below its sibling. -/// -/// Over budget, ROUTES ARE DROPPED FROM THE END and the gloss never is. A whole -/// route goes rather than a truncated one: half a command is not a way out, and -/// no truncator exists in this tree — `budget.rs` offers `estimate_tokens` and -/// `estimate_tokens_over` and nothing that shortens. -/// -/// **THE IRREDUCIBLE LINE IS EMITTED, NOT TRUNCATED, AND THAT IS A DECISION.** -/// When ` — ; ` is itself over -/// budget, it goes out over budget. The gloss is undroppable by contract and the -/// first route is the floor by contract, so there is nothing left to shed; the -/// threshold bounds the route LIST, not the line, exactly as `budget::Report` -/// reports over-budget rather than rewriting a file. Emitting a bare token -/// instead would spend the one firing that could have taught the class on saying -/// nothing. -/// -/// # THE UNDECLARED ARM IS BOUNDED TOO, BECAUSE ITS SIBLING IS -/// -/// A refusal composed from consumer prose carries no class, so it keeps the long -/// form — there is no token to buy concision with, and a bare line there would be -/// the bare "no" CLOUD-122 forbids. What it was NOT was bounded: it returned -/// before any ceiling was consulted and repeated identically on every firing, -/// forever. Same function, same authority boundary, same defect class as the arm -/// above, and bounding one while leaving its sibling unbounded is half a change -/// (non-negotiable rule 2). -/// -/// So it takes the same shape. The hatch sentence is the constant part — CLOUD-437 -/// established that it is identical on every deny and therefore pure per-firing -/// cost carrying no per-firing information — so it renders on the first sighting -/// and is what the ceiling sheds. `render()` itself is never shed: reason and fix -/// are what CLOUD-122 requires, and the fix clause is the one thing nothing may -/// drop. -/// -/// **A consumer that declares no ceiling gets no bound**, which is the same answer -/// every other budget gives an undeclared row — the ceiling is the consumer's -/// statement about their own line, and inventing one here would be this crate -/// deciding a consumer fact. -#[must_use] -pub fn deny_text( - refusal: &Refusal, - first_sighting: bool, - ceiling: Option<&crate::refusal::Ceiling>, -) -> String { - if refusal.verdict().is_some() && first_sighting { - return first_sighting_line(refusal, ceiling); - } - if refusal.verdict().is_some() { - return refusal.line(); - } - refusal.render() -} - -/// What an ESCALATION carries, which is not what a refusal carries. -/// -/// **A different reader, so a different projection** (CLOUD-1637). Everything -/// [`deny_text`] does is priced against an agent's context: the class is a token -/// it can dereference with `batten policy explain`, the row's remedy one it can -/// reach with `batten policy rule `, and the whole point of the compact arm -/// is that ~300 firings a session must not each carry a paragraph. -/// -/// A person answering an escalation has none of that. They have not read an -/// earlier firing, they are not going to run a lookup to decide the question in -/// front of them, and they see this string once. So the budget that justifies the -/// dereference is not in force and the dereference costs rather than saves. -/// -/// This is [`crate::refusal::Refusal::render`]'s stated purpose rather than a new -/// projection: its own doc calls it "the projection for a surface with no budget -/// pressure — `check`'s findings, a report, anything a human reads once", against -/// [`crate::refusal::Refusal::line`] for "a surface that pays for every byte on -/// every subsequent turn". The two were already written; the ask arm was reading -/// the wrong one. -/// -/// **This is what the row's `reason` is FOR on this arm.** `deny_text` stopped -/// rendering it because a `[[rule]]` `reason` is prose and the agent has a verb -/// that fetches it. Here it is the whole of what the person reads — -/// `land-through-the-loop`'s class gloss says only that the call matches a shape -/// the config refuses, and "land through `mise run land` so `main` stays -/// fast-forward" is the sentence that lets someone answer. `ask_disposition` is -/// the suite that says so. -/// -/// The hatch is not appended: an escalation is a question put to someone who can -/// answer it, and handing them an environment variable that skips the question is -/// offering a way past the gate instead of through it. -#[must_use] -pub fn ask_text(refusal: &Refusal) -> String { - refusal.render() -} - -/// The first-sighting projection: the class definition, bounded by its own key. -/// -/// Split out of [`deny_text`] so the shedding loop is one named thing rather than -/// a block inside a four-exit function — and so the irreducible case below is a -/// `break` a reader can see rather than a condition they have to reconstruct. -/// -/// Shedding is from the END: the routes are declared "in the order a reader -/// should consider them", so the last is the one whose loss costs least. The loop -/// stops at one route rather than at zero, which is what makes the floor a floor. -fn first_sighting_line(refusal: &Refusal, ceiling: Option<&crate::refusal::Ceiling>) -> String { - let gloss = refusal.gloss(); - if gloss.is_empty() && refusal.routes().is_empty() { - // A declared class with neither is not reachable through `verdict::validate`, - // which refuses a class with no route and refuses an empty gloss. Answering - // with the compact line rather than composing an empty clause keeps that - // unreachability from rendering as a dangling em-dash if it ever is. - return refusal.line(); - } - let compose = |routes: &[String]| { - let head = if gloss.is_empty() { - refusal.line() - } else { - format!("{} — {gloss}", refusal.line()) - }; - if routes.is_empty() { - head - } else { - format!("{head}; {}", routes.join("; ")) - } - }; - let mut routes: Vec = Vec::new(); - for route in refusal.routes() { - // A class can declare the same target under two ids, and a reader met - // with the same clause twice learns that the renderer cannot count. - if !routes.contains(route) { - routes.push(route.clone()); - } - } - loop { - let candidate = compose(&routes); - // At one route there is nothing left to shed: the gloss is undroppable by - // contract and the first route is the floor by contract, so an over-budget - // line here is emitted over budget rather than made useless. - if routes.len() <= 1 - || !ceiling.is_some_and(|declared| declared.over_first_sighting(&candidate)) - { - return candidate; - } - routes.pop(); - } -} - /// The first shape row that matches the mediated command, in declaration order. /// /// Declaration order is the tie-break rather than "most specific wins": a @@ -5569,11 +5594,14 @@ fn receipt_alternation_refusal( .map(|check| format!("`{check}` {}", verdict_of(check).as_str())) .collect::>() .join(", "); + // Undeclared on purpose, so the bindings CLOUD-1996 computes for receipt + // rows do not change; the per-firing state rides as the subjects. Refusal::new( &rule.id, - format!("this call needs any one of these receipts valid, and none is: {named}"), + "this call needs any one of these receipts valid, and none is", Fix::declared(rule.reason.as_deref()), ) + .at(named) } /// Compose a receipt row's refusal, naming the check and what is wrong with it. @@ -5677,6 +5705,62 @@ fn receipt_refusal( Refusal::declared(&rule.id, native, &subjects, fix) } +//MUTANT override-subject-unchecked|s@^ if rule.kind != RuleKind::Receipt {$@ if true {@|an_override_request_naming_a_subject_no_refusal_binds_is_refused +//MUTANT bindable-subjects-empty|s@^ Some(bindable)$@ Some(Vec::new())@|a_spent_admission_clears_a_superseded_receipt +//MUTANT class-filter-dropped|s@^ if refusal.verdict() == Some(class) {$@ if true {@|a_receipt_rows_age_bound_binds_only_under_the_expiry_class +//MUTANT shape-subject-unchecked|s@^ if rule.kind == RuleKind::Shape && rule.max.is_none() && rule.requires_key.is_none() {$@ if false {@|a_shape_admission_for_an_unbindable_subject_is_refused +/// Every subject a refusal of `class` by this RECEIPT row binds, or `None` for +/// a row of any other kind (CLOUD-1996). +/// +/// A receipt row's subjects depend on the row alone — the check, the key kind, +/// and `max_age` under expiry — so the set can be listed from config by calling +/// [`receipt_refusal`] itself, which is what keeps this from being a second +/// authority over a receipt refusal's subject. Every other mediated kind takes +/// its subject from the call and cannot be listed here. +/// +/// `override request` refuses a subject outside this set, because an admission +/// bound to it would be issued, spent, and admit nothing. A future [`Validity`] +/// missing from the list below fails closed: its class gets an empty set, and a +/// request is refused naming zero subjects rather than issued silently. An +/// alternation refusal carries no class, so it contributes nothing. +#[must_use] +pub(crate) fn bindable_subjects(rule: &Rule, class: &str) -> Option> { + const NON_VALID: [Validity; 5] = [ + Validity::Missing, + Validity::Expired, + Validity::Refuted, + Validity::StaleHead, + Validity::StaleMain, + ]; + // A PLAIN SHAPE ROW BINDS WHAT THE ROW ALONE DETERMINES (CLOUD-1806): + // `shape_refusal` reads nothing from the call, so the spelling a request + // must name is computable here exactly as a receipt row's is. + if rule.kind == RuleKind::Shape && rule.max.is_none() && rule.requires_key.is_none() { + let refusal = shape_refusal(rule); + let fits = refusal.verdict() == Some(class); + return Some(if fits { + refusal.bindings().to_vec() + } else { + Vec::new() + }); + } + if rule.kind != RuleKind::Receipt { + return None; + } + let mut bindable: Vec = Vec::new(); + for check in rule.checks.iter().flatten() { + for validity in NON_VALID { + let refusal = receipt_refusal(rule, check, validity, None); + if refusal.verdict() == Some(class) { + bindable.extend_from_slice(refusal.bindings()); + } + } + } + bindable.sort_unstable(); + bindable.dedup(); + Some(bindable) +} + /// The id-free half of the pipeline verdict: which shape a command commits. /// /// Three causes rather than three rules, on [`receipt_refusal`]'s precedent — the @@ -6652,7 +6736,7 @@ fn policy_protected_paths(rule: &Rule) -> Vec { /// `None` for every other event, for could-not-look, and for a policy that /// registers no module. #[must_use] -pub fn stop_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Option { +pub fn stop_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Option { if envelope.event != Event::Stop { return None; } @@ -6675,7 +6759,7 @@ pub fn stop_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> O // refuses. The id, the cause and the remedy all travel, so the fix clause is // present exactly as that contract insists. let (_, refusal) = policy_refusal(policy, envelope, facts)?; - Some(render_advice(&refusal)) + Some(refusal) } /// The policy gate: hand every registered module the call's facts and read back @@ -6717,39 +6801,25 @@ fn policy_rules(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Deci } } -/// The advisory half of [`policy_rules`]: what a NON-blocking module violation -/// says (CLOUD-1131). +/// The advisory half of [`policy_rules`]: every NON-blocking, non-`allow` module +/// violation, one per bundle (CLOUD-1131, CLOUD-1470). /// -/// `None` for silence, for a blocking violation — that one is the caller's -/// `Decision` and saying it twice would put one finding on two channels — and for -/// an event with no advisory channel, which is the host capability table's answer -/// rather than this function's. +/// Empty for silence, for a blocking violation — that one is the caller's +/// `Decision` and saying it twice would put one finding on two channels — and at +/// `Stop`, where [`stop_advice`] stays the sole module producer (CLOUD-888's +/// one-nudge bound). Every advisory is returned rather than the strongest, +/// because two pointers on one call are two things a reader needs, and keeping +/// only the first-declared shed the second silently. /// -/// **Assembled rather than `Refusal::render`ed**, for `stop_advice`'s reason: that -/// projection opens `Refused by`, and nothing here refuses. The id, the cause and -/// the remedy all travel, so a reader still gets the class and the way out. +/// Returned as [`Refusal`]s so the renderer is the caller's choice. +//MUTANT stop-advice-multiplied|s@^ let turn_end = envelope.event == Event::Stop;$@ let turn_end = false;@|a_stop_carries_one_module_nudge_whatever_the_warn_count #[must_use] -pub fn policy_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Option { - let (severity, refusal) = policy_refusal(policy, envelope, facts)?; - if blocks(severity, policy.fail_on_warning) { - return None; +pub fn policy_advice(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Vec { + let turn_end = envelope.event == Event::Stop; + if turn_end { + return Vec::new(); } - Some(render_advice(&refusal)) -} - -/// One refusal's text on the advisory channel, with no word that claims a verdict. -fn render_advice(refusal: &Refusal) -> String { - format!( - "{}: {} {}", - refusal.rule(), - refusal.reason(), - match refusal.fix() { - crate::refusal::Fix::Run(text) => text.clone(), - crate::refusal::Fix::None => String::new(), - } - ) - .trim_end() - .to_owned() + policy_advisories(policy, envelope, facts) } /// The STRONGEST violation any enabled bundle raises, with the severity its @@ -6769,6 +6839,11 @@ fn render_advice(refusal: &Refusal) -> String { /// So the scan is total and the strongest wins. Declaration order survives as the /// tie-break, which is what keeps output byte-stable between two rows of equal /// force — the property first-match-wins was actually buying. +/// +/// An `allow` row is OFF and is skipped (CLOUD-1470): it never blocks, so this +/// changes no decision, and it stops [`stop_advice`] nudging for a rule the +/// consumer switched off. +//MUTANT allow-row-nudged|s@^ if severity == RuleSeverity::Allow {$@ if false {@|an_allow_row_nudges_nothing_at_stop fn policy_refusal( policy: &Policy, envelope: &Envelope, @@ -6812,6 +6887,9 @@ fn policy_refusal( // from the class's first `command` route, so "a refusal names a way // out" holds by construction rather than by each module's care. let severity = bundle.severity_for(violation.rule.as_deref()); + if severity == RuleSeverity::Allow { + continue; + } // STRICTLY GREATER, so an equal severity leaves the incumbent in // place and declaration order remains the tie-break. if strongest.as_ref().is_none_or(|(held, _)| severity > *held) { @@ -6845,6 +6923,46 @@ fn policy_refusal( strongest } +/// Every non-blocking, non-`allow` module violation over this call, the first +/// per bundle in declaration order (CLOUD-1470). +/// +/// A blocking violation empties the result: it is the call's decision, and +/// saying it twice would put one finding on two channels. +//MUTANT advice-strongest-only|s@^ advised$@ advised.into_iter().take(1).collect()@|two_warn_advisories_on_one_call_both_arrive +//MUTANT allow-row-advised|s@^ if severity == RuleSeverity::Allow {$@ if false {@|an_allow_row_advises_nothing +fn policy_advisories(policy: &Policy, envelope: &Envelope, facts: &Facts<'_>) -> Vec { + if policy.bundles.is_empty() { + return Vec::new(); + } + let Ok(input) = call_document(envelope, facts) else { + return Vec::new(); + }; + let mut advised = Vec::new(); + for bundle in &policy.bundles { + let crate::facts::Look::Is(denials) = crate::policy::deny(bundle, &input) else { + continue; + }; + let Some(violation) = denials.first() else { + continue; + }; + let severity = bundle.severity_for(violation.rule.as_deref()); + if blocks(severity, policy.fail_on_warning) { + return Vec::new(); + } + if severity == RuleSeverity::Allow { + continue; + } + advised.push(Refusal::from_class( + bundle.attribute(violation), + &policy.verdicts, + &violation.verdict, + &violation.subjects, + Fix::None, + )); + } + advised +} + /// The first pre-approval any module grants this call, as the host's reason /// text (CLOUD-1949). /// @@ -10169,6 +10287,46 @@ pub fn encode_ask( } } +/// Encode a `PostToolUse` tool-output rewrite for `harness`, or `None` where +/// the host is not measured to honour one (CLOUD-2075). +/// +/// The gate only; [`tool_output_document`] owns the bytes. +/// +/// # Errors +/// +/// Serialization of this fixed shape cannot practically fail. +//MUTANT rewrite-ungated|s@^ if !harness.capabilities().rewrites_tool_output.is_capturable() {$@ if false {@|the_tool_output_rewrite_is_emitted_only_where_measured +pub fn encode_tool_output( + harness: Harness, + event: &str, + result: &Value, +) -> serde_json::Result> { + if !harness.capabilities().rewrites_tool_output.is_capturable() { + return Ok(None); + } + tool_output_document(event, result).map(Some) +} + +/// The `PostToolUse` document replacing what the model reads of a tool's output +/// (CLOUD-2075), whatever any harness declares — so its shape is testable apart +/// from the gate in [`encode_tool_output`]. +/// +/// **It relays the host's own tool output back to the same host**: the bytes go +/// to the channel they came from, unchanged except for finding lines, which is +/// the one exception to [`Envelope::result`]'s "never reproduced". +/// +/// # Errors +/// +/// Serialization of this fixed shape cannot practically fail. +pub fn tool_output_document(event: &str, result: &Value) -> serde_json::Result { + serde_json::to_string(&serde_json::json!({ + "hookSpecificOutput": { + "hookEventName": event, + "updatedToolOutput": result, + } + })) +} + /// Encode an **advisory** body for `harness`, or `None` where no non-blocking /// channel to the model is reachable on this surface (CLOUD-461). /// @@ -10440,16 +10598,14 @@ mod tests { }] } - /// CLOUD-1386: the first sighting carries EVERY declared `command` route. + /// CLOUD-2075: both arms carry EVERY route, the override included as the + /// ready request that admits; only the full arm adds the definition. /// /// Measured on `leased-push`, which declares the rebase first and the - /// explicit `--force-with-lease=:` second. A session that read only - /// the first could not tell the class refuses a SPELLING rather than the - /// action, and reported a working gate as a design defect. So the assertion - /// that matters is on the SECOND route: a renderer taking `Fix:`'s single - /// route passes every other clause of this case. + /// explicit `--force-with-lease=:` second — the second is the one + /// that answers the reader, so it is the assertion that matters. #[test] - fn a_first_sighting_carries_every_command_route() { + fn both_arms_carry_every_route() { let registry = two_route_class(); let refusal = Refusal::from_class( "leased-push", @@ -10458,65 +10614,34 @@ mod tests { &[], crate::refusal::Fix::None, ); - let text = deny_text(&refusal, true, None); - assert!( - text.contains("git push --force-with-lease=:"), - "the second route is the one that answers the reader: {text}" - ); - assert!(text.contains("git pull --rebase"), "{text}"); + for arm in [crate::refusal::Arm::Full, crate::refusal::Arm::Pointer] { + let text = refusal.render_finding(arm); + assert!( + text.contains("run git push --force-with-lease=:"), + "{text}" + ); + assert!(text.contains("run git pull --rebase"), "{text}"); + assert!( + text.contains( + "admit with batten override request --rule 'leased-push' --verdict \ + 'branch write unsafe' --subject '" + ), + "{text}" + ); + } assert!( - !text.contains("override"), - "a way out that begins by asking to be excused is not an alternative: {text}" + !refusal + .render_finding(crate::refusal::Arm::Pointer) + .contains(" —") ); } - /// The repeat is the bare line — the cost CLOUD-1286 measured, still unpaid. - #[test] - fn a_repeat_sighting_carries_no_route_at_all() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::None, - ); - let text = deny_text(&refusal, false, None); - assert!(!text.contains("git pull --rebase"), "{text}"); - assert!(!text.contains(" — "), "{text}"); - } - - /// A first sighting is bounded by the SAME declared ceiling as a repeat. - /// - /// The once-per-session change re-pointed `refusal_ceiling` at the second - /// firing, which left this arm bounded by nothing in the same commit that made - /// it the long one. Measured on a consumer `[[rule]]` row's `reason` reaching - /// `Fix::Run` as prose — ~700 characters ending in a DIFFERENT rule's id, which - /// is what `board_receipts::an_update_is_not_row_ones_business` caught. - /// - /// Dropped WHOLE rather than truncated: half a command is not a way out, and - /// the class token is still on the line for `batten policy explain`. - /// THE FIXTURE IS PROSE BECAUSE THE DEFECT WAS PROSE, and a shorter one does - /// not reach the bound. Measured while writing this: the class's own two - /// routes compose an 82-character line — about 20 estimated tokens, UNDER the - /// declared 24 — so a case built on them asserts the ceiling drops something - /// it never had cause to drop, and fails for being wrong about its own - /// premise rather than about the engine. - /// - /// A consumer `[[rule]]` row's `reason` is what actually arrives here, and - /// `an-update-owes-a-recent-read`'s is ~700 characters ending in another - /// rule's id. This mirrors that shape rather than lowering the ceiling until - /// a short line trips it, which would have measured the fixture. - const PROSE_FIX: &str = "Re-read the row. That is the whole remedy: read it \ - again with its relations, and the receipt mints itself from that result \ - — there is no second call and no payload to pipe anywhere. Then make the \ - write from what you just read, not from the plan you built earlier: if \ - the row changed, that is the point, so decide again. This bounds how old \ - the read was; it cannot prove the row is unchanged, because the tracker \ - offers no precondition on write."; + /// The prose a consumer row declares as its remedy reaches the FULL arm + /// (CLOUD-2075 reverses CLOUD-1637's omission), and never the pointer arm. + const PROSE_FIX: &str = "Re-read the row, then write from what you just read."; #[test] - fn a_first_sighting_over_the_declared_ceiling_drops_its_routes() { + fn a_rows_remedy_rides_the_full_arm_only() { let registry = two_route_class(); let refusal = Refusal::from_class( "row-one", @@ -10525,148 +10650,19 @@ mod tests { &[], crate::refusal::Fix::Run(PROSE_FIX.to_owned()), ); - let unbounded = deny_text(&refusal, true, None); - // THE PREMISE HAS MOVED, and the move is CLOUD-1637's (see `deny_text`). - // The prose no longer reaches the line on ANY arm — `Fix::Run` is a dedup - // key and never a rendered member — so what the ceiling sheds is the - // class's own routes, from the end, and never the gloss. - assert!( - !unbounded.contains("Re-read the row"), - "a consumer `reason` never reaches the emitted line: {unbounded}" - ); - assert!( - unbounded.contains("git pull --rebase") - && unbounded.contains("git push --force-with-lease=:"), - "the premise: unbounded, this arm carries both routes — {unbounded}" - ); - // Admits the head and the first route, refuses the second. - let one_route = crate::refusal::Ceiling { - max_tokens: 24, - first_sighting_max_tokens: Some( - crate::budget::estimate_tokens(&unbounded).saturating_sub(2), - ), - }; - let bounded = deny_text(&refusal, true, Some(&one_route)); - assert!( - bounded.contains("git pull --rebase"), - "the first route is the floor: {bounded}" - ); - assert!( - !bounded.contains("git push --force-with-lease=:"), - "the last route is what sheds: {bounded}" - ); + let full = refusal.render_finding(crate::refusal::Arm::Full); + assert!(full.contains(PROSE_FIX), "{full}"); assert!( - bounded.contains(" — branch write unsafe"), - "and the gloss is never what sheds: {bounded}" - ); - } - - /// The irreducible line goes out OVER budget rather than losing its meaning. - /// - /// The threshold bounds the route LIST, not the line. With the gloss - /// undroppable by contract and the first route the floor by contract, there is - /// nothing left to shed — and no truncator exists in this tree. Pinned here so - /// the decision is visible rather than emergent. - #[test] - fn an_irreducible_first_sighting_is_emitted_over_budget() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::None, - ); - let ceiling = crate::refusal::Ceiling { - max_tokens: 24, - first_sighting_max_tokens: Some(1), - }; - let text = deny_text(&refusal, true, Some(&ceiling)); - assert!( - ceiling.over_first_sighting(&text), - "the premise: nothing this arm can compose fits a ceiling of 1 — {text}" - ); - assert!( - text.contains(" — branch write unsafe"), - "the gloss is undroppable: {text}" - ); - assert!( - text.contains("git pull --rebase"), - "and the first route is the floor: {text}" - ); - } - - /// And a SHORT route still travels, or the bound above is just the old - /// never-render behaviour wearing a ceiling. - /// - /// The class's own two routes at the REAL declared ceiling of 24, which is the - /// case the row exists for: `leased-push`'s explicit lease form has to reach a - /// reader who has just been refused, and it does. - #[test] - fn a_first_sighting_inside_the_ceiling_still_carries_its_routes() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::None, - ); - let ceiling = crate::refusal::Ceiling { - max_tokens: 24, - first_sighting_max_tokens: None, - }; - let text = deny_text(&refusal, true, Some(&ceiling)); - assert!( - text.contains("git push --force-with-lease=:"), - "{text}" - ); - } - - /// The caller's narrower alternative is a dedup key and is never rendered. - /// - /// **This case reverses, and CLOUD-1637 is where** (see `deny_text`). It used - /// to assert that a consumer's `redirect` LED the routes clause, on the - /// argument that it knows something the class does not. What actually arrives - /// through `Fix::Run` is a `[[rule]]` row's `reason` — prose written as "what - /// to do instead" for a reader with no budget pressure, one of which is ~700 - /// characters and ends by naming a DIFFERENT rule. So the emitted line grew a - /// second row's id, which is what CLOUD-1286 removed the prefix to prevent. - /// - /// The `reason` has its own destination: `batten policy rule `, reached - /// through the rule id this line still carries. What is asserted now is that - /// it reaches the line through neither. - #[test] - fn a_narrower_fix_is_never_a_rendered_member() { - let registry = two_route_class(); - let refusal = Refusal::from_class( - "leased-push", - ®istry, - "branch write unsafe", - &[], - crate::refusal::Fix::Run(PROSE_FIX.to_owned()), - ); - let text = deny_text(&refusal, true, None); - assert!( - !text.contains("Re-read the row"), - "a consumer `reason` is not a rendered member: {text}" - ); - let routes = text.split("; ").skip(1).collect::>().join("; "); - assert!( - routes.starts_with("run git pull --rebase"), - "the class's own first route leads instead: {text}" - ); - assert!( - routes.contains("run git push --force-with-lease=:"), - "{text}" + !refusal + .render_finding(crate::refusal::Arm::Pointer) + .contains("Re-read the row") ); } /// A route the class declares twice is said once. /// - /// The dedup the case above used to cover incidentally. Two ids may point at - /// one target, and a reader met with the same clause twice learns that the - /// renderer cannot count. + /// Two ids may point at one target, and a reader met with the same clause + /// twice learns that the renderer cannot count. #[test] fn a_repeated_route_target_is_rendered_once() { let mut registry = two_route_class(); @@ -10682,7 +10678,7 @@ mod tests { &[], crate::refusal::Fix::None, ); - let text = deny_text(&refusal, true, None); + let text = refusal.render_finding(crate::refusal::Arm::Full); assert_eq!(text.matches("run git pull --rebase").count(), 1, "{text}"); } @@ -11063,6 +11059,8 @@ mod tests { reads: None, cwd: None, session: None, + agent: None, + start_source: None, // The Stop-path fields (CLOUD-479) are absent on a PreTool envelope, // which is the honest shape rather than a filler value. stop_active: None, @@ -11106,6 +11104,8 @@ mod tests { reads: None, cwd: None, session: None, + agent: None, + start_source: None, stop_active: None, last_message: None, transcript: None, @@ -11426,7 +11426,7 @@ mod tests { let line = suppressed.line_text(); assert!(!line.contains("gh pr merge"), "{line}"); assert!(!line.contains("42"), "{line}"); - assert_eq!(line, "waived gh-pr-merge (expires 2099-01-01)"); + assert_eq!(line, "waived rule 'gh-pr-merge' (expires 2099-01-01)"); } #[test] @@ -11441,8 +11441,8 @@ mod tests { .find("pub fn adjudicate(") .expect("adjudicate is defined here"); let end = source[start..] - .find("/// The text a host reads for one refusal") - .expect("the chain ends before deny_text"); + .find("fn event_decides(") + .expect("the chain ends before the per-event table"); let body = &source[start..start + end]; for clock in ["SystemTime", "waiver::today", "today()", "Date"] { assert!( @@ -11479,7 +11479,7 @@ mod tests { // the class is new to them — the one these assertions are about. The // repeat rendering has its own cases, where the difference IS the // subject rather than incidental to it. - Decision::Deny(refusal) => deny_text(&refusal, true, None), + Decision::Deny(refusal) => refusal.render_finding(crate::refusal::Arm::Full), // An `Ask` is not a deny, and collapsing the two here would let a // row that silently started escalating keep passing every assertion // below about what a refusal says. A `Waived` is not one either, and @@ -12907,16 +12907,16 @@ deny contains "refused by themodule" if { // // OVER THE EMITTED LINE, NOT THE STRUCT (CLOUD-1637). The // `Refusal` now CARRIES the gloss — resolved where the registry is - // in hand, so `deny_text` stays pure — and reading a `{:?}` of the + // in hand, so the projection stays pure — and reading a `{:?}` of the // struct would fail on a field whose presence is the design. What // the row is about is which ARM renders it: the repeat never does, // and the first sighting is the one firing that must. - let repeat = deny_text(&refusal, false, None); + let repeat = refusal.render_finding(crate::refusal::Arm::Pointer); assert!( !repeat.contains("the fixture class"), "a repeat dereferences the gloss rather than carrying it: {repeat}" ); - let first = deny_text(&refusal, true, None); + let first = refusal.render_finding(crate::refusal::Arm::Full); assert!( first.contains("the fixture class"), "and the first sighting is where it does travel: {first}" @@ -13385,7 +13385,7 @@ deny contains "refused by themodule" if { ]))) else { panic!("a stale receipt must deny"); }; - let rendered = refusal.render(); + let rendered = refusal.render_finding(crate::refusal::Arm::Full); assert!(rendered.contains("linear-check"), "got: {rendered}"); // WHAT INVALIDATED IT IS THE CLASS, not a phrase inside a sentence // (CLOUD-1285, then CLOUD-1286). `receipt read other` is the amend-or- @@ -13451,7 +13451,7 @@ deny contains "refused by themodule" if { let Decision::Deny(refusal) = write_guarded("Write", "batten.toml") else { panic!("a declared write verb against a protected path must deny"); }; - let rendered = refusal.render(); + let rendered = refusal.render_finding(crate::refusal::Arm::Full); assert!(rendered.contains("Write"), "got: {rendered}"); assert!(rendered.contains("batten.toml"), "got: {rendered}"); assert!( @@ -13809,10 +13809,10 @@ deny contains "refused by themodule" if { /// The subset of those the ENGINE ITSELF raises at the mediated boundary. /// /// **The surface is the whole distinction, and the gate's first run taught it.** - /// `Refusal::subject()` has exactly one consumer, `admit_mediated`, so the + /// `Refusal::bindings()` has exactly one consumer, `admit_mediated`, so the /// binding is what a MEDIATED refusal needs. A tree-scoped class is admitted /// through `apply_admissions`, which is anchored by the finding's fingerprint - /// and keyed on the finding's own path — `subject()` never enters it. The + /// and keyed on the finding's own path — `bindings()` never enters it. The /// landing-loop preset's `head grade twice` is one of those: its module emits /// `subjects: [{"artifact": sha}]` and it is admitted through the finding path, /// which is why demanding a boundary binding of it would fail a class that is @@ -13845,6 +13845,9 @@ deny contains "refused by themodule" if { if class == crate::verdict::Native::HistoryDropUnpushed.id() { return history_drop_refusal(&["aaaaaaa".to_owned()]); } + if class == crate::verdict::Native::ShapeRefused.id() { + return shape_refusal(&shape("r", "x", None)); + } if class == crate::verdict::Native::ReceiptSuperseded.id() { return receipt_refusal( &shape("r", "unused", None), @@ -13869,22 +13872,15 @@ deny contains "refused by themodule" if { ) } - /// A class that advertises an override must carry something to bind it to - /// (CLOUD-1871). - /// - /// a declared route is the only way through for any class - /// declaring an override route with a precondition, on the stated ground that - /// such a class "already has a way through that leaves a record … and - /// `admit_mediated` honours the spent admission". `admit_mediated` returns - /// early unless the refusal carries a subject. So the two must agree, and that - /// function's own doc says what it costs when they do not: *"a disagreement - /// here would mean a class the hatch stopped opening and no admission could - /// open either, which is the wall in its worst form."* + /// A class that advertises an override must bind the spelling it PRINTS + /// (CLOUD-1871, CLOUD-1826). /// - /// They disagreed. `history drop unpushed` names a count and per-commit - /// artifacts; `receipt read other` names artifacts and deliberately no path. - /// Both were unadmittable and unbypassable — measured by two admissions spent - /// against a `git reset --hard` that refused unchanged after each. + /// A declared override route is the only way through such a class, and + /// `admit_mediated` asks the store about `bindings()` alone. The first binding + /// must be the printed pointers in request spelling, because a reader pastes + /// the line: the protected sample `rm .serena/memories/core.md` prints + /// `.serena/memories/core.md rm`, and binding the bare path left that paste + /// honoured by nothing. #[test] fn every_class_declaring_an_override_route_can_be_bound() { let overridable = mediated_classes_with_an_override_route(); @@ -13900,43 +13896,80 @@ deny contains "refused by themodule" if { Some(class.as_str()), "a sample must carry the class it stands for" ); - assert!( - refusal.subject().is_some(), - "{class} declares an override route, which takes the hatch away, and \ - its refusal carries nothing an admission can bind — so the class has \ - no way through at all: {}", - refusal.render() + let printed = refusal + .reason() + .strip_prefix(class.as_str()) + .expect("a declared reason leads with its token") + .trim(); + let want = + crate::verdict::bound_subject(if printed.is_empty() { class } else { printed }); + assert_eq!( + refusal.bindings().first(), + Some(&want), + "{class} must bind the pointers its line prints: {}", + refusal.render_finding(crate::refusal::Arm::Full) ); } } - /// The anti-vacuity arm, and it is load-bearing (CLOUD-1871). + /// A refusal naming nothing binds its class in the spelling a request + /// stores (CLOUD-1826). /// - /// Without it the case above is satisfied by "every refusal names a subject", - /// which is a different and FALSE claim. A class that keeps its hatch has a - /// way through already and owes no binding: `call count over` names two counts - /// because two numbers are what a reader acts on, and a count is not an - /// identity — binding one would let an admission for "1 commit" fit a - /// different single commit. + /// The raw token `call name refused` was a binding no `override request` + /// could produce, since the request rejoins whitespace with `,`. The ceiling + /// class keeps its hatch and declares no override route, which is why a + /// count-only binding there stays inert. #[test] - fn a_class_that_keeps_its_hatch_owes_no_binding() { - let ceiling = ceiling_refusal(&ceiling_row("c", "Task", 10), 11, 10); + fn a_refusal_naming_nothing_binds_its_class_as_a_request_spells_it() { assert!( !mediated_classes_with_an_override_route() .iter() .any(|class| class == crate::verdict::Native::CeilingExceeded.id()), - "this arm is about a class that keeps its hatch; if the ceiling class \ - gained an override route it belongs in the case above instead" + "the ceiling class keeps its hatch; if it gained an override route its \ + count binding would no longer be inert" ); - assert!( - ceiling.subject().is_none(), - "a count is not an identity, so it must not become a binding an \ - admission could harvest: {}", - ceiling.render() + let refusal = shape_refusal(&shape("r", "x", None)); + assert_eq!( + refusal.bindings(), + [crate::admission::subject_as_bound("call name refused")], + "{}", + refusal.render_finding(crate::refusal::Arm::Full) + ); + assert_eq!(refusal.bindings(), ["call,name,refused"]); + } + + /// A receipt row's bindable subjects are listed per class, and its age + /// bound binds only under the expiry class it is the measure for + /// (CLOUD-1996). A row of any other kind has no listable set. + #[test] + fn a_receipt_rows_age_bound_binds_only_under_the_expiry_class() { + let mut rule = shape("r", "unused", None); + rule.kind = RuleKind::Receipt; + rule.checks = Some(vec!["verify".to_owned()]); + rule.max_age = Some(300); + assert_eq!( + bindable_subjects(&rule, "receipt read late"), + Some(vec!["verify,commit,300s".to_owned()]) + ); + assert_eq!( + bindable_subjects(&rule, "receipt read other"), + Some(vec!["verify,commit".to_owned()]) + ); + assert_eq!( + bindable_subjects(&rule, "receipt read missing"), + Some(vec!["verify,commit".to_owned()]) ); + // A plain shape row only ever raises `call name refused` (CLOUD-1806). + rule.kind = RuleKind::Shape; + assert_eq!( + bindable_subjects(&rule, "receipt read other"), + Some(Vec::new()) + ); + rule.max = Some(1); + assert_eq!(bindable_subjects(&rule, "receipt read other"), None); } - /// Every artifact the refusal names travels in the binding (CLOUD-1871). + /// Every pointer the refusal prints travels in the binding (CLOUD-1871). /// /// Binding one of several would let an admission earned for one commit admit a /// later reset discarding that commit AND another — @@ -13946,15 +13979,14 @@ deny contains "refused by themodule" if { fn a_refusal_naming_several_artifacts_binds_all_of_them() { let one = history_drop_refusal(&["aaaaaaa".to_owned()]); let two = history_drop_refusal(&["aaaaaaa".to_owned(), "bbbbbbb".to_owned()]); - assert_eq!(one.subject(), Some("aaaaaaa")); + assert_eq!(one.bindings(), ["1,aaaaaaa"]); assert_eq!( - two.subject(), - Some("aaaaaaa,bbbbbbb"), + two.bindings(), + ["2,aaaaaaa,bbbbbbb"], "every commit the refusal names travels in the binding" ); - assert_ne!( - one.subject(), - two.subject(), + assert!( + one.bindings().iter().all(|b| !two.bindings().contains(b)), "an admission for one commit must not fit a reset discarding two" ); } @@ -14179,7 +14211,7 @@ deny contains "refused by themodule" if { ); assert!( !text.contains(" Fix: "), - "nor the remedy, which `batten policy explain` prints: {text}" + "nor a `Fix:` clause: the remedy rides the full arm as a sentence: {text}" ); assert!( text.contains(refusal.rule()), @@ -14202,9 +14234,11 @@ deny contains "refused by themodule" if { !text.contains("BYPASS"), "no deny advertises the hatch on the hot path: {text}" ); + // CLOUD-2075 REVERSES the half that followed: an override route is + // a way out, so it renders as the ready request on every firing. assert!( - !text.contains("batten override request"), - "and none composes an override command line per firing: {text}" + text.contains("admit with batten override request --rule '"), + "the override route is on the line, as the request that admits: {text}" ); } } @@ -14244,8 +14278,8 @@ deny contains "refused by themodule" if { // is the token and the pointer and stops (CLOUD-1286), so the gloss's // opening parenthesis is the thing that must NOT be there. assert!( - reason.starts_with("path write refused"), - "the hot path leads with the token: {reason}" + reason.starts_with("verdict 'path write refused'"), + "the hot path leads with the labelled token: {reason}" ); assert!( !reason.contains("path write refused ("), @@ -14274,8 +14308,10 @@ deny contains "refused by themodule" if { .contains("\"fix\":null"), "the key is present and null" ); - assert!(refusal.render().contains("Fix: none declared")); - assert!(refusal.render().contains("surface that owns it")); + // The way out is the row's own hop (CLOUD-2075), never a bare "no". + let line = refusal.render_finding(crate::refusal::Arm::Full); + assert!(line.contains("run batten policy rule 'some-row'"), "{line}"); + assert!(line.contains("it fired"), "{line}"); } /// Adjudicate against the protected fixture with a declared redirect table. @@ -15475,6 +15511,44 @@ deny contains "refused by themodule" if { ); } + /// The `PostToolUse` rewrite is emitted only where it is MEASURED + /// (CLOUD-2075 §7 case 0, CLOUD-1961's class). The document's shape is the + /// host's own `tool_response` with only `stdout`/`stderr` replaced, whatever + /// any harness declares; the gate is every harness's declaration. + #[test] + fn the_tool_output_rewrite_is_emitted_only_where_measured() { + let rewritten = serde_json::json!({ + "stdout": "rule 'r'; run batten policy rule 'r'", + "stderr": "", + "interrupted": false, + }); + let document: Value = serde_json::from_str( + &tool_output_document("PostToolUse", &rewritten).expect("serializes"), + ) + .expect("the document is JSON"); + assert_eq!( + document["hookSpecificOutput"]["hookEventName"], + "PostToolUse" + ); + assert_eq!( + document["hookSpecificOutput"]["updatedToolOutput"], + rewritten + ); + for harness in Harness::ALL { + let declared = harness.capabilities().rewrites_tool_output; + let encoded = + encode_tool_output(*harness, "PostToolUse", &rewritten).expect("serializes"); + if declared.is_capturable() { + assert!(encoded.is_some(), "{harness:?} declares the rewrite"); + } else { + assert_eq!(encoded, None, "{harness:?} is not measured to rewrite"); + } + // No live probe has answered for any host yet, so every row is + // `Unknown` until one does — never a guessed `Yes` or `No`. + assert_eq!(declared, Declaration::Unknown, "{harness:?}"); + } + } + /// A `warn` at `PreToolUse` reaches the agent, and this is the measurement. /// /// CLOUD-1131's acceptance clause is that the signal is OBSERVED reaching the @@ -15966,7 +16040,7 @@ deny contains "refused by themodule" if { ) else { panic!("a host-spelled write against a protected path must deny"); }; - let rendered = refusal.render(); + let rendered = refusal.render_finding(crate::refusal::Arm::Full); assert!(rendered.contains("WriteFile"), "got: {rendered}"); assert!( rendered.contains("change it in a pull request"), @@ -16157,6 +16231,8 @@ deny contains "refused by themodule" if { reads: None, cwd: None, session: None, + agent: None, + start_source: None, stop_active: None, last_message: None, transcript: None, diff --git a/crates/batten/src/hookcost.rs b/crates/batten/src/hookcost.rs index 9b13dace3..ae41263b3 100644 --- a/crates/batten/src/hookcost.rs +++ b/crates/batten/src/hookcost.rs @@ -211,19 +211,35 @@ pub const REPEAT_RULE: &str = "hook-repeat-pointer"; /// /// **No I/O and no clock**, which is what lets the second test tier run this over /// a fixture transcript and get the same answer the live path would. +/// +/// **Repeats are counted per compaction cycle** (CLOUD-2075): every +/// [`Event::SessionBoundary`] opens a new segment, because the contract is a +/// finding's full arm ONCE per cycle — so a full arm after a `SessionStart` is the +/// one copy that cycle holds, not a repeat. An emission carrying labelled +/// findings is judged per finding: a full arm counts per `(segment, key)`, and a +/// pointer arm never counts, since pointing is what a repeat is meant to do. +/// Unlabelled output keeps the `(segment, hook, digest)` key. +//MUTANT-SUITE crates/batten/src/hookcost.rs +//MUTANT segment-not-reset|s@^ segment += 1;$@ segment += 0;@|a_full_arm_after_a_session_start_is_not_a_repeat_and_a_pointer_arm_never_is #[must_use] pub fn measure(stream: &Stream) -> Reading { let mut per_hook: BTreeMap = BTreeMap::new(); - // Keyed on (producer, digest) so one hook saying two different things is two - // entries and two hooks saying one thing is two entries. Collapsing either - // way would report a repeat that nobody made. - let mut seen: BTreeMap<(String, String), (usize, usize)> = BTreeMap::new(); + // Keyed on (segment, producer, digest) so one hook saying two different + // things is two entries and two hooks saying one thing is two entries. + // Collapsing either way would report a repeat that nobody made. + let mut seen: BTreeMap<(usize, String, String), (usize, usize)> = BTreeMap::new(); let mut tokens = 0; + let mut segment: usize = 0; for record in &stream.records { + if record.event == Event::SessionBoundary { + segment += 1; + continue; + } let Event::HookOutput { hook, tokens: cost, digest, + findings, } = &record.event else { continue; @@ -235,15 +251,30 @@ pub fn measure(stream: &Stream) -> Reading { }); entry.tokens += cost; entry.emissions += 1; - let slot = seen - .entry((hook.clone(), digest.clone())) - .or_insert((0, record.line)); - slot.0 += 1; + if findings.is_empty() { + let slot = seen + .entry((segment, hook.clone(), digest.clone())) + .or_insert((0, record.line)); + slot.0 += 1; + continue; + } + for (key, arm) in findings { + if *arm == crate::refusal::Arm::Pointer { + continue; + } + // The key's last field is the definition's digest; the names before + // it are pointers, and the report carries only the digest prefix. + let digest = key.rsplit('\u{1f}').next().unwrap_or(key).to_owned(); + let slot = seen + .entry((segment, hook.clone(), digest)) + .or_insert((0, record.line)); + slot.0 += 1; + } } let repeats = seen .into_iter() .filter(|(_, (count, _))| *count > 1) - .map(|((hook, digest), (count, first_line))| Repeat { + .map(|((_, hook, digest), (count, first_line))| Repeat { hook, // A PREFIX. Eight characters name the thing in a report; the whole // digest would let a reader who already holds a candidate text @@ -334,10 +365,47 @@ mod tests { hook: hook.to_owned(), tokens, digest: digest.to_owned(), + findings: Vec::new(), + }, + } + } + + /// One emission carrying one labelled finding of `key` on `arm`. + fn finding_at(line: usize, key: &str, arm: crate::refusal::Arm) -> Record { + Record { + line, + event: Event::HookOutput { + hook: "PreToolUse:Bash".to_owned(), + tokens: 10, + digest: format!("{line:012}"), + findings: vec![(key.to_owned(), arm)], }, } } + #[test] + fn a_full_arm_after_a_session_start_is_not_a_repeat_and_a_pointer_arm_never_is() { + use crate::refusal::Arm; + let key = "r\u{1f}c\u{1f}abcdef0123456789"; + let boundary = |line| Record { + line, + event: Event::SessionBoundary, + }; + let mut records = vec![finding_at(1, key, Arm::Full), boundary(2)]; + records.push(finding_at(3, key, Arm::Full)); + records.extend((4..9).map(|line| finding_at(line, key, Arm::Pointer))); + let clean = measure(&session(records, 4_000)); + assert!(clean.repeats.is_empty(), "{:?}", clean.repeats); + assert!(judge(&clean, Some(&Ceiling::once())).is_empty()); + + let twice = measure(&session( + vec![finding_at(1, key, Arm::Full), finding_at(2, key, Arm::Full)], + 4_000, + )); + assert_eq!(twice.repeats.len(), 1, "two full arms in one cycle repeat"); + assert_eq!(judge(&twice, Some(&Ceiling::once())).len(), 1); + } + fn session(records: Vec, bytes: usize) -> Stream { Stream { session: Some("s-1".to_owned()), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 402fdaa20..bfb2d6099 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -1555,12 +1555,16 @@ fn run_provision_apply( let config = resolve::resolve(Path::new("."), overrides)?; let repo = git::repo_root(Path::new("."))?; let cache = provision::cache_root(&repo)?; + // ONLY AN INSTALLED ENGINE PROVISIONS THE HOST (CLOUD-2124): a build of + // this checkout fills the repository's cache and leaves the host's links + // to the binary that owns them. + let links_host = std::env::current_exe().map_or(true, |exe| provision::links_host(&exe, &repo)); for entry in &config.provisions { // A checksum mismatch propagates as a `Denial` (exit 2) from here, so // the loop stops at the first bad artifact rather than going on to // install the rest under a verdict that already failed. - let applied = provision::apply(entry, &cache, dry_run)?; + let applied = provision::apply(entry, &cache, dry_run, links_host)?; let verb = match applied { provision::Applied::Installed => "installed", provision::Applied::AlreadyFresh => continue, @@ -1624,7 +1628,7 @@ fn run_init( file = config::CONFIG_FILE )), ); - output::verdict(err, &refusal.render())?; + output::verdict(err, &refusal.render_finding(refusal::Arm::Full))?; Ok(ExitCode::Violation) } } @@ -1759,7 +1763,7 @@ fn run_hk( }], Fix::None, ); - output::verdict(err, &refusal.render())?; + output::verdict(err, &refusal.render_finding(refusal::Arm::Full))?; Ok(ExitCode::Violation) } } @@ -8233,6 +8237,9 @@ fn run_override_spend( } } +//MUTANT-SUITE crates/batten/tests/it/admission.rs +//MUTANT fingerprint-unaddressed|s@path == subject || fingerprint == subject@path == subject@|twin_findings_on_one_path_are_each_admitted_by_fingerprint +//MUTANT fingerprint-binds|s@Ok((admission::Anchor::Finding(fingerprint), path))@Ok((admission::Anchor::Finding(fingerprint), subject.to_owned()))@|twin_findings_on_one_path_are_each_admitted_by_fingerprint /// The [`admission::Anchor`] a `(rule, subject)` pair is answered about /// (CLOUD-1125). /// @@ -8286,15 +8293,18 @@ fn admission_anchor( config: &resolve::Resolved, rule: &str, subject: &str, -) -> Result { +) -> Result<(admission::Anchor, String)> { // The HEAD is READ, never defaulted. `unwrap_or_default()` here would bind // `call:` on a repository this cannot resolve, so two different // could-not-look states would share one address and an admission minted in // either would spend against the other. - let head = || -> Result { - Ok(admission::Anchor::Call { - head: git::head_commit(root)?, - }) + let head = || -> Result<(admission::Anchor, String)> { + Ok(( + admission::Anchor::Call { + head: git::head_commit(root)?, + }, + subject.to_owned(), + )) }; // `RunOverSelection`, never `Run`: this is a NARROWED read — one rule, one // subject — and registry equality's exhausted half is a property of the whole @@ -8444,16 +8454,41 @@ fn admission_anchor( ) else { return head(); }; - let mut matched: Vec = scan - .findings + anchor_among(&scan.findings, rule, subject, head) +} + +/// Which one of `findings` a `(rule, subject)` pair addresses, and the subject +/// that binds it — [`admission_anchor`]'s decision over the scan it ran. +fn anchor_among( + findings: &[rules::Finding], + rule: &str, + subject: &str, + head: impl FnOnce() -> Result<(admission::Anchor, String)>, +) -> Result<(admission::Anchor, String)> { + // THE PATH OR THE FINDING'S OWN FINGERPRINT (CLOUD-2126). A pointer is the + // first path-bearing subject, so two findings of one rule over one path — + // `issue file same` for two rows naming the same file — print the same + // pointer and differ only in identity. Matching the path alone left that + // pair with no admission route at all; the fingerprint is the pointer that + // tells them apart, and the refusal below names each one. + // + // THE FINGERPRINT SELECTS, THE PATH BINDS. `apply_admissions` looks an + // admission up by the finding's PATH, so a binding whose subject were the + // fingerprint would be answered, spent and queried by nothing — the defect + // the ambiguity arm exists to refuse. The path returned is what binds. + let mut matched: Vec<(String, String)> = findings .iter() - .filter(|finding| finding.rule == rule && finding.path == subject) - .map(|finding| finding.identity.fingerprint.to_hex()) + .filter(|finding| finding.rule == rule) + .map(|finding| (finding.path.clone(), finding.identity.fingerprint.to_hex())) + .filter(|(path, fingerprint)| path == subject || fingerprint == subject) .collect(); matched.sort_unstable(); matched.dedup(); match matched.len() { - 1 => Ok(admission::Anchor::Finding(matched.remove(0))), + 1 => { + let (path, fingerprint) = matched.remove(0); + Ok((admission::Anchor::Finding(fingerprint), path)) + } // ZERO SPLITS IN TWO, AND THE SPLIT IS CLOUD-1551 (with CLOUD-1374 and // CLOUD-1378 folded into it). One arm is the honest fallback this // always was; the other is the ambiguity arm's defect wearing a smaller @@ -8469,8 +8504,7 @@ fn admission_anchor( // What the two arms differ on is whether there was a finding to // address, and this scan already holds that fact. 0 => { - let mut named: Vec<&str> = scan - .findings + let mut named: Vec<&str> = findings .iter() .filter(|finding| finding.rule == rule) .map(|finding| finding.path.as_str()) @@ -8512,9 +8546,17 @@ fn admission_anchor( // `apply_admissions` looks up a `Finding` anchor, so a `Call` one stored // for a tree finding is queried by nothing. A refusal that says why beats // an override that appears to work. + // AND IT NAMES THE WAY THROUGH (CLOUD-2126): each candidate's fingerprint, + // which `--subject` accepts, so the refusal is a route rather than a wall. count => Err(UsageError::raise(format!( "{count} findings for rule `{rule}` name subject `{subject}`, so the pair does not \ - address one finding and an admission bound to it would suppress none of them" + address one finding and an admission bound to it would suppress none of them; \ + name one by its fingerprint as the subject: {}", + matched + .iter() + .map(|(_, fingerprint)| fingerprint.as_str()) + .collect::>() + .join(", ") ))), } } @@ -8581,6 +8623,25 @@ fn run_override_request( resolved.id ))); }; + // A RECEIPT ROW'S SUBJECTS ARE LISTABLE FROM CONFIG, so a request naming one + // no refusal of that row binds is refused here rather than issued, spent and + // honoured by nothing (CLOUD-1996). `subject` is already in bound spelling. + if let Some(row) = config.rules.iter().find(|row| row.id == rule) + && let Some(bound) = hook::bindable_subjects(row, &resolved.id) + && !bound.iter().any(|spelling| spelling == subject) + { + let listed = if bound.is_empty() { + "none".to_owned() + } else { + bound.join(", ") + }; + return Err(error::UsageError::raise(format!( + "no `{rule}` refusal under `{}` binds subject `{subject}`, so an admission bound \ + to it would admit nothing; that rule binds {} subject(s) here: {listed}", + resolved.id, + bound.len() + ))); + } // BEFORE THE ARTICULATION IS ASKED FOR, never after it has been written // (CLOUD-1551). Resolving the anchor can now REFUSE — a rule that fired @@ -8593,7 +8654,9 @@ fn run_override_request( // // The scan this pays for is the narrowed one (CLOUD-1571), so the // questions-only path costs one rule over one subject rather than the tree. - let anchor = admission_anchor(root, &config, rule, subject)?; + // A fingerprint subject selects one finding; its PATH is what binds. + let (anchor, bound) = admission_anchor(root, &config, rule, subject)?; + let subject = bound.as_str(); let mut raw = String::new(); if std::io::stdin().read_to_string(&mut raw).is_err() { @@ -15898,6 +15961,7 @@ fn run_hook( // same `Fix::Run`, which is the safe direction and a visible one. if envelope.event == hook::Event::PostTool { record_post_tool(overrides, &envelope, harness, &mut advice); + collapse_seen_output(harness, &envelope, out)?; } // THE SAME CORRECTION AGAIN, ONE SELECTOR LATER (CLOUD-924). The paragraph // above records CLOUD-312 finding that "command-less" had stopped meaning @@ -16221,21 +16285,37 @@ fn run_hook( // a `retry` becomes a different refusal and a `silent` becomes an allow, and // advice about the original call would then be advice about a call that no // longer happened. - let decision = settle_repair(&policy, &envelope, decision); + let decision = settle_repair(&policy, &envelope, decision) + .map_refusal(|refusal| refusal.read_through(&policy.redirects)); let ceiling = policy.advisory.as_ref(); // A PRE-APPROVAL TAKES THE ADVICE INTO ITS OWN DOCUMENT (CLOUD-1949): two // documents on one stream is the collision above, with the grant as the // discarded one. - let context = matches!(decision, hook::Decision::Preapproved(_)) && !advice.is_empty(); - let context = context.then(|| advisory::admit(std::mem::take(&mut advice), ceiling).text); + let context = preapproval_context(&decision, &envelope, &mut advice, ceiling); emit_channel(harness, &envelope, out, err, advice, ceiling, &decision)?; let rendering = Rendering { - ceiling: policy.refusal.as_ref(), context: context.as_deref(), }; render(harness, &envelope, decision, &rendering, mode, out, err) } +/// The advice a pre-approval carries in its own document (CLOUD-1949), taken +/// out of `advice` and marked seen at this emission (CLOUD-2075), or `None` +/// when the decision is not a pre-approval or there is nothing to carry. +fn preapproval_context( + decision: &hook::Decision, + envelope: &hook::Envelope, + advice: &mut Vec, + ceiling: Option<&advisory::Channel>, +) -> Option { + if !matches!(decision, hook::Decision::Preapproved(_)) || advice.is_empty() { + return None; + } + let mut taken = std::mem::take(advice); + sight_advice(envelope, &mut taken); + Some(advisory::admit(taken, ceiling).text) +} + /// The note for an event this host does not declare, or `None` to carry on. /// /// **`Unrecognized` is not undeclared**, and collapsing the two is why this is a @@ -16367,8 +16447,9 @@ fn read_envelope( /// /// # What is NOT exempt /// -/// Any command, any MCP call, any subagent spawn, any unclassified tool, and any -/// write naming anything else. A shell command that happens to target the config +/// Any command but the engine's own repair verb spelled whole (CLOUD-2116), any +/// MCP call, any subagent spawn, any unclassified tool, and any write naming +/// anything else. A shell command that happens to target the config /// is refused too — its argv is not decidable from the envelope, the same /// undecidability `batten.toml`'s `protected_readers` comment records for an /// interpreter, and the direction that must refuse rather than pass. @@ -16393,6 +16474,8 @@ fn read_envelope( //MUTANT floor-admits-an-unclassified-call|s@ (hook::Operation::Read, None) => true,@ (_, None) => true,@|a_mutating_mcp_call_is_still_refused_over_a_config_that_will_not_load //MUTANT floor-refuses-inert-tools|s@ (hook::Operation::Other(name), None) if hook::INERT_TOOLS.contains(\&name.as_str()) => true,@@|a_search_still_answers_over_a_config_that_will_not_load //MUTANT floor-admits-any-write|s@ (hook::Operation::Write, Some(path)) => names_the_config_authority(path),@ (hook::Operation::Write, Some(_)) => true,@|a_write_to_another_path_is_still_refused_over_a_config_that_will_not_load +//MUTANT floor-refuses-engine-repair|s@ (hook::Operation::Execute, None) => envelope.command.trim() == engine::REPAIR,@@|the_engine_repair_verb_reaches_a_skewed_pin +//MUTANT floor-admits-a-carried-repair|s@envelope.command.trim() == engine::REPAIR,@envelope.command.contains(engine::REPAIR),@|a_command_carrying_the_repair_verb_is_still_refused fn recoverable_without_rules(envelope: &hook::Envelope) -> bool { // NO `command.is_empty()` GUARD, AND ITS ABSENCE IS THE DECISION. A `Bash` // envelope is `Operation::Execute` with no write, so it lands on `_ => false` @@ -16412,6 +16495,12 @@ fn recoverable_without_rules(envelope: &hook::Envelope) -> bool { (hook::Operation::Other(name), None) if hook::INERT_TOOLS.contains(&name.as_str()) => true, // The repair, and only onto the file that is faulting. (hook::Operation::Write, Some(path)) => names_the_config_authority(path), + // THE OTHER REPAIR, for the fault no edit can end (CLOUD-2116). A pin + // naming a NEWER build than the installed one is fixed by installing + // the pin, never by lowering it, so a floor of read-and-edit bricked the + // container on exactly the remedy its own refusal named. The engine's + // own verb, spelled whole: equality, so no shell composition rides it. + (hook::Operation::Execute, None) => envelope.command.trim() == engine::REPAIR, // EVERY OTHER SHAPE REFUSES, `Execute`, `Mcp`, `Subagent` and `Other` // among them. An operation this build could not classify is // could-not-look, and a could-not-look that mutates is the one thing @@ -16511,10 +16600,11 @@ fn is_source_excerpt(line: &str) -> bool { fn unadjudicable_remedy() -> Fix { Fix::Run(format!( "a `Read` still answers and an `Edit` or `Write` of `{}` or `{}` still lands — \ - repair the file with those; every other call stays refused until it loads, \ - so rebuild the binary instead if the config is newer than it", + repair the file with those; where the config is newer than this binary, \ + `{}` installs its pin and is the one command admitted", config::CONFIG_FILE, resolve::LOCAL_CONFIG_FILE, + engine::REPAIR, )) } @@ -16564,11 +16654,8 @@ fn unadjudicable_remedy() -> Fix { /// [`ExitCode::Violation`]. Raising a [`Denial`] here would send `2` to the one /// host that reads the document instead of the number. /// -/// **The rendering carries no ceiling and no hatch**, because both live on the -/// policy that would not load. `None` reads downstream as "no declared -/// bound" rather than as a bound of zero, which is the direction that keeps a -/// refusal about an unreadable config from being truncated by a value nobody -/// could read. +/// **The refusal is `unloaded`**: no `policy rule` hop resolves when the config +/// did not load, so its line names none (CLOUD-2075). //MUTANT refusal-drops-the-cause|s@^ .filter(\x7cline\x7c !is_source_excerpt(line))$@ .take(1)@|a_fact_row_that_states_no_returns_is_refused_at_load_over_the_binary fn deny_unadjudicable( harness: hook::Harness, @@ -16609,7 +16696,7 @@ fn deny_unadjudicable( .filter(|line| !line.is_empty()) .collect::>() .join(" "); - let refusal = Refusal::new( + let refusal = Refusal::unloaded( "engine-cannot-adjudicate", format!( "this build could not load the rules it is registered to enforce, so nothing judged \ @@ -16617,10 +16704,7 @@ fn deny_unadjudicable( ), unadjudicable_remedy(), ); - let rendering = Rendering { - ceiling: None, - context: None, - }; + let rendering = Rendering { context: None }; // WHICH CHANNEL CARRIED THE REFUSAL IS `render`'S OWN ANSWER, and reading it // here is what lets the number say could-not-look without ever spending the // refusal to do it (CLOUD-1677's exit-code half). @@ -16758,27 +16842,10 @@ fn admit_mediated(decision: hook::Decision, out: &mut dyn Write) -> Result Result, ) { - if advice.is_empty() - && let Some(nudge) = hook::stop_advice(policy, envelope, facts) - .or_else(|| stop_nudges(overrides, envelope, raw)) - { - advice.push(advisory::Advice::new( - severity::AdvisoryTier::Caution, - nudge, - )); + if advice.is_empty() { + if let Some(refusal) = hook::stop_advice(policy, envelope, facts) { + advice.push(advisory::Advice::finding( + severity::AdvisoryTier::Caution, + refusal.read_through(&policy.redirects), + )); + } else if let Some(nudge) = stop_nudges(overrides, envelope, raw) { + advice.push(advisory::Advice::new( + severity::AdvisoryTier::Caution, + nudge, + )); + } } // THE WRITE-TIME SIGNAL (CLOUD-1131), and it is the delivery half of the // demotion `hook::policy_rules` performs. A `mediated_call` module enabled at @@ -17003,16 +17078,36 @@ fn fill_turn_advice( // single tool call the agent is making right now: it is about the call in // hand rather than about the turn, and suppressing it because something else // already spoke would make the signal arrive at some calls and not others for - // reasons the reader cannot see. At `Stop` the two producers render the same - // violation through the same function, so the equality test is what keeps one - // finding from arriving twice rather than a second rule about which one wins. - if let Some(signal) = hook::policy_advice(policy, envelope, facts) - && !advice.iter().any(|entry| entry.text == signal) - { - advice.push(advisory::Advice::new( - severity::AdvisoryTier::Warning, - signal, - )); + // reasons the reader cannot see. `policy_advice` is empty at `Stop` (the + // block above owns that moment), and returns every non-blocking module's + // line on a call (CLOUD-1470); the key test drops a finding two bundles + // raised identically (CLOUD-2075). + for refusal in hook::policy_advice(policy, envelope, facts) { + let refusal = refusal.read_through(&policy.redirects); + let key = refusal.sighting_key(); + let seen = advice.iter().any(|entry| { + entry + .finding + .as_ref() + .is_some_and(|other| other.sighting_key() == key) + }); + if !seen { + advice.push(advisory::Advice::finding( + severity::AdvisoryTier::Warning, + refusal, + )); + } + } +} + +/// Render each classed entry's finding at the moment it is emitted, through the +/// one chooser, so the context's store is marked only for what reaches it. +fn sight_advice(envelope: &hook::Envelope, advice: &mut [advisory::Advice]) { + for entry in advice { + if let Some(refusal) = entry.finding.take() { + let arm = arm_for(hook::HookSource::Harness, envelope, &refusal); + entry.text = refusal.render_finding(arm); + } } } @@ -17104,6 +17199,7 @@ fn collect_batch_advice( // process, in this order, the record describing what this session LOADED is // dropped before a repair can write one describing what it FIXED. expire_wiring_record(envelope); + expire_sightings(envelope, advice); repair_startup_rows(envelope, overrides); report_container_health(envelope, overrides, advice); Ok(()) @@ -17344,12 +17440,95 @@ fn expire_wiring_record(envelope: &hook::Envelope) { return; } let _ = wiring::clear_at_load(hook_authority_root()); - // AND THE CLASSES THIS SESSION HAS ALREADY BEEN TOLD (CLOUD-1386), on the - // same event and for the same reason: the event is the session identity, and - // a sighting that outlived its session would withhold a remedy from a reader - // who has never seen it. Clearing costs a directory removal; not clearing - // costs the exact defect the store exists to prevent. - refusal::forget_sightings(hook_authority_root()); +} + +/// Decide this context's sightings at `SessionStart`, per source (CLOUD-2075). +/// +/// **`compact` re-delivers eagerly**: every full arm the previous cycle saw goes +/// out in full on this event's `additionalContext`, and stays marked, so the +/// context holds exactly one copy at every moment. **Every other source** forgets +/// this context alone; its next firing is full. Other contexts in the clone are +/// never touched. +//MUTANT compact-not-redelivered|s@^ if envelope.start_source.as_deref() == Some(hook::COMPACT_SOURCE) {$@ if false {@|a_compaction_redelivers_every_seen_item_once_at_session_start +//MUTANT drain-result-kept|s@^ forget_drain_result(envelope);$@ let _ = forget_drain_result;@|a_session_start_relists_the_drain_payload +fn expire_sightings(envelope: &hook::Envelope, advice: &mut Vec) { + if envelope.event != hook::Event::SessionStart { + return; + } + forget_drain_result(envelope); + let Some(context) = envelope.context() else { + return; + }; + if envelope.start_source.as_deref() == Some(hook::COMPACT_SOURCE) { + for text in refusal::sighted(hook_authority_root(), &context) { + advice.push(advisory::Advice::delivered( + severity::AdvisoryTier::Warning, + text, + )); + } + return; + } + refusal::forget_sightings(hook_authority_root(), &context); +} + +/// Mark every finding a tool's output carries as seen in this context, and cut +/// the ones it had already seen to their pointer (CLOUD-2075 §D). +/// +/// A Bash child cannot see which context it prints into; the hook payload names +/// it. The marking happens on every harness, so a CLI first sighting joins the +/// same lifecycle as a hook one. The rewrite document is emitted only where the +/// host is measured to honour it ([`hook::encode_tool_output`]) and only when a +/// line changed. A session-less payload marks nothing. +fn collapse_seen_output( + harness: hook::Harness, + envelope: &hook::Envelope, + out: &mut dyn Write, +) -> Result<()> { + let Some(context) = envelope.context() else { + return Ok(()); + }; + let root = hook_authority_root(); + let mut rewritten = envelope.result.clone(); + let mut changed = false; + for stream in ["stdout", "stderr"] { + let Some(text) = envelope.result.get(stream).and_then(|value| value.as_str()) else { + continue; + }; + let cut = refusal::collapse(text, |key, full| { + refusal::first_sighting(root, &context, key, full) + }); + if cut != text { + changed = true; + rewritten[stream] = serde_json::Value::String(cut); + } + } + if changed + && let Some(body) = hook::encode_tool_output(harness, &envelope.raw_event, &rewritten)? + { + writeln!(out, "{body}")?; + } + Ok(()) +} + +/// Clear the drain's `unchanged` watermark for this session's lineage, so the +/// first drain of a new cycle lists in full (CLOUD-2075). Silent on every +/// failure: a missed clear costs one `unchanged` marker, never a verdict. +fn forget_drain_result(envelope: &hook::Envelope) { + let Some(session) = envelope.session.as_deref() else { + return; + }; + let Ok(repo) = git::repo_root(hook_authority_root()) else { + return; + }; + let Ok(resolved) = store::resolve(&repo) else { + return; + }; + let Some(dir) = store::bound_dir(&resolved) else { + return; + }; + if let Ok(root) = session::root(&dir, session) { + let _ = session::forget_result(&dir, &root); + } } /// Run the declared handlers for this envelope's event (CLOUD-898). @@ -17450,14 +17629,7 @@ fn dispatch_handlers( } return None; }; - if !envelope.event.carries_a_verdict() { - advice.push(advisory::Advice::new( - severity::AdvisoryTier::Caution, - format!("hook.handler.{id}: {reason}"), - )); - return None; - } - Some(hook::Decision::Deny(crate::refusal::Refusal::declared( + let refused = crate::refusal::Refusal::declared( format!("hook.handler.{id}"), verdict::Native::HandlerDenied, // THE HANDLER'S OWN WORDS TRAVEL AS A SUBJECT, not as the reason @@ -17474,7 +17646,16 @@ fn dispatch_handlers( // "every refusal names something to run" is now the REGISTRY's obligation // and `verdict::validate` refuses a class that fails it. crate::refusal::Fix::None, - ))) + ); + // The demotion is the SAME refusal, carried as a classed finding (CLOUD-2075). + if !envelope.event.carries_a_verdict() { + advice.push(advisory::Advice::finding( + severity::AdvisoryTier::Caution, + refused, + )); + return None; + } + Some(hook::Decision::Deny(refused)) } /// Assemble a `requires_key` row's checkout evidence (CLOUD-446). @@ -17694,6 +17875,10 @@ fn emit_channel( if advice.is_empty() || speaks_a_verdict { return Ok(()); } + // Marked only here, after the verdict's early return, so advice dropped + // beside a verdict is never recorded as seen (CLOUD-2075). + let mut advice = advice; + sight_advice(envelope, &mut advice); let emission = advisory::admit(advice, ceiling); emit_advisory(harness, envelope, out, err, &emission.text) } @@ -18144,13 +18329,10 @@ fn drain_advisories( Verbosity::Verbose, err, &format!( - "hook: drained {} line(s); withheld {} out of scope, {} over the cardinality cap, {} \ - over the token budget, {} flapping; {} rule(s) with a flapping identity", + "hook: drained {} line(s); withheld {} out of scope; {} rule(s) with a flapping \ + identity", drained.lines.len(), drained.scope_filtered.len(), - drained.capped.len(), - drained.over_budget.len(), - drained.flap_suppressed.len(), drained.flapping.len(), ), )?; @@ -20072,24 +20254,31 @@ fn load_exec_settings( /// What a refusal line needs to render, resolved by the caller. /// /// TWO VALUES THAT TRAVEL TOGETHER, and bundling them is what keeps [`render`]'s -/// signature honest rather than merely short: both are resolved from the policy at -/// the boundary, and NEITHER is an input to the decision. The hatch is a name the -/// renderer prints; the ceiling is a bound on how long the printed line may be. -/// Reaching for `policy` inside `render` to fetch either would give it back the -/// inputs CLOUD-898 says it must not have, over values that decide nothing about -/// the call. -/// -/// Growing this struct is therefore the test for a third one: it belongs here if -/// the renderer only prints or measures it, and belongs nowhere near here if the +/// signature honest rather than merely short: it is resolved at the boundary and +/// is NOT an input to the decision. No renderer takes a ceiling (CLOUD-2075): a +/// ceiling reports in the corpus case and never sheds a pointer. +/// +/// Growing this struct is the test for another value: it belongs here if the +/// renderer only prints or measures it, and belongs nowhere near here if the /// renderer would have to branch on it to decide. struct Rendering<'a> { - /// What one emitted mediated line may cost, or no declared bound. - ceiling: Option<&'a refusal::Ceiling>, /// The admitted advice a pre-approval carries in its own document /// (CLOUD-1949). Printed, never branched on — the test above. context: Option<&'a str>, } +/// The one chooser between a finding's two arms (CLOUD-2075): the source's +/// declared lifecycle, consulted against the context's store. +fn arm_for(source: hook::HookSource, envelope: &hook::Envelope, refusal: &Refusal) -> refusal::Arm { + match (source.finding_lifecycle(), envelope.context()) { + (hook::Lifecycle::Sighted, Some(context)) => { + refusal::sight(hook_authority_root(), &context, refusal) + } + (hook::Lifecycle::Sighted, None) | (hook::Lifecycle::PrintedFull, _) => refusal::Arm::Full, + } +} + +//MUTANT ask-arm-sighted|s@^ let asked = refusal.render_finding(refusal::Arm::Full);$@ let asked = refusal.render_finding(arm_for(hook::HookSource::Harness, envelope, \&refusal));@|an_ask_carries_the_full_arm_on_every_firing fn render( harness: hook::Harness, envelope: &hook::Envelope, @@ -20099,7 +20288,7 @@ fn render( out: &mut dyn Write, err: &mut dyn Write, ) -> Result { - let Rendering { ceiling, context } = *rendering; + let Rendering { context } = *rendering; // THE DECISION ARRIVES AS A VALUE, which is what makes this a renderer // rather than a second adjudicator (CLOUD-898). A handler's refusal and the // engine's own reach the host through the identical match below: a @@ -20165,20 +20354,11 @@ fn render( // whether it named a fix. hook::Decision::Deny(refusal) => { // THE EFFECT IS HERE AND THE RENDERING IS NOT (CLOUD-1386). - // `deny_text` decides between two projections and stays pure; - // consulting-and-marking a store is a write, and a write belongs at - // the boundary with every other one. A renderer that touched the disk - // would also be one no test could drive twice. - // KEYED ON THE RULE AND THE CLASS (CLOUD-1637). It was keyed on the - // class token alone, which meant an UNDECLARED refusal — no token — - // skipped the store and always read as a first sighting, so its long - // form repeated forever. Every refusal has a key now, so both arms - // consult the store and both are bounded; - // `refusal::first_sighting` carries why neither name alone is the - // right key and what was measured in each direction. - let first_sighting = - refusal::first_sighting(hook_authority_root(), &refusal.sighting_key()); - let reason = hook::deny_text(&refusal, first_sighting, ceiling); + // `render_finding` projects and stays pure; consulting-and-marking + // the context's store is a write, and a write belongs at the boundary + // with every other one. `arm_for` is the one chooser (CLOUD-2075). + let arm = arm_for(hook::HookSource::Harness, envelope, &refusal); + let reason = refusal.render_finding(arm); match hook::encode_deny(harness, &envelope.raw_event, &reason)? { Some(body) => { writeln!(out, "{body}")?; @@ -20203,19 +20383,16 @@ fn render( // withholding the remedy to save a clause would be spending their // attention rather than the model's. // - // IT ALSO DOES NOT TAKE THE REFUSAL'S PROJECTION (CLOUD-1637). It used - // to call `deny_text` with `first_sighting = true`, which was the right - // answer to the wrong question: the arms of `deny_text` differ in how - // much an AGENT has already read, and a person has read none of it and - // will not run a lookup to answer the question in front of them. - // `ask_text` carries why the long form is theirs. - let reason = hook::ask_text(&refusal); - match hook::encode_ask(harness, &envelope.raw_event, &reason)? { + // The full arm on every firing (CLOUD-2075): a person has read none + // of the earlier firings and will not run a lookup to answer the + // question in front of them. + let asked = refusal.render_finding(refusal::Arm::Full); + match hook::encode_ask(harness, &envelope.raw_event, &asked)? { Some(body) => { writeln!(out, "{body}")?; Ok(ExitCode::Success) } - None => Err(Denial::raise(reason)), + None => Err(Denial::raise(asked)), } } // The pre-approval, and it is the mirror image of the arm above it. An diff --git a/crates/batten/src/patch.rs b/crates/batten/src/patch.rs index 59b9cd224..f6db4a7d6 100644 --- a/crates/batten/src/patch.rs +++ b/crates/batten/src/patch.rs @@ -118,6 +118,8 @@ pub(crate) fn is_text(bytes: &[u8]) -> bool { /// Infallible: a digest absorbs bytes and cannot refuse them, so there is no /// error path to invent. The caller's fallibility is in READING the objects, not /// in hashing them. +//MUTANT-SUITE crates/batten/src/patch.rs +//MUTANT base-oid-hashed|s@^ if before.text.is_some() \&\& after.text.is_some() {$@ if false {@|an_edit_replayed_onto_a_moved_base_keeps_its_identity pub(crate) fn identity(changes: &mut [Change]) -> Option { if changes.is_empty() { return None; @@ -141,7 +143,17 @@ pub(crate) fn identity(changes: &mut [Change]) -> Option { } Kind::Modified { before, after } => { field(&mut hasher, b"~"); - side(&mut hasher, before, None); + // THE BASE IS NOT THE CHANGE. Between two text sides the edit + // script below IS the change, so the before side contributes its + // mode alone: hashing its object id made the identity depend on + // the whole base file, and a rebase over any edit to that file + // elsewhere minted a new identity for the same change — so a + // replayed branch read as a sibling's work (CLOUD-2089's lease). + if before.text.is_some() && after.text.is_some() { + field(&mut hasher, before.mode.to_le_bytes().as_slice()); + } else { + side(&mut hasher, before, None); + } side(&mut hasher, after, Some(before)); } } @@ -271,6 +283,54 @@ mod tests { } } + /// The same edit replayed onto a base that moved ELSEWHERE in the file is + /// the same change. The base differs only far from the hunk, so the edit + /// script is identical and only the base's object id is not. + #[test] + fn an_edit_replayed_onto_a_moved_base_keeps_its_identity() { + let text = |body: &str| Blob { + oid: format!("{:0>64}", body.len()), + mode: 0o100_644, + text: Some(body.as_bytes().to_vec()), + }; + let filler = (0..40).fold(String::new(), |mut text, n| { + text.push_str("line "); + text.push_str(&n.to_string()); + text.push('\n'); + text + }); + let edit = |base: &str, oid: &str| { + let mut before = text(&format!("a\n{filler}{base}\n")); + before.oid = oid.repeat(64); + Change { + path: b"src/lib.rs".to_vec(), + kind: Kind::Modified { + after: text(&format!("a\nINSERTED\n{filler}{base}\n")), + before, + }, + } + }; + let original = identity(&mut [edit("tail one", "1")]); + let replayed = identity(&mut [edit("tail two", "2")]); + assert!(original.is_some()); + assert_eq!( + original, replayed, + "a base edited elsewhere is not a new change" + ); + let other = Change { + path: b"src/lib.rs".to_vec(), + kind: Kind::Modified { + before: text("a\n"), + after: text("a\nDIFFERENT\n"), + }, + }; + assert_ne!( + original, + identity(&mut [other]), + "a different edit still differs" + ); + } + /// An empty change set has no identity, which is what produces /// `Evidence::NoContent` rather than a hash every empty commit shares. #[test] diff --git a/crates/batten/src/perf.rs b/crates/batten/src/perf.rs index eb92c146f..3f75a5178 100644 --- a/crates/batten/src/perf.rs +++ b/crates/batten/src/perf.rs @@ -2825,39 +2825,24 @@ pub struct RenderRecord { /// `policy-budget` already gates instruction files with, rather than a new /// bytes-over-four approximation wearing a precise-looking unit. pub tokens: usize, - /// The rendered line itself, so the tier can compare it to - /// [`crate::refusal::Refusal::line`] rather than to a length. + /// The rendered line itself, so the tier can compare it to the pointer arm + /// rather than to a length. No renderer takes a ceiling (CLOUD-2075), so this + /// is the whole arm. pub line: String, - /// The same arm rendered with NO ceiling, and it is the column that makes the - /// shipped one legible. - /// - /// The committed `[refusal] max_tokens` bounds the carried line, and when a - /// class's routes take it over that bound `deny_text` falls back to the - /// compact form — so a table of the shipped rendering alone can report every - /// arm as equal and look like a broken measurement rather than like a budget - /// doing its job. This is what the first sighting WOULD cost if the ceiling - /// permitted it, which is both the explanation and the number a successor - /// weighing a residency protocol actually needs. - pub unbounded_characters: usize, - /// [`crate::budget::estimate_tokens`] over that same unbounded rendering. - pub unbounded_tokens: usize, } /// Render every strategy × residency × class arm through the shipped path. /// -/// The registry and ceiling are the caller's, so the committed authority is what -/// gets measured: a bench that vendored its own registry would price a class -/// nobody is ever refused under. +/// The registry is the caller's, so the committed authority is what gets +/// measured: a bench that vendored its own registry would price a class nobody +/// is ever refused under. /// /// # Errors /// /// A class this repository does not declare. That is a property of the /// configuration rather than a verdict about the cost, and reporting a /// zero-length rendering for an absent class would be a measurement of nothing. -pub fn refusal_render( - registry: &[crate::verdict::DeclaredVerdict], - ceiling: Option<&crate::refusal::Ceiling>, -) -> Result> { +pub fn refusal_render(registry: &[crate::verdict::DeclaredVerdict]) -> Result> { let mut records = Vec::new(); for (class, rule) in MEASURED_CLASSES { let refusal = crate::refusal::Refusal::from_class( @@ -2876,8 +2861,12 @@ pub fn refusal_render( for strategy in Strategy::ALL { for residency in Residency::ALL { let first = first_sighting(*strategy, *residency); - let line = crate::hook::deny_text(&refusal, first, ceiling); - let unbounded = crate::hook::deny_text(&refusal, first, None); + let arm = if first { + crate::refusal::Arm::Full + } else { + crate::refusal::Arm::Pointer + }; + let line = refusal.render_finding(arm); records.push(RenderRecord { strategy: *strategy, residency: *residency, @@ -2886,8 +2875,6 @@ pub fn refusal_render( characters: line.chars().count(), tokens: crate::budget::estimate_tokens(&line), line, - unbounded_characters: unbounded.chars().count(), - unbounded_tokens: crate::budget::estimate_tokens(&unbounded), }); } } @@ -2982,58 +2969,6 @@ fn refusal_render_preamble() -> String { out } -/// What the declared ceiling actually did to these records, as opposed to what -/// it is declared to do. -/// -/// **DERIVED, NEVER ASSERTED, and that distinction is why this function exists.** -/// This paragraph used to state flatly that the ceiling withholds the carried -/// routes, which was true when it was written and false one renderer change -/// later — CLOUD-1637 landed, every emitted figure became its unbounded one, and -/// the report went on explaining a suppression that was no longer happening. A -/// sentence about a measurement has to be computed from it. -fn refusal_render_ceiling_note( - records: &[RenderRecord], - ceiling: Option<&crate::refusal::Ceiling>, -) -> String { - use std::fmt::Write as _; - - let mut out = String::new(); - let Some(declared) = ceiling else { - out.push_str( - "**No `[refusal]` ceiling is declared**, so the emitted and unbounded columns below \ - are the same rendering.\n\n", - ); - return out; - }; - let withheld = records - .iter() - .filter(|record| record.characters < record.unbounded_characters) - .count(); - if withheld == 0 { - let _ = writeln!( - out, - "**The declared `[refusal] max_tokens` is {}, and on this tree it withholds \ - NOTHING**: every emitted figure below equals its unbounded one, so the ceiling is \ - declared and inert over these classes rather than shaping the numbers. The \ - `unbounded` columns are kept because that is a fact about today's registry, not a \ - property of the bound.\n", - declared.max_tokens - ); - } else { - let _ = writeln!( - out, - "**The declared `[refusal] max_tokens` is {}, and it is an input to {} of the {} \ - arms below**, where the rendered line would exceed it and `deny_text` falls back to \ - the compact form. The `unbounded` columns are those arms rendered with no ceiling — \ - what the sighting would cost if the budget permitted it.\n", - declared.max_tokens, - withheld, - records.len() - ); - } - out -} - /// Whether any measured class has nothing for a residency protocol to withhold. /// /// **THE SAME LESSON AS THE CEILING NOTE, one paragraph over.** This text used to @@ -3078,10 +3013,7 @@ fn refusal_render_margin_note(records: &[RenderRecord]) -> String { out, "**A zero in the emitted column is a measurement, not a gap in the table**, and \ {} of the measured classes reach it: {}. A class whose first sighting renders \ - exactly what its repeat renders has nothing for a residency protocol to withhold, \ - whether because the renderer appends nothing for it or because the declared ceiling \ - withholds what it would have appended. The unbounded columns are what tell those \ - two apart.\n", + exactly what its repeat renders has nothing for a residency protocol to withhold.\n", flat.len(), flat.join(", ") ); @@ -3168,17 +3100,18 @@ fn refusal_render_verdict(records: &[RenderRecord]) -> String { /// it is byte-stable under no commit — and the crate version is absent for the /// same reason one release later: release-plz bumps it in a commit that renders /// nothing differently, so a version in the body would redden the drift check on -/// every release. The baseline is the declared class ids and the declared -/// ceiling, which are what the rendering reads. +/// every release. The baseline is the declared class ids, which are what the +/// rendering reads; no renderer takes a ceiling (CLOUD-2075). #[must_use] -pub fn refusal_render_report( - records: &[RenderRecord], - ceiling: Option<&crate::refusal::Ceiling>, -) -> String { +pub fn refusal_render_report(records: &[RenderRecord]) -> String { use std::fmt::Write as _; let mut out = refusal_render_preamble(); - out.push_str(&refusal_render_ceiling_note(records, ceiling)); + out.push_str( + "**Nothing is shed.** No renderer takes a ceiling: `[refusal]`'s keys are measured \ + by the corpus case and report an over-ceiling line rather than truncating one, so \ + every figure below is the whole arm.\n\n", + ); for (class, rule) in MEASURED_CLASSES { let _ = writeln!(out, "## `{class}` (rule `{rule}`)\n"); @@ -3188,8 +3121,6 @@ pub fn refusal_render_report( "first sighting".to_owned(), "emitted characters".to_owned(), "emitted tokens".to_owned(), - "unbounded characters".to_owned(), - "unbounded tokens".to_owned(), ]]; for record in records.iter().filter(|record| record.class == *class) { rows.push(vec![ @@ -3198,8 +3129,6 @@ pub fn refusal_render_report( record.first_sighting.to_string(), record.characters.to_string(), record.tokens.to_string(), - record.unbounded_characters.to_string(), - record.unbounded_tokens.to_string(), ]); } out.push_str(&markdown_table(&rows)); @@ -3221,17 +3150,12 @@ pub fn refusal_render_report( out, "- **`{class}`** — a warm repeat emits {} characters ({} tokens) today against \ {} ({} tokens) delivered in full every time: **{} characters saved per repeat \ - firing**. Unbounded, the same comparison is {} against {}: **{} characters** — \ - what a residency protocol would have to deliver, and withhold, per firing.", + firing**.", compact.characters, compact.tokens, full.characters, full.tokens, full.characters.saturating_sub(compact.characters), - compact.unbounded_characters, - full.unbounded_characters, - full.unbounded_characters - .saturating_sub(compact.unbounded_characters) ); } } diff --git a/crates/batten/src/preset.rs b/crates/batten/src/preset.rs index 4df5a0cd9..2a2d82ce0 100644 --- a/crates/batten/src/preset.rs +++ b/crates/batten/src/preset.rs @@ -679,7 +679,7 @@ upward forever. Nothing is broken and no branch is at fault: re-derive the numbe new comment.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -692,7 +692,7 @@ above the committed budget. Raise it before it starts failing healthy runs — t that turns into a red job nobody caused.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -705,7 +705,7 @@ to defend is the one move a budget exists to forbid, so this reports that the de convertible and a deliberate commit does the converting.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -721,7 +721,7 @@ release job on it. A family present without exactly one closing line was torn by than its producer, which writes whole or removes, and reads the same way.", routes: &[run( "task run first", - "run the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ + "the writer the `drift-runs` and `drift-jobs` `[[record]]` rows name, \ which re-takes the run window before it re-walks the jobs", )], applicability: crate::verdict::Applicability::Advice, @@ -1149,7 +1149,7 @@ invocation. The refusal names the task because the mapping it reads is task to a remedy is in the finding rather than a file the reader has to go and search.", routes: &[run( "task run first", - "run the task the refusal names, through the task runner", + "the task the refusal names, through the task runner", )], applicability: crate::verdict::Applicability::Advice, }, @@ -1229,10 +1229,7 @@ it runs a different version, or the same version without the variables the proje like a wrong invocation. Measured on one consumer: sixty runs of a test suite died on an \ unset variable instead of on the assertion, and the report that followed was published \ as three claims about the tree, all false.", - routes: &[run( - "task run first", - "run the declared task, or invoke the program through the pin", - )], + routes: &[run("task run first", "mise exec -- ")], applicability: crate::verdict::Applicability::Advice, }, // THE PROBE HALF, AND A SEPARATE CLASS ON PURPOSE (CLOUD-1256). @@ -1946,6 +1943,29 @@ mod tests { use std::collections::BTreeSet; + /// No `command` route's target begins with the verb it renders under + /// (CLOUD-1470). `render_route` prefixes every command target with `run `, + /// so a target that already began `run ` rendered as `run run …` once the + /// advisory channel started printing routes. + #[test] + fn no_command_route_repeats_the_verb_it_renders_under() { + let doubled: Vec<&str> = MANIFESTS + .iter() + .flat_map(|manifest| manifest.verdicts.iter()) + .filter(|entry| { + entry.routes.iter().any(|route| { + route.kind == crate::verdict::RouteKind::Command + && route.target.starts_with("run ") + }) + }) + .map(|entry| entry.id) + .collect(); + assert!( + doubled.is_empty(), + "routes render as `run run …`: {doubled:?}" + ); + } + /// Every class a preset's modules raise is declared by its own manifest. /// /// The direction a consumer module already gets from `check_verdicts_are_declared`, diff --git a/crates/batten/src/provision.rs b/crates/batten/src/provision.rs index 43a652603..cec8a81da 100644 --- a/crates/batten/src/provision.rs +++ b/crates/batten/src/provision.rs @@ -624,13 +624,32 @@ pub fn status(entries: &[Provision], cache_root: &Path) -> Result { report.push(EntryStatus { name: entry.name.clone(), version: entry.version.clone(), - freshness: freshness_of(entry, cache_root)?, + freshness: freshness_of(entry, cache_root, true)?, }); } Ok(Report { entries: report }) } -fn freshness_of(entry: &Provision, cache_root: &Path) -> Result { +/// Whether an engine at `exe` may write the HOST's links for `repo_root` +/// (CLOUD-2124). +/// +/// **A build of the checkout it provisions never does.** Such a binary is +/// `cargo`'s output, not an installed engine — the line `engine update` already +/// draws for the same reason (CLOUD-2063). A launcher names its interpreter by +/// absolute path, so a gate running `provision apply` through `cargo run` +/// rewrote `~/.local/bin/mise` to run a debug build: measured 2026-10-07, a +/// scratch clone's `target/debug/batten` became every `mise` call's +/// interpreter for the whole host. The binary installs the binary; once one is +/// installed, it alone provisions the host. The cache is the repository's own, +/// so a build of it still fills it — the scanner a gate needs stays reachable. +#[must_use] +//MUTANT-SUITE crates/batten/src/provision.rs +//MUTANT checkout-build-links-host|s@^ !exe.starts_with(repo_root)$@ true@|a_build_of_the_checkout_never_links_the_host +pub fn links_host(exe: &Path, repo_root: &Path) -> bool { + !exe.starts_with(repo_root) +} + +fn freshness_of(entry: &Provision, cache_root: &Path, links_host: bool) -> Result { let dir = entry_dir(cache_root, entry); let binary = dir.join(BIN_DIR).join(&entry.binary); if !binary.is_file() { @@ -652,7 +671,7 @@ fn freshness_of(entry: &Provision, cache_root: &Path) -> Result { // `Missing` rather than a fourth verdict: what is missing is the binary at // the place this entry declares it must be, which is the same class as // nothing cached and takes the same repair. - if let Some(dest) = entry.link.as_deref() { + if let Some(dest) = entry.link.as_deref().filter(|_| links_host) { let linked = expand_home(dest)?.join(&entry.binary); if !linked.is_file() { return Ok(Freshness::Missing); @@ -728,8 +747,16 @@ pub enum Applied { /// did make. An /// unsupported URL scheme is a [`UsageError`] (→ exit `1`), since the manifest /// asked for something this build does not do. -pub fn apply(entry: &Provision, cache_root: &Path, dry_run: bool) -> Result { - if freshness_of(entry, cache_root)? == Freshness::Fresh { +/// +/// `links_host` is [`links_host`]'s answer: `false` leaves the declared link — +/// the host's, not the repository's — unjudged and unwritten. +pub fn apply( + entry: &Provision, + cache_root: &Path, + dry_run: bool, + links_host: bool, +) -> Result { + if freshness_of(entry, cache_root, links_host)? == Freshness::Fresh { return Ok(Applied::AlreadyFresh); } if dry_run { @@ -747,7 +774,7 @@ pub fn apply(entry: &Provision, cache_root: &Path, dry_run: bool) -> Result Result Result<()> { +fn install(entry: &Provision, cache_root: &Path, bytes: &[u8], links_host: bool) -> Result<()> { let dir = entry_dir(cache_root, entry); let bin_dir = dir.join(BIN_DIR); fs::create_dir_all(&bin_dir).context("create the provision cache directory")?; @@ -814,7 +841,7 @@ fn install(entry: &Provision, cache_root: &Path, bytes: &[u8]) -> Result<()> { // reading `missing` also leaves the link unmade. A fresh entry whose link // never landed would be the silent half-install this ordering exists to // rule out. - if let Some(dest) = entry.link.as_deref() { + if let Some(dest) = entry.link.as_deref().filter(|_| links_host) { link_onto_path(entry, dest, &cached, &binary)?; } // The artifact is written last, so a crash between the two leaves the entry @@ -2132,6 +2159,26 @@ pub fn binary_path(repo_root: &Path, entry: &Provision) -> Result { mod tests { use super::*; + /// CLOUD-2124: the measured case, a scratch clone's debug build provisioning + /// the host from inside its own checkout, is refused; an installed engine, + /// and a build of ANOTHER tree provisioning a fixture, still link. + #[test] + fn a_build_of_the_checkout_never_links_the_host() { + let repo = Path::new("/scratch/ciclone"); + assert!( + !links_host(Path::new("/scratch/ciclone/target/debug/batten"), repo), + "cargo's output inside the provisioned checkout is not an installed engine" + ); + assert!( + links_host(Path::new("/root/.local/bin/batten"), repo), + "the installed engine provisions the host" + ); + assert!( + links_host(Path::new("/home/user/batten/target/debug/batten"), repo), + "a build of another tree is not a build of this one" + ); + } + // ----------------------------------------------------------------------- // The control credential is DERIVED, never a forge's literal (CLOUD-1615). // diff --git a/crates/batten/src/refusal.rs b/crates/batten/src/refusal.rs index 51f85d3c0..829cd01ce 100644 --- a/crates/batten/src/refusal.rs +++ b/crates/batten/src/refusal.rs @@ -233,16 +233,29 @@ pub struct Refusal { /// The id that refused: a `[[rule]]` row's id, or a derived gate's declared /// constant. What a reviewer greps for in `batten.toml`. rule: String, - /// Every non-override route the class declares, rendered by kind, for the - /// once-per-session sighting (CLOUD-1386, CLOUD-1637). + /// Every route the class declares, override routes included, unrendered + /// (CLOUD-2075). [`finding_line`] renders them by kind on BOTH arms. /// /// **Skipped in serialization**, because it is a RENDERING input rather than /// part of the refusal payload: a consumer of `{rule, verdict, reason, fix}` /// asked for the remedy, and `fix` is still that. Resolved here because this - /// is where the registry is already in hand, which keeps `deny_text` pure and - /// the boundary free of a second registry lookup. + /// is where the registry is already in hand, which keeps the projection pure + /// and the boundary free of a second registry lookup. #[serde(skip)] - routes: Vec, + routes: Vec, + /// The rendered pointers, as [`crate::verdict::render_subjects`] spells them + /// (CLOUD-2075). The ` at ` clause of both arms. + #[serde(skip)] + subjects: String, + /// Which reader a document route's target is read through, where the + /// consumer declares one (`[[redirect]] read`), keyed by target + /// ([`Refusal::read_through`]). + #[serde(skip)] + readers: std::collections::BTreeMap, + /// Whether `batten policy rule ''` resolves this refusal's row: false + /// only where the config did not load ([`Refusal::unloaded`]). + #[serde(skip)] + dereferenceable: bool, /// The one-line gloss of the declared class, for the first-sighting arm /// (CLOUD-1637). /// @@ -272,14 +285,11 @@ pub struct Refusal { reason: String, /// What to run instead, or an explicit none. fix: Fix, - /// The canonical subject an admission binds to, when this refusal names one. + /// Every spelling a MEDIATED admission binds to, in order + /// ([`admission_bindings`] carries which and why). /// - /// The first path-bearing subject where there is one, which is already the - /// finding's own pointer by `rules/policy-modules.md`'s rule — so that is the - /// same choice the tree surface makes, not a second one. Where there is no - /// path, every artifact joined; [`admission_subject`] carries why, and why a - /// path-only answer left two classes with an override route nobody could - /// reach (CLOUD-1871). + /// The tree surface binds `finding.path` instead and anchors by fingerprint, + /// so it never reads this; the two are different questions (CLOUD-1826). /// /// **Carried rather than re-derived at the boundary**, and that is the whole /// reason the field exists. [`crate::admission::admitted`] binds five fields, @@ -289,93 +299,62 @@ pub struct Refusal { /// normalization cases that made CLOUD-1133 a defect. /// /// **Not serialized**, so `-J` output is byte-identical to before (house style - /// §6). It is an internal binding rather than news: the same pointer is + /// §6). It is an internal binding rather than news: the same pointers are /// already in `reason`, and a consumer gains nothing from a second copy under /// its own key. #[serde(skip_serializing)] - subject: Option, + bindings: Vec, } -/// The subject an admission binds to, for a refusal naming `subjects`. -/// -/// A path first, and every artifact when there is no path (CLOUD-1871). -/// -/// # A path-only answer made two classes unadmittable -/// -/// This used to return the first path-bearing subject and `None` for anything -/// else, on the reasoning that "an artifact is not a path, so an admission bound -/// to it would name something the store cannot compare against the tree". That -/// is true of a TREE finding, which is anchored by fingerprint and whose subject -/// is compared against the tree — and it is the wrong question for a MEDIATED -/// refusal, which [`crate::admission::Anchor::Call`] already anchors to a head. -/// There the subject is simply what the refusal named, and a commit id or a -/// check name is a perfectly comparable binding. -/// -/// The cost of the narrower answer was not theoretical. `admit_mediated` returns -/// early with no subject, so `history drop unpushed` (a `Count` and one -/// `Artifact` per commit) and `receipt read other` (artifacts, its subject -/// deliberately unnamed to keep payload out of a refusal) could never be -/// admitted — while both declare an `override` route, which makes -/// leave their declared routes as the only way through. -/// No hatch and no admission is the state that function's own doc calls "the -/// wall in its worst form" and asserts cannot happen. It had happened, to two of -/// the three classes that declare such a route; `path write refused` escaped -/// only because it leads with a path. -/// -/// # EVERY artifact, not the first -/// -/// The join is what keeps the binding honest. One artifact out of several would -/// let an admission earned for commit A admit a later reset discarding A **and** -/// B — the harvesting hole `an_admission_bound_to_another_subject_is_refused` -/// exists to close, reopened one variant over. Rendered order, so the value is -/// byte-stable for a given refusal. -/// -/// # A count is not an identity -/// -/// Counts stay out: they are derivable from what the refusal already names, and -/// binding one would make an admission for "1 commit" fit a different single -/// commit. A class whose subjects are ONLY counts therefore still has no -/// binding — which is not silently tolerated: `every_admissible_class_can_be_ -/// bound` refuses a class that declares an override route and cannot produce one. -/// -/// Rule 4 is untouched either way. This binds only what the refusal already -/// renders; it puts no new byte on any channel, and `subject` is -/// `skip_serializing`, so `-J` output and `schema/*.json` do not move. +/// Every spelling a mediated refusal of `token` naming `subjects` binds, in +/// order (CLOUD-1826). +/// +/// - **(a) The printed spelling, always first** — +/// [`crate::verdict::bound_subject`] of exactly the pointers +/// [`crate::verdict::render_line`] prints, counts included. A reader pastes +/// the line into `override request --subject`, which spells through the same +/// function, so the pasted line binds as the refusal does. +/// - **(b) The first path**, when it differs from (a): the `--subject ` +/// spelling the protected-path route documents. +/// - **(c) The class token in bound spelling**, only when there are no subjects: +/// `call name refused` binds `call,name,refused`, which is exactly what a +/// request for that subject stores. The raw token it replaces was a spelling no +/// request could produce. +/// +/// Every entry is a fixed point of `bound_subject`, so any spelling the boundary +/// asks about is one a request can store. +/// +/// # A count binds only inside the whole printed spelling +/// +/// So an admission for `1 ` cannot fit `2 ` — the harvest hole +/// CLOUD-1871 closed stays closed. A count-only class with no override route +/// stays inert, because `override request` refuses such a class. +/// +/// # Why not `rules::first_pointer` +/// +/// That is the TREE pointer: one path slot, anchored by fingerprint. Forcing it +/// to equal this binding would either widen tree output or drop pointers here. +/// +/// Rule 4 is untouched: this binds only what the refusal already renders, and +/// `bindings` is `skip_serializing`, so `-J` output and `schema/*.json` do not +/// move. //MUTANT-SUITE crates/batten/src/hook.rs -//MUTANT artifact-binding-dropped|s@^ (!artifacts.is_empty()).then(\x7c\x7c artifacts.join(","))@ None@|every_class_declaring_an_override_route_can_be_bound -fn admission_subject(subjects: &[crate::verdict::Subject]) -> Option { - if let Some(path) = subjects.iter().find_map(|subject| match subject { - crate::verdict::Subject::Path { path } | crate::verdict::Subject::Line { path, .. } => { - Some(path.clone()) - } - crate::verdict::Subject::Count { .. } | crate::verdict::Subject::Artifact { .. } => None, - }) { - return Some(path); - } - let artifacts: Vec<&str> = subjects - .iter() - .filter_map(|subject| match subject { - crate::verdict::Subject::Artifact { artifact } => Some(artifact.as_str()), - crate::verdict::Subject::Count { .. } - | crate::verdict::Subject::Path { .. } - | crate::verdict::Subject::Line { .. } => None, - }) - .collect(); - // The restored defect, exactly: a path-only answer, which is what left two - // classes with an override route nobody could reach. - (!artifacts.is_empty()).then(|| artifacts.join(",")) +//MUTANT printed-spelling-dropped|s@^ let mut spellings = vec!\[bound_subject(&render_subjects(subjects))\];$@ let mut spellings: Vec = Vec::new();@|a_subject_copied_from_the_refusal_line_admits_the_write +//MUTANT path-spelling-dropped|s@^ let path = subjects.iter().find_map(crate::verdict::Subject::path).map(bound_subject);$@ let path: Option = None;@|a_spent_admission_admits_the_write_it_was_taken_for +//MUTANT class-spelling-raw|s@^ return vec!\[bound_subject(token)\];$@ return vec![token.to_owned()];@|a_refusal_naming_nothing_binds_its_class_as_a_request_spells_it +fn admission_bindings(token: &str, subjects: &[crate::verdict::Subject]) -> Vec { + use crate::verdict::{bound_subject, render_subjects}; + if subjects.is_empty() { + return vec![bound_subject(token)]; + } + let mut spellings = vec![bound_subject(&render_subjects(subjects))]; + // One line, because `path-spelling-dropped` anchors on it whole. + #[rustfmt::skip] + let path = subjects.iter().find_map(crate::verdict::Subject::path).map(bound_subject); + spellings.extend(path.filter(|path| !spellings.contains(path))); + spellings } -/// What [`Fix::None`] renders as: the gap, stated, plus the general recourse. -/// -/// A refusal with no declared alternative still owes the caller *something* — the -/// contract is that a block gets an agent to right in one hop — so the crate's own -/// general answer stands in. It is deliberately generic: which surface owns a -/// given path is the consumer's knowledge, and CLOUD-280 is where a path class -/// gets to declare it. -const NO_DECLARED_FIX: &str = - "none declared — change it through the surface that owns it, or restore it with git"; - /// Whether this RULE has already explained itself this session, marking it if /// not (CLOUD-1386, re-keyed by CLOUD-1637). /// @@ -386,10 +365,11 @@ const NO_DECLARED_FIX: &str = /// is what gets explained. That holds only where a class has one raiser, and it /// does not for the native-kind population: `[[rule]]` rows of kind `shape`, /// `receipt`, `forbid`, `pipeline`, `command`, `ratchet` and `secrets` declare no -/// class of their own and raise the kind's native one, so fourteen `shape` rows -/// all raise `call name refused` and ten `receipt` rows share four classes. Under -/// a token key the first `shape` row to fire consumed the sighting for all -/// fourteen, and the next row's FIRST firing rendered as a repeat with its +/// class of their own and raise the kind's native one, so every plain `shape` +/// row (eleven in this config when CLOUD-1806 counted) raises `call name +/// refused` and ten `receipt` rows share four classes. Under a token key the +/// first `shape` row to fire consumed the sighting for all of them, and the +/// next row's FIRST firing rendered as a repeat with its /// rule-specific remedy never pointed at. CLOUD-1637's second amendment is where /// that was corrected, and it prescribed the rule id. /// @@ -422,22 +402,25 @@ const NO_DECLARED_FIX: &str = /// actionable, and reported a working gate as a design defect. Neither "always" /// nor "never" is right. "Once" is. /// -/// SCOPED TO THE SESSION. The store lives under `$GIT_DIR`, -/// so it dies with the container and is cleared at `SessionStart` beside the -/// wiring record — which is the same identity `expire_wiring_record` uses, and -/// for the reason stated there: the event IS the session. +/// SCOPED TO THE CONTEXT, NOT THE CLONE (CLOUD-2075). The store lives under +/// `$GIT_DIR/batten-sightings//`, where the context +/// is the session plus the agent id where a subagent is the reader — a +/// subagent's first sighting used to come back compact because another context +/// in the clone had already marked it. Each file holds the FULL arm's text, so a +/// compaction can re-deliver it. +/// +/// **Compaction is a `SessionStart` with `source: compact`**, and it is visible: +/// the boundary re-delivers every full arm this context holds on that event and +/// keeps the marks, so the context holds exactly one copy at every moment. Any +/// other source forgets this context alone. /// /// **A failure to read or write answers TRUE**, which is the direction that /// matters: an unreadable store means the class explains itself again, costing a /// clause. The opposite default would silently withhold the remedy from a reader /// who has never seen it, which is the whole defect. -/// -/// Compaction is invisible from here, so "per session" is the implementable -/// approximation of "per reader" — and it errs toward repeating rather than -/// assuming what a reader retained. #[must_use] -pub fn first_sighting(root: &Path, key: &str) -> bool { - let Some(dir) = crate::git::git_dir(root).ok().map(|dir| dir.join(STORE)) else { +pub fn first_sighting(root: &Path, context: &str, key: &str, full: &str) -> bool { + let Some(dir) = context_dir(root, context) else { return true; }; // One file per key rather than a list: two refusals firing concurrently @@ -451,20 +434,155 @@ pub fn first_sighting(root: &Path, key: &str) -> bool { let _ = std::fs::create_dir_all(&dir); // Discarded deliberately: an unwritable store means the next firing explains // itself again, which is the safe direction. - let _ = crate::durable::replace(&path, key); + let _ = crate::durable::replace(&path, full); true } -/// Forget every class explained under the previous session (CLOUD-1386). +/// The directory one context's sightings live in. +//MUTANT sighting-context-ignored|s@^ Some(store.join(crate::provision::digest(context.as_bytes())))$@ Some(store.join(crate::provision::digest(b"")))@|two_contexts_in_one_clone_each_get_the_full_text +fn context_dir(root: &Path, context: &str) -> Option { + let store = crate::git::git_dir(root).ok()?.join(STORE); + Some(store.join(crate::provision::digest(context.as_bytes()))) +} + +/// Which arm this firing of `refusal` gets in `context`, marking it seen. +//MUTANT sight-always-first|s@^ if !first_sighting(root, context, \&key, \&full) {$@ if false {@|a_warn_advisory_is_full_once_then_a_pointer +#[must_use] +pub fn sight(root: &Path, context: &str, refusal: &Refusal) -> Arm { + let full = refusal.render_finding(Arm::Full); + let key = refusal.sighting_key(); + if !first_sighting(root, context, &key, &full) { + return Arm::Pointer; + } + Arm::Full +} + +/// Forget every item this ONE context has seen (CLOUD-2075). Other contexts in +/// the clone are never touched. +//MUTANT forget-sightings-noop|s@^ let _ = std::fs::remove_dir_all(dir);$@ let _ = dir;@|a_session_start_forgets_only_that_contexts_sightings +pub fn forget_sightings(root: &Path, context: &str) { + if let Some(dir) = context_dir(root, context) { + let _ = std::fs::remove_dir_all(dir); + } +} + +/// Every full arm this context has seen, sorted so re-delivery is byte-stable. +#[must_use] +pub fn sighted(root: &Path, context: &str) -> Vec { + let Some(dir) = context_dir(root, context) else { + return Vec::new(); + }; + let Ok(entries) = std::fs::read_dir(dir) else { + return Vec::new(); + }; + let mut texts: Vec = entries + .filter_map(Result::ok) + .filter_map(|entry| std::fs::read_to_string(entry.path()).ok()) + .collect(); + texts.sort_unstable(); + texts +} + +/// An item's identity: the rule, the class, and a digest of its DEFINITION, so +/// an edit to a gloss, route, precondition or reason mid-cycle is a new item +/// that renders in full again (CLOUD-1582, absorbed). +//MUTANT sighting-key-ignores-definition|s@^ let digest = crate::provision::digest(definition.as_bytes());$@ let digest = crate::provision::digest(b"");@|an_edited_definition_is_a_new_item_mid_cycle +fn key_of(rule: &str, class: Option<&str>, definition: &str) -> String { + let digest = crate::provision::digest(definition.as_bytes()); + format!("{rule}\u{1f}{}\u{1f}{digest}", class.unwrap_or_default()) +} + +/// One finding line read back: its names, its arm and its key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Parsed { + /// The class, where the line is labelled with one. + pub verdict: Option, + /// The rule id. + pub rule: String, + /// `Full` iff the line carries the ` — ` definition clause. + pub arm: Arm, + /// [`key_of`] over the line's definition. + pub key: String, +} + +/// Read one quoted name from the front of `text`, returning it and the rest. +fn unquote(text: &str) -> Option<(String, &str)> { + let mut rest = text.strip_prefix('\'')?; + let mut name = String::new(); + loop { + let index = rest.find(['\'', '\\'])?; + name.push_str(&rest[..index]); + if rest[index..].starts_with("\\'") { + name.push('\''); + rest = &rest[index + 2..]; + } else if rest[index..].starts_with('\'') { + return Some((name, &rest[index + 1..])); + } else { + name.push('\\'); + rest = &rest[index + 1..]; + } + } +} + +/// The ONE reader of the finding grammar +/// `[verdict '' ]rule ''[ at ](; )*[ — ]`. /// -/// Called from the `SessionStart` arm beside the wiring record's own clear. A -/// store that outlived its session would withhold a remedy from a reader who has -/// not seen it, which is the failure this exists to prevent — so the clear is the -/// load-bearing half, not the bookkeeping half. -pub fn forget_sightings(root: &Path) { - if let Ok(dir) = crate::git::git_dir(root) { - let _ = std::fs::remove_dir_all(dir.join(STORE)); +/// `None` for a line that does not open with a label, which is every line that +/// is not a finding. +#[must_use] +pub fn parse_finding(line: &str) -> Option { + let mut rest = line; + let mut verdict = None; + if let Some(after) = rest.strip_prefix("verdict ") { + let (name, tail) = unquote(after)?; + verdict = Some(name); + rest = tail.strip_prefix(' ')?; + } + let (rule, _) = unquote(rest.strip_prefix("rule ")?)?; + let (head, tail) = match line.split_once(" —") { + Some((head, tail)) => (head, Some(tail)), + None => (line, None), + }; + let arm = if tail.is_some() { + Arm::Full + } else { + Arm::Pointer + }; + // THE DEFINITION IS THE LINE MINUS ITS PER-FIRING PARTS: the labels and + // subjects (the first `; `-segment) and every override request, whose + // `--subject` varies per firing. + let routes: Vec<&str> = head + .split("; ") + .skip(1) + .filter(|segment| !segment.starts_with(OVERRIDE_OPENER)) + .collect(); + let definition = format!("{} —{}", routes.join("; "), tail.unwrap_or_default()); + let key = key_of(&rule, verdict.as_deref(), &definition); + Some(Parsed { + verdict, + rule, + arm, + key, + }) +} + +/// Cut every full arm `mark` reports already seen to its pointer prefix, +/// leaving every other line byte-identical (CLOUD-2075 §D). `mark(key, full)` +/// consults and marks the reader's store and answers whether this is the first +/// sighting. +//MUTANT boundary-collapse-skipped|s@^ let rewritten = if arm == Arm::Pointer { pointer } else { body };$@ let rewritten = body;@|collapse_cuts_a_marked_full_arm_to_its_pointer_prefix +pub fn collapse(text: &str, mut mark: impl FnMut(&str, &str) -> bool) -> String { + let mut lines: Vec<&str> = Vec::new(); + for body in text.split('\n') { + let arm = match parse_finding(body) { + Some(parsed) if parsed.arm == Arm::Full && !mark(&parsed.key, body) => Arm::Pointer, + Some(_) | None => Arm::Full, + }; + let pointer = body.split(" —").next().unwrap_or(body); + let rewritten = if arm == Arm::Pointer { pointer } else { body }; + lines.push(rewritten); } + lines.join("\n") } /// Where the per-session sightings live, under `$GIT_DIR`. @@ -474,6 +592,174 @@ pub fn forget_sightings(root: &Path) { /// receipts would put a note where every reader expects a claim. const STORE: &str = "batten-sightings"; +/// Which of a finding's two renderings a firing gets (CLOUD-2075). +/// +/// The pointer arm is a byte PREFIX of the full arm and carries the same +/// subjects and routes; the full arm adds the ` — ` tail. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Arm { + /// Labels, subjects, every route and the definition — once per context per + /// compaction cycle. + Full, + /// Labels, subjects and every route, on every other firing. + Pointer, +} + +/// The two names a finding line labels, so a reader can always tell a rule +/// from a verdict (CLOUD-2075). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Label { + /// A declared class, dereferenced by `batten policy explain`. + Verdict, + /// A `[[rule]]` id, dereferenced by `batten policy rule`. + Rule, +} + +impl Label { + /// The label's word. + #[must_use] + pub const fn word(self) -> &'static str { + match self { + Label::Verdict => "verdict", + Label::Rule => "rule", + } + } +} + +/// The ONE spelling of `verdict ''` and `rule ''`. Every emitter +/// that labels a name calls this; `emission_census` refuses a hand-spelled one. +#[must_use] +pub fn label(kind: Label, name: &str) -> String { + format!("{} {}", kind.word(), quoted(name)) +} + +/// A name in single quotes. A `'` inside it is written `\'`; [`parse_finding`] +/// undoes exactly that. +fn quoted(name: &str) -> String { + format!("'{}'", name.replace('\'', "\\'")) +} + +/// Pointer text made safe for the grammar: it can never contain the tail +/// opener or the clause separator. +fn plain(text: &str) -> String { + text.replace(" — ", " - ").replace("; ", ", ") +} + +/// How every rendered override route opens; [`parse_finding`] drops these +/// segments from an item's definition because their subject varies per firing. +const OVERRIDE_OPENER: &str = "admit with batten override request "; + +/// CLOUD-1806's class route. The hop [`finding_line`] appends names the same +/// verb with the real id, so a route whose target is this placeholder is +/// dropped at render rather than printed twice. +pub(crate) const RULE_HOP_PLACEHOLDER: &str = "batten policy rule ''"; + +/// The one projection every channel carries (CLOUD-2075): +/// `[verdict '' ]rule ''[ at ](; )*[; run batten policy rule ''][ — ]`. +/// +/// Both labels are printed even where the id equals the token, so a reader can +/// always tell a rule from a verdict. Every route — override routes included, +/// each a ready `override request` with the subject the refusal binds — is on +/// BOTH arms, so the pointer arm sheds no way out. The full arm adds the +/// definition: the class gloss (or the undeclared refusal's reason), the row's +/// own remedy, each override's precondition, and the `explain` hop. +/// +/// Nothing is shed and no ceiling is consulted: `[refusal]`'s keys are +/// measured by `refusal_ceiling`'s corpus case, which reports an over-ceiling +/// line rather than truncating one. +//MUTANT full-arm-reason-dropped|s@^ if let Some(remedy) = refusal.remedy() {$@ if let Some(remedy) = None::<\&str> {@|a_first_sighting_carries_the_rows_reason_and_both_labels +//MUTANT pointer-arm-routes-dropped|s@^ for route in routes(refusal) {$@ for route in routes(refusal).into_iter().take(if arm == Arm::Full { usize::MAX } else { 0 }) {@|the_pointer_arm_carries_every_route_and_subject_the_full_arm_does +//MUTANT collapsed-row-unlabelled|s@^ if let Some(token) = refusal.verdict() {$@ if let Some(token) = refusal.verdict() \&\& token != refusal.rule() {@|a_collapsed_row_still_labels_rule_and_verdict +//MUTANT advice-routes-dropped|s@^ for route in routes(refusal) {$@ for route in routes(refusal).into_iter().take(0) {@|a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call +//MUTANT rule-label-dropped|s@^ line.push_str(\&label(Label::Rule, refusal.rule()));$@ line.push_str(refusal.rule());@|every_hook_policy_table_deny_names_its_fix +fn finding_line(refusal: &Refusal, arm: Arm) -> String { + let mut line = String::new(); + if let Some(token) = refusal.verdict() { + line.push_str(&label(Label::Verdict, token)); + line.push(' '); + } + line.push_str(&label(Label::Rule, refusal.rule())); + if !refusal.subjects.is_empty() { + line.push_str(" at "); + line.push_str(&plain(&refusal.subjects)); + } + for route in routes(refusal) { + line.push_str("; "); + line.push_str(&route); + } + if refusal.dereferenceable { + line.push_str("; run batten policy rule "); + line.push_str("ed(refusal.rule())); + } + if arm == Arm::Pointer { + return line; + } + line.push_str(" —"); + let definition = if refusal.verdict.is_some() { + &refusal.gloss + } else { + &refusal.reason + }; + if !definition.is_empty() { + line.push(' '); + line.push_str(&sentence(definition)); + } + if let Some(remedy) = refusal.remedy() { + line.push(' '); + line.push_str(&sentence(remedy)); + } + for (id, precondition) in refusal.preconditions() { + line.push_str(" Admissible as "); + line.push_str("ed(id)); + line.push_str(" when "); + line.push_str(&sentence(precondition)); + } + if let Some(class) = refusal.verdict() { + line.push_str(" Run batten policy explain "); + line.push_str("ed(class)); + line.push('.'); + } + line +} + +/// Every route rendered, de-duplicated, in declaration order. +fn routes(refusal: &Refusal) -> Vec { + let mut rendered: Vec = Vec::new(); + for route in &refusal.routes { + if let Some(text) = route_text(refusal, route) + && !rendered.contains(&text) + { + rendered.push(text); + } + } + rendered +} + +/// One route's text, or `None` where it does not render. +//MUTANT override-route-unrendered|s@^ if route.kind == RouteKind::Override {$@ if route.kind == RouteKind::Override \&\& false {@|the_override_route_on_the_line_is_the_request_that_admits +//MUTANT route-reader-unnamed|s@^ Some(reader) => format!("{text} via {reader}"),$@ Some(_) => text,@|a_document_route_into_a_read_redirected_path_names_its_reader +fn route_text(refusal: &Refusal, route: &crate::verdict::Route) -> Option { + use crate::verdict::RouteKind; + if route.kind == RouteKind::Override { + let class = refusal.verdict()?; + let subject = refusal.bindings().first()?; + return Some(format!( + "{OVERRIDE_OPENER}--rule {} --verdict {} --subject {}", + quoted(refusal.rule()), + quoted(class), + quoted(subject), + )); + } + if route.target == RULE_HOP_PLACEHOLDER { + return None; + } + let text = plain(&crate::verdict::render_route(route)?); + Some(match refusal.readers.get(&route.target) { + Some(reader) => format!("{text} via {reader}"), + None => text, + }) +} + impl Refusal { /// Build a refusal. The [`Fix`] is required, which is the contract. pub fn new(rule: impl Into, reason: impl Into, fix: Fix) -> Refusal { @@ -484,17 +770,79 @@ impl Refusal { // rendering arm asks whether there is anything to say, not whether a // class exists. routes: Vec::new(), + subjects: String::new(), + readers: std::collections::BTreeMap::new(), + dereferenceable: true, // Likewise: no class, so no gloss. The undeclared arm's payload is - // the consumer's own `reason`, which `render` already carries. + // the consumer's own `reason`, which the full arm carries. gloss: String::new(), verdict: None, reason: reason.into(), fix, // A consumer-composed refusal carries no declared class, so there is - // no token an admission could bind (`rules.rs`'s own words) — and a - // subject with nothing to bind it to would read as admissible. - subject: None, + // nothing a request could name — and a binding with no class behind + // it would read as admissible. + bindings: Vec::new(), + } + } + + /// The pointers this firing names, for a refusal composed from prose + /// (CLOUD-2075). A declared refusal takes them from its subjects instead. + #[must_use] + pub fn at(mut self, subjects: impl Into) -> Refusal { + self.subjects = subjects.into(); + self + } + + /// The refusal for a config that did not load: no `policy rule` hop + /// resolves, so the line names none (CLOUD-2075). + #[must_use] + pub fn unloaded(rule: impl Into, reason: impl Into, fix: Fix) -> Refusal { + Refusal { + dereferenceable: false, + ..Refusal::new(rule, reason, fix) + } + } + + /// Name the reader each document route's target is read through, where the + /// consumer's `[[redirect]]` declares one (CLOUD-2075). + #[must_use] + pub fn read_through(mut self, redirects: &[crate::redirect::Redirect]) -> Refusal { + for route in &self.routes { + if route.kind == crate::verdict::RouteKind::Document + && let Some(reader) = crate::redirect::resolve_read(redirects, &route.target) + { + self.readers.insert(route.target.clone(), reader.to_owned()); + } + } + self + } + + /// The one projection (CLOUD-2075); see [`finding_line`]. + #[must_use] + pub fn render_finding(&self, arm: Arm) -> String { + finding_line(self, arm) + } + + /// The row's own remedy: the `Fix::Run` text, unless it is one of the + /// class's route targets already on the line. + #[must_use] + pub fn remedy(&self) -> Option<&str> { + let text = self.fix.declared_alternative()?; + if self.routes.iter().any(|route| route.target == text) { + return None; } + Some(text) + } + + /// `(id, precondition)` for each override route the class declares. + #[must_use] + pub fn preconditions(&self) -> Vec<(&str, &str)> { + self.routes + .iter() + .filter(|route| route.kind == crate::verdict::RouteKind::Override) + .filter_map(|route| Some((route.id.as_str(), route.precondition.as_deref()?))) + .collect() } /// Build one of Batten's OWN refusals, from a declared class (CLOUD-1050). @@ -549,21 +897,27 @@ impl Refusal { // Resolved here rather than at the boundary because the registry is // already in hand — a second lookup downstream would be a second // authority over which routes the class declares. - routes: crate::verdict::sighting_routes(registry, token), + routes: crate::verdict::resolve(registry, token) + .map(|(entry, _)| entry.routes.clone()) + .unwrap_or_default(), + subjects: crate::verdict::render_subjects(subjects), + readers: std::collections::BTreeMap::new(), + dereferenceable: true, gloss: crate::verdict::gloss_of(registry, token) .unwrap_or_default() .to_owned(), verdict: Some(token.to_owned()), reason: crate::verdict::render_line(registry, token, subjects), fix, - subject: admission_subject(subjects), + bindings: admission_bindings(token, subjects), } } - /// The canonical subject an admission binds to, when this refusal names one. + /// Every spelling a mediated admission binds to, printed spelling first + /// ([`admission_bindings`]). Empty only for a consumer-composed refusal. #[must_use] - pub fn subject(&self) -> Option<&str> { - self.subject.as_deref() + pub fn bindings(&self) -> &[String] { + &self.bindings } /// The declared class, or `None` for a refusal composed from consumer prose. @@ -605,26 +959,24 @@ impl Refusal { /// rules/scanning.md` is what the reader of a refused `grep` needed. /// [`crate::verdict::sighting_routes`] carries the mapping and the exhaustive /// match that keeps a future kind from being dropped silently. + /// + /// Rendered exactly as the line carries them, override routes included + /// (CLOUD-2075). #[must_use] - pub fn routes(&self) -> &[String] { - &self.routes + pub fn routes(&self) -> Vec { + routes(self) } - /// What the sightings store keys this refusal by: the rule and the class. - /// - /// Composed here rather than at the boundary because it is a fact about the - /// refusal, and because the two halves are private. The separator is a unit - /// separator, which neither a rule id nor a three-word class can contain, so - /// no two distinct pairs can collide on one key. + /// What the sightings store keys this refusal by: the rule, the class and + /// the definition (CLOUD-2075). /// - /// Degenerates to the rule id for an undeclared refusal, which has no class — - /// the honest key there, since the rule's own prose is the whole payload. + /// Read off the full arm through [`parse_finding`], so ONE authority keys + /// both a refusal the engine built and a line the boundary read back from + /// tool output. #[must_use] pub fn sighting_key(&self) -> String { - match self.verdict() { - Some(token) => format!("{}\u{1f}{token}", self.rule), - None => self.rule.clone(), - } + let full = self.render_finding(Arm::Full); + parse_finding(&full).map_or(full, |parsed| parsed.key) } /// The class's one-line gloss, or empty for a refusal with no class. @@ -638,99 +990,6 @@ impl Refusal { &self.gloss } - /// The text projection every channel carries. - /// - /// `Refused by : Fix: .` — one sentence of cause and one - /// of remedy, in that order, with the remedy clause **always present**. A - /// channel may append its own trailing note (the mediation hatch is - /// [`crate::hook`]'s, not a refusal's), but nothing may drop the fix clause, - /// because dropping it is exactly the bare "no" this contract exists to - /// prevent. - #[must_use] - pub fn render(&self) -> String { - let fix = match &self.fix { - Fix::Run(text) => text.as_str(), - Fix::None => NO_DECLARED_FIX, - }; - format!( - "Refused by {}: {} Fix: {}", - self.rule, - sentence(&self.reason), - sentence(fix) - ) - } - - /// What the HOT PATH emits: the declared class and its pointers, and nothing - /// else (CLOUD-1286). - /// - /// [`Refusal::render`] is the projection for a surface with no budget - /// pressure — `check`'s findings, a report, anything a human reads once. This - /// is the projection for a surface that pays for every byte on every - /// subsequent turn, and the two are deliberately different rather than one - /// wrapper being shortened for everybody. - /// - /// **Three clauses go, and each is a copy of something already declared.** - /// `Refused by :` restates a token that names its own class; the - /// parenthetical gloss IS the class's definition inlined; `Fix:` is the - /// class's first `command` route, which `batten policy explain ` - /// prints along with every other route the class declares — the override - /// route included, which the `Fix:` clause could never reach by construction. - /// So the decision this row owed in writing is: **the token is the pointer to - /// the fix**, one hop, and the hop is the same command for all four clauses - /// rather than a different lookup for each. - /// - /// **The RULE ID stays, as a trailing pointer rather than as a prefix.** What - /// goes is `Refused by :` — five tokens of framing around one useful - /// word. The word itself is not framing: two rows can raise the same class, - /// and `explain` answers about the class and cannot say which row fired, so - /// dropping the id would leave a reader unable to find the config line that - /// refused them. It varies per firing, which is exactly the test this row - /// applies — the prose that repeats is what moves behind the dereference, - /// and the pointers that change stay inline. - /// - /// **CLOUD-1637 counted that argument and it holds for more rows than it - /// claimed.** "Two rows can raise the same class" reads as an edge case; it - /// is 66 of the 128 `[[rule]]` rows in this repository's own config. Rows of - /// kind `shape`, `receipt`, `forbid`, `pipeline`, `command`, `ratchet` and - /// `secrets` declare no class of their own and raise their kind's native one - /// — fourteen `shape` rows all raise `call name refused`, ten `receipt` rows - /// share four classes. So the id is not a tie-breaker for a rare collision, - /// it is the only discriminator for half the population, and it renders on - /// BOTH arms rather than on the first sighting alone. - /// - /// The second half of the argument is now true in a way it was not when it - /// was written: `explain` still cannot say which row fired, and - /// `batten policy rule ` is the verb that can. The id is a live pointer - /// rather than a string to grep for. - /// - /// **An UNDECLARED refusal keeps the long form**, and that is not a hole. A - /// refusal composed from consumer prose carries no token, so a bare line - /// would be a bare "no" — precisely the thing CLOUD-122 exists to forbid. - /// Concision is bought with a class a reader can look up; where there is no - /// class there is nothing to buy it with, and the long form is the honest - /// answer rather than a fallback. - /// **A COLLAPSED ROW RENDERS ONE TOKEN** (CLOUD-1638). Where the row IS its - /// class's sole raiser the two names are one, and `validate` refuses any - /// other spelling at load — so appending the id would print the same three - /// words twice. The 116 rows that are not collapsed still carry it, for the - /// reason above: it is their only discriminator. - /// - /// Decided from the strings rather than from a flag, because the load-time - /// predicate has already made them equal exactly when they name one thing, - /// and re-deriving the condition here would be a second authority over it. - #[must_use] - pub fn line(&self) -> String { - match self.verdict() { - // `reason` already IS `render_line`'s output for a declared refusal — - // token plus pointers — so this is a projection rather than a second - // renderer. Composing the line here from the token and the subject - // would be a second authority over a string the composer built. - Some(token) if token == self.rule => self.reason.clone(), - Some(_) => format!("{} {}", self.reason, self.rule), - None => self.render(), - } - } - /// The machine-readable payload: `{rule, reason, fix}`, byte-stable. /// /// `hook` has no `-J` channel by design — its stdout is already a @@ -866,18 +1125,19 @@ mod tests { } #[test] - fn the_rendering_always_carries_a_fix_clause() { + fn every_line_names_its_rule_hop_whatever_the_fix() { // Both dispositions, because the one that matters is the one with nothing - // declared: that is where a bare "no" would come from. - assert!( - Refusal::new("g", "why", Fix::None) - .render() - .contains("Fix: none declared") - ); + // declared: the `policy rule` hop is the way out there (CLOUD-2075). + for fix in [Fix::None, Fix::Run("do this".to_owned())] { + let full = Refusal::new("g", "why", fix).render_finding(Arm::Full); + assert!(full.contains("; run batten policy rule 'g'"), "{full}"); + } assert!( - Refusal::new("g", "why", Fix::Run("do this".to_owned())) - .render() - .contains("Fix: do this.") + Refusal::unloaded("g", "why", Fix::None) + .render_finding(Arm::Full) + .find("policy rule") + .is_none(), + "a config that did not load resolves no rule hop" ); } @@ -888,13 +1148,62 @@ mod tests { // rather than doubled. let authored = Refusal::new("g", "Because it does.", Fix::Run("mise run x".to_owned())); assert_eq!( - authored.render(), - "Refused by g: Because it does. Fix: mise run x." + authored.render_finding(Arm::Full), + "rule 'g'; run batten policy rule 'g' — Because it does. mise run x." ); let bare = Refusal::new("g", "because it does", Fix::Run("mise run x.".to_owned())); assert_eq!( - bare.render(), - "Refused by g: because it does. Fix: mise run x." + bare.render_finding(Arm::Full), + "rule 'g'; run batten policy rule 'g' — because it does. mise run x." + ); + } + + #[test] + fn the_pointer_arm_is_a_byte_prefix_of_the_full_arm() { + let refusal = Refusal::declared( + "protected-mutation", + crate::verdict::Native::ProtectedMutation, + &[crate::verdict::Subject::Path { + path: "docs/a.md".to_owned(), + }], + Fix::None, + ); + let full = refusal.render_finding(Arm::Full); + let pointer = refusal.render_finding(Arm::Pointer); + assert!(full.starts_with(&pointer), "{pointer}\n{full}"); + assert!(!pointer.contains(" —"), "{pointer}"); + assert!( + pointer.starts_with("verdict 'path write refused' rule 'protected-mutation' at "), + "{pointer}" + ); + let parsed = parse_finding(&full).expect("the full arm parses"); + assert_eq!(parsed.arm, Arm::Full); + assert_eq!(parsed.rule, "protected-mutation"); + assert_eq!(parsed.verdict.as_deref(), Some("path write refused")); + assert_eq!( + parse_finding(&pointer).expect("the pointer parses").arm, + Arm::Pointer + ); + assert_eq!(parsed.key, refusal.sighting_key()); + } + + #[test] + fn a_quote_in_a_name_round_trips_through_the_grammar() { + let line = format!("{} — x.", label(Label::Rule, "it's")); + assert_eq!(parse_finding(&line).expect("parses").rule, "it's"); + assert!(parse_finding("not a finding").is_none()); + } + + #[test] + fn collapse_cuts_a_marked_full_arm_to_its_pointer_prefix() { + let full = Refusal::new("g", "why", Fix::None).render_finding(Arm::Full); + let pointer = Refusal::new("g", "why", Fix::None).render_finding(Arm::Pointer); + let text = format!("{full}\ncanary\n{full}\n{pointer}\nrule 'unterminated"); + let mut seen = std::collections::HashSet::new(); + let cut = collapse(&text, |key, _| seen.insert(key.to_owned())); + assert_eq!( + cut, + format!("{full}\ncanary\n{pointer}\n{pointer}\nrule 'unterminated") ); } @@ -939,11 +1248,11 @@ mod sightings { fn a_class_explains_itself_once_and_then_stops() { let dir = repo("once"); assert!( - first_sighting(&dir, "branch write unsafe"), - "a class this session has not raised explains itself" + first_sighting(&dir, "s", "branch write unsafe", "full"), + "a class this context has not raised explains itself" ); assert!( - !first_sighting(&dir, "branch write unsafe"), + !first_sighting(&dir, "s", "branch write unsafe", "full"), "and does not explain itself a second time" ); } @@ -952,27 +1261,33 @@ mod sightings { #[test] fn each_class_is_counted_on_its_own() { let dir = repo("keyed"); - assert!(first_sighting(&dir, "branch write unsafe")); + assert!(first_sighting(&dir, "s", "branch write unsafe", "a")); assert!( - first_sighting(&dir, "path write refused"), + first_sighting(&dir, "s", "path write refused", "b"), "a different class has still never been seen" ); } - /// THE CLEAR IS THE LOAD-BEARING HALF. A store that outlived its session would - /// withhold the remedy from a reader who has never read it — the exact defect - /// the store exists to prevent, reintroduced by forgetting to forget. + /// THE CLEAR IS THE LOAD-BEARING HALF, and it is per context: forgetting one + /// context leaves another's sightings alone (CLOUD-2075). #[test] fn a_new_session_hears_it_again() { let dir = repo("cleared"); - assert!(first_sighting(&dir, "branch write unsafe")); - assert!(!first_sighting(&dir, "branch write unsafe")); + assert!(first_sighting(&dir, "a", "branch write unsafe", "full")); + assert!(first_sighting(&dir, "b", "branch write unsafe", "full")); + assert!(!first_sighting(&dir, "a", "branch write unsafe", "full")); - forget_sightings(&dir); + assert_eq!(sighted(&dir, "a"), vec!["full".to_owned()]); + forget_sightings(&dir, "a"); + assert!(sighted(&dir, "a").is_empty()); assert!( - first_sighting(&dir, "branch write unsafe"), + first_sighting(&dir, "a", "branch write unsafe", "full"), "session start forgets, so the next reader is told" ); + assert!( + !first_sighting(&dir, "b", "branch write unsafe", "full"), + "and another context keeps what it saw" + ); } /// A TREE WITH NO GIT DIRECTORY ANSWERS TRUE, which is the safe direction: @@ -983,9 +1298,9 @@ mod sightings { let dir = std::env::temp_dir().join(format!("batten-no-git-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).expect("the fixture directory"); - assert!(first_sighting(&dir, "branch write unsafe")); + assert!(first_sighting(&dir, "s", "branch write unsafe", "full")); assert!( - first_sighting(&dir, "branch write unsafe"), + first_sighting(&dir, "s", "branch write unsafe", "full"), "and keeps doing so, because nothing could record that it had" ); } diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index a3e434dcb..6ff255580 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -6292,11 +6292,16 @@ pub struct Finding { /// carries no line and prints its pointer without one rather than inventing a /// number it does not have — the `None` arm is a different pointer, not a /// degraded one. +/// +/// The rule is LABELLED (CLOUD-2075), so a reader can always tell a rule from a +/// verdict: `[:] rule ''[ ]`. +//MUTANT check-rule-unlabelled|s@^ let rule = crate::refusal::label(crate::refusal::Label::Rule, \&self.rule);$@ let rule = self.rule.clone();@|a_check_finding_line_labels_its_rule impl crate::output::Line for Finding { fn line(&self) -> String { + let rule = crate::refusal::label(crate::refusal::Label::Rule, &self.rule); let at = match self.line { - Some(line) => format!("{}:{} {}", self.path, line, self.rule), - None => format!("{} {}", self.path, self.rule), + Some(line) => format!("{}:{} {rule}", self.path, line), + None => format!("{} {rule}", self.path), }; // Appended rather than interpolated into the pointer, so a consumer // parsing `path:line rule` off the front still parses it. @@ -6825,7 +6830,7 @@ fn run_static_inner( }], Fix::Run(SPAWNING_VERB.to_owned()), ) - .render(), + .render_finding(crate::refusal::Arm::Full), )); } } @@ -10186,9 +10191,9 @@ fn policy_rule( // left the reader to find the file themselves. A `subjects` entry IS // a pointer, which is the whole reason the field is tagged rather // than free, so the first path-bearing one is what the finding - // carries. A class whose subjects are counts or artifacts still - // falls back to the bundle root, which is the honest pointer when - // the finding is about a set rather than a file. + // carries. A path-less list carries its first rendered subject + // (`first_pointer`); the bundle root is reached only for an empty + // list, where the finding has nothing narrower to point at. path: pointer.clone().unwrap_or_else(|| { rule.bundle .clone() @@ -10293,6 +10298,14 @@ fn policy_remediation( /// [`crate::verdict::Subject::render`]'s, so a count says the same thing here as /// it does on the mediated path, and a reader cannot mistake `2 file(s)` for a /// file they could open. +/// +/// # The tree pointer, deliberately not the mediated binding +/// +/// This is the tree finding's pointer and its admission subject, anchored by +/// fingerprint. A mediated refusal binds every pointer it prints instead +/// (`refusal::admission_bindings`, CLOUD-1826): a tree finding has one `path` +/// slot, and forcing the two to agree would widen tree output or drop pointers +/// from the mediated binding. fn first_pointer(subjects: &[crate::verdict::Subject]) -> (Option, Option) { for subject in subjects { match subject { @@ -13667,6 +13680,27 @@ pub fn glob_match(pattern: &str, path: &str) -> bool { mod tests { use super::PathSet; + /// CLOUD-2075 §7 case 21: a `check` finding labels its rule. + #[test] + fn a_check_finding_line_labels_its_rule() { + use crate::output::Line as _; + let finding = super::Finding { + owner: None, + rule: "r".to_owned(), + severity: super::RuleSeverity::Deny, + path: "src/a.rs".to_owned(), + line: Some(3), + identity: crate::identity::StoredIdentity::new( + crate::identity::FindingKind::Scope, + crate::identity::scope_fingerprint("r", "src/a.rs"), + ), + check: crate::findings::Check::Reevaluate, + remediation: Some(crate::findings::Remediation::NoFix("fixture".to_owned())), + reason: None, + }; + assert_eq!(finding.line(), "src/a.rs:3 rule 'r'"); + } + /// CLOUD-609's guard on the change itself: `contains` still means MEMBERSHIP. /// /// Asserted directly rather than through a caller, because widening it would diff --git a/crates/batten/src/secrets.rs b/crates/batten/src/secrets.rs index 3b30c021f..23dfc1862 100644 --- a/crates/batten/src/secrets.rs +++ b/crates/batten/src/secrets.rs @@ -1117,7 +1117,7 @@ fn resolve_scanner(provisions: &[Provision], root: &Path) -> Result { }], Fix::Run(PROVISION_VERB.to_owned()), ) - .render(), + .render_finding(crate::refusal::Arm::Full), )); }; // The anchor is a RELATIVE path (`.`) whenever the config sits in the cwd, @@ -1146,7 +1146,7 @@ fn resolve_scanner(provisions: &[Provision], root: &Path) -> Result { }], Fix::Run(PROVISION_VERB.to_owned()), ) - .render(), + .render_finding(crate::refusal::Arm::Full), )); } Ok(path) diff --git a/crates/batten/src/selfwrite.rs b/crates/batten/src/selfwrite.rs index 75d8c7a14..fdcd05884 100644 --- a/crates/batten/src/selfwrite.rs +++ b/crates/batten/src/selfwrite.rs @@ -201,6 +201,7 @@ pub fn scan(stream: &Stream, memory_root: &str) -> Vec { // A cost, not a decision: CLOUD-417's counter is `hookcost`'s and this // predicate has nothing to say about it. | Event::HookOutput { .. } + | Event::SessionBoundary | Event::AssistantText => {} } } diff --git a/crates/batten/src/session.rs b/crates/batten/src/session.rs index a5971a791..bf2b07246 100644 --- a/crates/batten/src/session.rs +++ b/crates/batten/src/session.rs @@ -494,6 +494,24 @@ pub fn save_watermark(store_dir: &Path, root: &Root, watermark: &Watermark) -> R write_record(store_dir, &record) } +/// Clear what the drain last told `root`'s lineage, keeping its cycle ordinal +/// (CLOUD-2075): after a `SessionStart` the context no longer holds the payload +/// the `unchanged` marker would point at, so the next drain lists in full. +/// +/// # Errors +/// +/// Returns an error when the record cannot be written or published. +pub fn forget_result(store_dir: &Path, root: &Root) -> Result<()> { + let Some(mut record) = read_record(&record_path(store_dir, &root.key)) else { + return Ok(()); + }; + let Some(mark) = record.watermark.as_mut() else { + return Ok(()); + }; + mark.result_id.clear(); + write_record(store_dir, &record) +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/src/stop.rs b/crates/batten/src/stop.rs index fae4dd028..ff93a096f 100644 --- a/crates/batten/src/stop.rs +++ b/crates/batten/src/stop.rs @@ -326,7 +326,11 @@ mod tests { assert_eq!(refusal.rule(), RULE); // One command, not a menu: the contract is one hop to right. assert_eq!(refusal.fix(), &Fix::Run("mise run check".to_owned())); - assert!(refusal.render().contains("denial: first")); + assert!( + refusal + .render_finding(crate::refusal::Arm::Full) + .contains("denial: first") + ); } #[test] @@ -338,8 +342,14 @@ mod tests { pending: Vec::new(), }; let refusal = facts.refusal().expect("at-risk work blocks"); - assert!(matches!(refusal.fix(), Fix::Run(_))); - assert!(refusal.render().contains("Fix:")); + let Fix::Run(fix) = refusal.fix() else { + panic!("at-risk work names a fix"); + }; + assert!( + refusal + .render_finding(crate::refusal::Arm::Full) + .contains(fix.as_str()) + ); } #[test] diff --git a/crates/batten/src/transcript.rs b/crates/batten/src/transcript.rs index d099390ec..818c31ea9 100644 --- a/crates/batten/src/transcript.rs +++ b/crates/batten/src/transcript.rs @@ -402,6 +402,12 @@ pub enum Event { /// match. A substring or prefix comparison would have to hold the text to /// make it, which is the payload this variant refuses to carry. digest: String, + /// The labelled findings the emission carried, one per line that + /// [`crate::refusal::parse_finding`] reads, as its key and arm + /// (CLOUD-2075). A key is a digest plus the two names, so the text still + /// dies inside [`collect`]; this is what lets the session measure judge a + /// finding per compaction cycle rather than per byte. + findings: Vec<(String, crate::refusal::Arm)>, }, /// The assistant said something to the operator: a non-empty `text` block /// on an assistant turn. @@ -412,6 +418,11 @@ pub enum Event { /// It exists so "a human spoke and the session answered only with tool /// calls" is a typed question rather than a reading of prose. AssistantText, + /// A `SessionStart` hook ran: the boundary of a compaction cycle + /// (CLOUD-2075), inside which a finding's full arm is delivered once. + /// + /// **APPENDED, for [`Event::MemoryInjection`]'s reason.** + SessionBoundary, } /// One event and where it was found. @@ -690,7 +701,7 @@ impl Stream { // breakdown. `hook_decisions` beside it is a different question // — how many hooks DECIDED, not what they said — which is // exactly the pair this variant exists to keep apart. - Event::HookOutput { .. } | Event::AssistantText => {} + Event::HookOutput { .. } | Event::SessionBoundary | Event::AssistantText => {} } } counts @@ -707,9 +718,10 @@ impl Stream { Event::ToolCall { .. } => kinds.insert(Kind::ToolCalls), Event::ToolResult { .. } => kinds.insert(Kind::ToolResults), Event::HookDecision { .. } => kinds.insert(Kind::HookDecisions), - Event::MemoryInjection { .. } | Event::HookOutput { .. } | Event::AssistantText => { - false - } + Event::MemoryInjection { .. } + | Event::HookOutput { .. } + | Event::SessionBoundary + | Event::AssistantText => false, }; } kinds @@ -1020,11 +1032,20 @@ fn collect_attachment(attachment: &Attachment, line: usize, records: &mut Vec Vec<(String, crate::refusal::Arm)> { + let mut texts: Vec = Vec::new(); + texts.extend(self.additional_context.iter().cloned()); + texts.extend(self.stderr.iter().cloned()); + if let Some(document) = self + .stdout + .as_deref() + .and_then(|stdout| serde_json::from_str::(stdout).ok()) + { + let specific = &document["hookSpecificOutput"]; + for key in ["permissionDecisionReason", "additionalContext"] { + if let Some(text) = specific[key].as_str() { + texts.push(text.to_owned()); + } + } + } + texts + .iter() + .flat_map(|text| text.lines()) + .filter_map(crate::refusal::parse_finding) + .map(|parsed| (parsed.key, parsed.arm)) + .collect() + } + /// Which producer this cost belongs to. /// /// `hookName` first, its `hookEvent` next, the tag last. Never a constant of @@ -1545,6 +1593,10 @@ const QUEUED_COMMAND: &str = "queued_command"; /// The host's `origin.kind` for a record the operator authored. const HUMAN_ORIGIN: &str = "human"; +/// The host's `hookEvent` for a session start, the compaction-cycle boundary +/// (CLOUD-2075). +const SESSION_START: &str = "SessionStart"; + /// The host's authorship record on a line or an attachment. #[derive(Debug, Deserialize)] struct HostOrigin { diff --git a/crates/batten/src/verdict.rs b/crates/batten/src/verdict.rs index 28713b6ae..b3f49e4e7 100644 --- a/crates/batten/src/verdict.rs +++ b/crates/batten/src/verdict.rs @@ -425,6 +425,15 @@ impl Subject { } } + /// The path this subject names, for a `Path` or a `Line`; `None` otherwise. + #[must_use] + pub fn path(&self) -> Option<&str> { + match self { + Subject::Path { path } | Subject::Line { path, .. } => Some(path), + Subject::Count { .. } | Subject::Artifact { .. } => None, + } + } + /// Read one subject off a policy module's `subjects` array. /// /// `None` is could-not-look, never an empty subject: a member whose shape @@ -476,6 +485,30 @@ pub fn render_subjects(subjects: &[Subject]) -> String { .join(" ") } +/// The spelling an admission binds a subject in — the one function both sides +/// of the mediated surface spell through (CLOUD-1826, CLOUD-1997). +/// +/// A refusal PRINTS its pointers joined by a space ([`render_subjects`]); a +/// reader copies that line into `override request --subject`. So whitespace is +/// rejoined with `,`, and the binding side (`refusal::admission_bindings`) runs +/// what it prints through this same function — which is what makes a pasted line +/// bind as the refusal does. +/// +/// **The `/` exception exists for the TREE surface**: a tree admission is matched +/// against `finding.path` verbatim, and a path like `docs/a b.md` rejoined would +/// name nothing any finding carries. Idempotent: a `/`-bearing text comes back +/// trimmed, and any other text has no whitespace left after the join. +//MUTANT rendered-subject-unmatched|s@^ trimmed.split_whitespace().collect::>().join(",")$@ trimmed.to_owned()@|a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does +//MUTANT path-subject-split|s@^ if trimmed.contains('/') {$@ if false {@|a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does +#[must_use] +pub fn bound_subject(text: &str) -> String { + let trimmed = text.trim(); + if trimmed.contains('/') { + return trimmed.to_owned(); + } + trimmed.split_whitespace().collect::>().join(",") +} + /// What a refusal says on the hot path: the token and its pointers, and stops /// (CLOUD-1053, narrowed by CLOUD-1286). /// @@ -597,7 +630,7 @@ pub fn first_command_route<'a>(registry: &'a [DeclaredVerdict], token: &str) -> //MUTANT-SUITE crates/batten/tests/it/refusal_ceiling.rs //MUTANT document-route-dropped|s@ RouteKind::Document => "read",@ RouteKind::Document => return None,@|a_first_sighting_carries_the_gloss_and_its_route_by_kind #[must_use] -fn render_route(route: &Route) -> Option { +pub(crate) fn render_route(route: &Route) -> Option { let verb = match route.kind { RouteKind::Command => "run", RouteKind::Document => "read", @@ -1622,9 +1655,9 @@ pub struct VendoredVerdict { /// What the boundary may do about it (CLOUD-1639). /// /// A plain field with no default, unlike the consumer table's: a `const` - /// initialiser cannot omit one, and spelling `Applicability::Advice` on each - /// vendored row is what makes any that is NOT advice visible - /// in a diff rather than inferred from an absence. + /// initialiser cannot omit one, and spelling `Applicability::Advice` on every + /// vendored row is what makes the ones that are NOT advice visible in a diff + /// rather than inferred from an absence. pub applicability: Applicability, } @@ -1663,6 +1696,7 @@ pub const fn read(id: &'static str, target: &'static str) -> VendoredRoute { } } +//MUTANT write-admission-dropped|s@^ kind: RouteKind::Override,$@ kind: RouteKind::Document,@|every_class_routed_only_into_a_protected_path_is_admissible /// An `override`-kind route, which is the only kind whose precondition is /// REQUIRED rather than optional. /// @@ -1689,6 +1723,15 @@ re-running it cannot change its answer, and the work the receipt was taken about pushed", ); +/// CLOUD-1806's route: the recorded way through every plain `shape` row. +//MUTANT shape-class-inadmissible|s@^const SHAPE_ADMIT_ROUTE: VendoredRoute = admit($@const SHAPE_ADMIT_ROUTE: VendoredRoute = run(@|a_shape_deny_is_admissible_through_its_class_override +//MUTANT shape-route-circular|s@^ run("rule read first", crate::refusal::RULE_HOP_PLACEHOLDER),$@ read("config read first", "batten.toml"),@|a_shape_first_sighting_names_the_rows_remedy_verb +const SHAPE_ADMIT_ROUTE: VendoredRoute = admit( + "articulate the call", + "the remedy `batten policy rule` prints for this row cannot perform the change this call \ +makes, and you can name what the call changes and where a reviewer will see its effect", +); + /// Every class the BINARY ships: the native ones and the vendored presets'. /// /// # Why the presets' vocabulary ships with the presets @@ -2108,8 +2151,13 @@ non-negotiable rule 4 decided at the composer rather than at the report.", gloss: "the mediated call matches a command shape the config refuses", class: "A `shape` row declares a command spelling that is refused outright. The \ refusal names the row rather than echoing the command, because the command is the caller's \ -own text and could carry anything. What to run instead is the row's declared remedy.", - routes: &[read("config read first", "batten.toml")], +own text and could carry anything. What to run instead is the row's declared remedy, which \ +`batten policy rule` prints; where that remedy cannot perform the change, the class is \ +admissible through a recorded admission bound to the row id at the current commit.", + routes: &[ + run("rule read first", crate::refusal::RULE_HOP_PLACEHOLDER), + SHAPE_ADMIT_ROUTE, + ], applicability: Applicability::Advice, }, // ─── the repaired arms (CLOUD-1639) ────────────────────────────────────── @@ -2613,6 +2661,65 @@ pub fn declared_from(entry: &VendoredVerdict) -> DeclaredVerdict { } } +//MUTANT protected-route-unread|s@protects(&route[.]target)@protects("")@|every_class_routed_only_into_a_protected_path_is_admissible +//MUTANT own-override-ignored|s@ !declares_override(entry))$@ !entry.id.is_empty())@|every_class_routed_only_into_a_protected_path_is_admissible +fn declares_override(entry: &DeclaredVerdict) -> bool { + entry + .routes + .iter() + .any(|route| route.kind == RouteKind::Override) +} + +/// Every live class whose only ways out end in a path `protects` covers, and +/// that has no admission of its own — empty whenever `path write refused`, the +/// class blocking those paths, declares one (CLOUD-1893). +/// +/// A refusal whose only way out is an action another refusal blocks is a +/// deadlock with every gate green; CLOUD-1051/1357 shipped it once. This is the +/// graph edge that names it, computed from the registry and a path set. +/// +/// - A `document` route into a protected path is read as asking for a CHANGE, +/// because the kind cannot tell a read from an edit. That reading is safe: +/// this refuses nothing, and its only discharge is an admission that exists. +/// - `command` and `issue` routes count as unblocked — whether running one +/// clears the refusal is a model verdict (rule 3), which +/// `policy/verdict-routes-resolve.rego` disclaims too. +/// - There is no load-time arm. The blocker is vendored and a consumer cannot +/// redeclare it, so the predicate over `consumer ∪ vendored()` is empty +/// exactly when it is empty over `vendored()`; a consumer load refused over +/// the binary's own table would be the wrongly-refusing gate. +/// - One override per class is rejected: it would be one admission in many +/// costumes, the shape CLOUD-680 measured. +#[must_use] +pub fn routed_only_into_protection( + registry: &[DeclaredVerdict], + protects: impl Fn(&str) -> bool, +) -> Vec { + let gate = Native::ProtectedMutation.id(); + if registry + .iter() + .any(|entry| entry.id == gate && declares_override(entry)) + { + return Vec::new(); + } + #[rustfmt::skip] + let blocked = |route: &Route| route.kind == RouteKind::Document && protects(&route.target); + registry + .iter() + .filter(|entry| entry.successor.is_none() && entry.withdrawn.is_none()) + .filter(|entry| !declares_override(entry)) + .filter(|entry| { + let ways: Vec<&Route> = entry + .routes + .iter() + .filter(|route| route.kind != RouteKind::Override) + .collect(); + !ways.is_empty() && ways.iter().all(|route| blocked(route)) + }) + .map(|entry| entry.id.clone()) + .collect() +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/src/waiver.rs b/crates/batten/src/waiver.rs index ff6d8e889..5bb3edb35 100644 --- a/crates/batten/src/waiver.rs +++ b/crates/batten/src/waiver.rs @@ -415,15 +415,13 @@ impl Applied { /// who greps `check` output can grep this. #[must_use] pub fn line_text(&self) -> String { + let rule = crate::refusal::label(crate::refusal::Label::Rule, &self.rule); match self.line { Some(line) => format!( - "waived {}:{} {} (expires {})", - self.path, line, self.rule, self.expires - ), - None => format!( - "waived {} {} (expires {})", - self.path, self.rule, self.expires + "waived {}:{} {rule} (expires {})", + self.path, line, self.expires ), + None => format!("waived {} {rule} (expires {})", self.path, self.expires), } } } @@ -511,7 +509,8 @@ impl Suppressed { /// too — which is the point of keeping one verdict word across both channels. #[must_use] pub fn line_text(&self) -> String { - format!("waived {} (expires {})", self.rule, self.expires) + let rule = crate::refusal::label(crate::refusal::Label::Rule, &self.rule); + format!("waived {rule} (expires {})", self.expires) } } @@ -716,7 +715,7 @@ mod tests { assert_eq!(applied.len(), 1); assert_eq!( applied[0].line_text(), - "waived src/a.rs:3 r (expires 2099-01-01)" + "waived src/a.rs:3 rule 'r' (expires 2099-01-01)" ); assert!( !applied[0].line_text().contains("deny"), @@ -811,7 +810,7 @@ mod tests { let (_, applied) = apply(vec![scoped], &[waiver("r", "2099-01-01")], TODAY); assert_eq!( applied[0].line_text(), - "waived **/*.rs r (expires 2099-01-01)" + "waived **/*.rs rule 'r' (expires 2099-01-01)" ); } @@ -926,7 +925,7 @@ mod tests { expires: "2099-01-01".to_owned(), } .line_text(); - assert_eq!(line, "waived no-merge (expires 2099-01-01)"); + assert_eq!(line, "waived rule 'no-merge' (expires 2099-01-01)"); // One verdict word across both channels, so a reader who greps `check` // output for a suppression finds a mediated one too. assert!(line.starts_with("waived ")); diff --git a/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in b/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in index 924a31d11..4be6d1002 100644 --- a/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in +++ b/crates/batten/tests/fixtures/repos/attribution-appeal/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -notes.md:1 no-appeal-to-authority +notes.md:1 rule 'no-appeal-to-authority' diff --git a/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in b/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in index 307304813..b9128ccf5 100644 --- a/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in +++ b/crates/batten/tests/fixtures/repos/document-no-frontmatter/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -notes.md:1 skill-tier no-document +notes.md:1 rule 'skill-tier' no-document diff --git a/crates/batten/tests/fixtures/repos/document-node-differs/expected.in b/crates/batten/tests/fixtures/repos/document-node-differs/expected.in index bf127357d..2bd001e96 100644 --- a/crates/batten/tests/fixtures/repos/document-node-differs/expected.in +++ b/crates/batten/tests/fixtures/repos/document-node-differs/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -pins.toml:1 pin-agreement node-differs +pins.toml:1 rule 'pin-agreement' node-differs diff --git a/crates/batten/tests/fixtures/repos/forbid-deny/expected.in b/crates/batten/tests/fixtures/repos/forbid-deny/expected.in index 74e8a70ca..45b1a7e5e 100644 --- a/crates/batten/tests/fixtures/repos/forbid-deny/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-deny/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -lib.rs:4 no-todo +lib.rs:4 rule 'no-todo' diff --git a/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in b/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in index 7eddc28c8..ee9e29a06 100644 --- a/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-quote-load-bearing/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -pins.toml:6 no-built-backend +pins.toml:6 rule 'no-built-backend' diff --git a/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in b/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in index e463d3926..ae8fd1a76 100644 --- a/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-regex-cluster/expected.in @@ -1,5 +1,5 @@ argv: check exit: 2 stdout: -run.sh:1 no-grep-q -run.sh:2 no-grep-q +run.sh:1 rule 'no-grep-q' +run.sh:2 rule 'no-grep-q' diff --git a/crates/batten/tests/fixtures/repos/forbid-warn/expected.in b/crates/batten/tests/fixtures/repos/forbid-warn/expected.in index 5ac05436e..162d5649d 100644 --- a/crates/batten/tests/fixtures/repos/forbid-warn/expected.in +++ b/crates/batten/tests/fixtures/repos/forbid-warn/expected.in @@ -1,4 +1,4 @@ argv: check exit: 0 stdout: -lib.rs:2 no-todo +lib.rs:2 rule 'no-todo' diff --git a/crates/batten/tests/fixtures/repos/program-run-loose/batten.toml.in b/crates/batten/tests/fixtures/repos/program-run-loose/batten.toml.in new file mode 100644 index 000000000..13b2c372d --- /dev/null +++ b/crates/batten/tests/fixtures/repos/program-run-loose/batten.toml.in @@ -0,0 +1,11 @@ +version = 1 + +# `program run loose` as `batten.toml` declares it (CLOUD-2123), copied so this +# fixture proves the committed regex rather than a paraphrase of it. +[[rule]] +id = "program run loose" +kind = "forbid" +glob = "mise.toml" +regex = '"[^"`\n]*\./install\.sh' +severity = "deny" +scope = "tree" diff --git a/crates/batten/tests/fixtures/repos/program-run-loose/expected.in b/crates/batten/tests/fixtures/repos/program-run-loose/expected.in new file mode 100644 index 000000000..0eceda3c2 --- /dev/null +++ b/crates/batten/tests/fixtures/repos/program-run-loose/expected.in @@ -0,0 +1,5 @@ +argv: check +exit: 2 +stdout: +mise.toml:3 rule 'program run loose' +mise.toml:6 rule 'program run loose' diff --git a/crates/batten/tests/fixtures/repos/program-run-loose/mise.toml.in b/crates/batten/tests/fixtures/repos/program-run-loose/mise.toml.in new file mode 100644 index 000000000..059346c32 --- /dev/null +++ b/crates/batten/tests/fixtures/repos/program-run-loose/mise.toml.in @@ -0,0 +1,9 @@ +# Prose naming `./install.sh` in backticks is not a call. +[tasks."session:batten"] +run = ["./install.sh", "batten engine update"] + +[tasks."deps-install"] +run = "./install.sh" + +[tasks."ok"] +run = ["batten engine update", "batten wiring reclaim -y"] diff --git a/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in b/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in index 6d57dec3a..cbb11de26 100644 --- a/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in +++ b/crates/batten/tests/fixtures/repos/reference-unresolved/expected.in @@ -1,4 +1,4 @@ argv: check exit: 2 stdout: -n.md:1 dangling unresolved +n.md:1 rule 'dangling' unresolved diff --git a/crates/batten/tests/it/adjudicate_absent.rs b/crates/batten/tests/it/adjudicate_absent.rs index d8087b58a..8e929c5c5 100644 --- a/crates/batten/tests/it/adjudicate_absent.rs +++ b/crates/batten/tests/it/adjudicate_absent.rs @@ -338,6 +338,53 @@ fn a_command_is_still_refused_over_a_config_that_will_not_load() { ); } +/// A config pinning a release no build of this test carries, so the load refuses +/// on the skew alone: the config is NEWER than the binary, and no edit repairs it. +const PINS_ANOTHER_RELEASE: &str = "version = 1\nengine = { release = \"v9.9.9\" }\n"; + +/// A `Bash` envelope carrying `command`. +fn command_envelope(command: &str) -> String { + envelope( + "Bash", + &format!( + "{{\"command\":{}}}", + serde_json::to_string(command).expect("a command serializes") + ), + ) +} + +#[test] +fn the_engine_repair_verb_reaches_a_skewed_pin() { + // THE MEASURED BRICK (CLOUD-2116). `main` moved the pin past the installed + // binary; the refusal named an installer the floor refused, and so did every + // other command, `git push` included. The engine's own verb is the repair a + // read-and-edit floor cannot perform, so it is the one command admitted. + let dir = fixture("adjudicate-floor-engine-repair", PINS_ANOTHER_RELEASE); + assert_eq!( + code_for(&dir, &command_envelope("batten engine update")), + Some(0), + "the repair for a newer pin must reach the shell" + ); +} + +#[test] +fn a_command_carrying_the_repair_verb_is_still_refused() { + // THE MIRROR. Admitting by containment would let any command ride the + // repair's spelling past a build that judges nothing. + let dir = fixture("adjudicate-floor-engine-carried", PINS_ANOTHER_RELEASE); + for command in [ + "batten engine update && rm -rf notes.md", + "echo x; batten engine update", + "git push", + ] { + assert_eq!( + code_for(&dir, &command_envelope(command)), + Some(2), + "only the verb spelled whole is the repair: {command}" + ); + } +} + #[test] fn a_mutating_mcp_call_is_still_refused_over_a_config_that_will_not_load() { // THE DEFECT THE FIRST DRAFT SHIPPED, pinned so it cannot return. diff --git a/crates/batten/tests/it/admission.rs b/crates/batten/tests/it/admission.rs index 0ea6699ab..6bd53398d 100644 --- a/crates/batten/tests/it/admission.rs +++ b/crates/batten/tests/it/admission.rs @@ -167,6 +167,9 @@ fn a_subject_copied_from_the_refusal_line_binds_as_the_refusal_does() { admission::subject_as_bound("policy/agent-spawn.rego"), "policy/agent-spawn.rego" ); + // The tree surface matches `finding.path` verbatim, so a path with a space + // must not be rejoined into a name no finding carries (CLOUD-1826). + assert_eq!(admission::subject_as_bound("docs/a b.md"), "docs/a b.md"); let root = fixture("copied-subject"); let issued = admission::issue( &root, @@ -869,12 +872,19 @@ severity = "deny" # A SECOND ROW UNDER A SECOND SCOPE, and the subject deliberately names a file # the tree rule also refuses. That collision is what # `a_mint_for_a_mediated_rule_anchors_the_call_not_a_tree_finding` measures. +# +# KEYED, so it is not a PLAIN shape row: a plain one only ever raises `call name +# refused`, and `override request` refuses a subject it could never bind under +# any other class (CLOUD-1806). A keyed row's refusal is not listable from the +# row, so this mint is still issued — which is all these cases need of it. [[rule]] id = "mediated-refuses" kind = "shape" scope = "mediated_call" severity = "deny" pattern = "never-matches-anything" +requires_key = 'NEVER-[0-9]+' +base = "origin/main" reason = "the fixture's mediated row; it exists to be minted against, never to fire" [[verdict]] @@ -1412,6 +1422,114 @@ fn a_mint_addressing_no_finding_of_a_rule_that_fired_refuses_rather_than_binding ); } +/// Two findings over ONE path, told apart only by a second subject — the shape +/// `filed-here` raises when two rows name the same file (CLOUD-2126). +const TWINS: &str = r#" +package batten.admits + +import rego.v1 + +rules contains "always-refuses" + +violation contains { + "rule": "always-refuses", + "verdict": "always probe probe", + "subjects": [{"path": "a.rs"}, {"artifact": row}], +} if { + some row in ["ROW-1", "ROW-2"] +} +"#; + +/// Request an admission for `subject`; return the run. +fn request_for(root: &Path, subject: &str) -> std::process::Output { + common::run_with_stdin( + root, + &[ + "override", + "request", + "--rule", + "always-refuses", + "--verdict", + "always probe probe", + "--subject", + subject, + ], + "precondition=the refusal is the fixture's point\nlost=one twin of two\n\ + rejected-route=admits fix probe has nothing to change\n", + ) +} + +#[test] +fn twin_findings_on_one_path_are_each_admitted_by_fingerprint() { + // CLOUD-2126. Matching the path alone refused both twins as ambiguous and + // left no route at all; the refusal now names each fingerprint, a + // fingerprint selects one finding, and the PATH binds — `apply_admissions` + // looks an admission up by the finding's path, so binding the fingerprint + // would be spent and queried by nothing. + // + // Fails by: bind the fingerprint as the subject and the spend below refuses + // `mismatch`; drop the fingerprint arm and the second request refuses. + let root = admits_fixture_of("twins", TWINS, &["a.rs"]); + let refused = request_for(&root, "a.rs"); + assert_eq!( + refused.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "the path alone addresses two findings: {}", + common::stderr(&refused) + ); + let said = common::stderr(&refused); + let fingerprints: Vec<&str> = said + .rsplit_once(": ") + .map(|(_, listed)| listed.trim().split(", ").collect()) + .unwrap_or_default(); + assert_eq!(fingerprints.len(), 2, "the refusal names both: {said}"); + + for (spent, fingerprint) in fingerprints.iter().enumerate() { + let issued = request_for(&root, fingerprint); + let address = String::from_utf8_lossy(&issued.stdout).trim().to_owned(); + assert_eq!( + address.len(), + 64, + "a fingerprint addresses one: {}", + common::stderr(&issued) + ); + let spend = common::run( + &root, + &[ + "override", + "spend", + "--admission", + &address, + "--rule", + "always-refuses", + "--verdict", + "always probe probe", + "--subject", + "a.rs", + ], + ); + assert_eq!( + spend.status.code(), + Some(batten::exit::ExitCode::Success.code()), + "the path is what the admission bound: {}", + common::stderr(&spend) + ); + let after = common::run(&root, &["check"]); + let expected = if spent == 0 { + batten::exit::ExitCode::Violation + } else { + batten::exit::ExitCode::Success + }; + assert_eq!( + after.status.code(), + Some(expected.code()), + "admission {} of 2 clears exactly its twin: {}", + spent + 1, + common::stderr(&after) + ); + } +} + #[test] fn a_mint_for_a_rule_that_produced_no_finding_still_falls_back_to_the_call() { // THE FAIL-OPEN HALF, and the conjunct the case above does not reach diff --git a/crates/batten/tests/it/advisory_drain.rs b/crates/batten/tests/it/advisory_drain.rs index d0ec5ff46..135ac2699 100644 --- a/crates/batten/tests/it/advisory_drain.rs +++ b/crates/batten/tests/it/advisory_drain.rs @@ -291,12 +291,15 @@ fn a_post_tool_event_drains_the_store_as_pointer_lines() { assert_eq!(first.status.code(), Some(0), "the drain never denies"); let lines = payload(&first); assert_eq!(lines.len(), 1, "one finding, one line: {lines:?}"); - let fields: Vec<&str> = lines[0].split(' ').collect(); - assert_eq!(fields.len(), 4, "fingerprint, rule, path:line, count"); + // CLOUD-2075's labelled grammar: `rule '' at `. + let rest = lines[0] + .strip_prefix("rule 'no-todo' at ") + .unwrap_or_else(|| panic!("the line labels its rule: {lines:?}")); + let fields: Vec<&str> = rest.split(' ').collect(); + assert_eq!(fields.len(), 3, "fingerprint, path:line, count"); assert_eq!(fields[0].len(), 64, "a fingerprint is 64 hex characters"); - assert_eq!(fields[1], "no-todo"); - assert_eq!(fields[2], "src/a.rs:2"); - assert_eq!(fields[3], "1"); + assert_eq!(fields[1], "src/a.rs:2"); + assert_eq!(fields[2], "1"); assert!( !lines[0].contains("TODO"), "a pointer, never the matched content" @@ -587,6 +590,31 @@ fn an_unchanged_finding_set_answers_with_the_marker_rather_than_the_listing() { ); } +/// A `SessionStart` opens a new cycle, and the context no longer holds the +/// payload `unchanged` would point at — so the next drain lists in full +/// (CLOUD-2075 §7 case 22). +#[test] +fn a_session_start_relists_the_drain_payload() { + let (repo, home) = drained_fixture("drain-session-start", "\n[drain]\ninterval_ms = 0\n"); + let first = payload(&hook(&repo, &home, &post_tool_batch("s1"))); + assert_eq!(first.len(), 1, "{first:?}"); + assert_eq!( + payload(&hook(&repo, &home, &post_tool_batch("s1"))), + vec!["unchanged".to_owned()] + ); + let started = hook( + &repo, + &home, + r#"{"hook_event_name":"SessionStart","session_id":"s1","source":"startup","cwd":"/w"}"#, + ); + assert_eq!(started.status.code(), Some(0)); + assert_eq!( + payload(&hook(&repo, &home, &post_tool_batch("s1"))), + first, + "the first drain of a new cycle lists in full" + ); +} + #[test] fn a_drain_with_nothing_to_say_stays_silent_rather_than_claiming_unchanged() { // The distinction the marker would lose if it were emitted unconditionally: @@ -788,21 +816,20 @@ fn spread_fixture(name: &str, drain_table: &str, spans: usize) -> (PathBuf, Path } #[test] -fn a_rule_over_the_cardinality_cap_emits_one_summary_line_and_the_cap_is_config() { - // CLOUD-82 (b) over the binary, and the half a renderer unit test cannot - // reach: the cap that decides is the one in `batten.toml`. Same fixture, - // same findings, two caps, two payloads — a hard-coded K could not produce - // both columns, and a key that parsed but did nothing would produce neither. +fn a_rule_over_the_cardinality_cap_reports_it_beside_every_entry_and_the_cap_is_config() { + // CLOUD-82 (b) over the binary, reversed by CLOUD-2075: the cap REPORTS and + // withholds nothing. The cap that decides the report is the one in + // `batten.toml` — two caps, two payloads. let (capped, home_c) = spread_fixture( "drain-cap-on", "\n[drain]\ninterval_ms = 0\ncardinality_cap = 2\n", 4, ); let lines = payload(&hook(&capped, &home_c, &post_tool_batch("s1"))); + assert_eq!(lines.len(), 5, "four entries and the cap report: {lines:?}"); assert_eq!( - lines, - vec!["rule no-todo: 2+ findings".to_owned()], - "one pointer-only summary line, never the four entries" + lines[0], "rule 'no-todo': 4 findings, over the cardinality cap of 2", + "the report leads its rule's entries" ); let (uncapped, home_u) = spread_fixture( @@ -814,26 +841,15 @@ fn a_rule_over_the_cardinality_cap_emits_one_summary_line_and_the_cap_is_config( assert_eq!( lines.len(), 4, - "under the cap every identity speaks: {lines:?}" - ); - - // The withheld identities are recorded under the reason that feeds - // rule-health telemetry — not as an ordinary drain suppression, which is - // what a transient bound would be. - let shown = state_cmd(&capped, &home_c, &["state", "list", "-J"]); - let document: serde_json::Value = - serde_json::from_slice(&shown.stdout).expect("state list -J is JSON"); - assert_eq!( - document[0]["presentation"]["not-shown"], "over-cardinality-cap", - "the cap is journalled as itself: {document}" + "under the cap every identity speaks and nothing is reported: {lines:?}" ); } #[test] -fn the_emitted_payload_stays_under_the_configured_token_budget() { - // CLOUD-82 (a) over the binary. The budget is asserted against the bytes the - // host actually receives, with the same estimator `[budget]` gates - // instruction files with — a second estimator here could agree with nothing. +fn an_emitted_payload_over_the_configured_token_budget_reports_it() { + // CLOUD-82 (a) over the binary, reversed by CLOUD-2075: the budget REPORTS, + // with the same estimator `[budget]` gates instruction files with, and + // withholds no pointer. const BUDGET: usize = 20; let (repo, home) = spread_fixture( "drain-budget", @@ -841,15 +857,11 @@ fn the_emitted_payload_stays_under_the_configured_token_budget() { 12, ); let lines = payload(&hook(&repo, &home, &post_tool_batch("s1"))); + assert_eq!(lines.len(), 13, "every pointer and the report: {lines:?}"); assert!( - batten::budget::estimate_tokens(&lines.join("\n")) <= BUDGET, - "over the configured budget: {lines:?}" - ); - assert!( - lines.last().is_some_and( - |line| line.starts_with("budget: ") && line.ends_with(" findings withheld") - ), - "and the payload says how much it did not say: {lines:?}" + lines.last().is_some_and(|line| line.starts_with("budget: ") + && line.ends_with(&format!(" tokens over the declared {BUDGET}"))), + "and the payload says how far over it is: {lines:?}" ); } @@ -883,8 +895,8 @@ fn a_re_raised_group_reports_the_delta_rather_than_the_instance_list() { "the count field carries the delta: {again:?}" ); let fields: Vec<&str> = again[0].split(' ').collect(); - assert_eq!(fields.len(), 4, "still a pointer, not an instance list"); - assert_eq!(fields[2], "src/a.rs:2", "and one in-scope pointer"); + assert_eq!(fields.len(), 6, "still a pointer, not an instance list"); + assert_eq!(fields[4], "src/a.rs:2", "and one in-scope pointer"); } #[test] @@ -1011,16 +1023,17 @@ fn occurrences(record: &serde_json::Value) -> Option { // Acceptance (a), all four clauses over one alternating fixture. #[test] -fn an_alternating_rule_tracks_state_truthfully_while_its_emissions_stop_at_the_cap() { +fn an_alternating_rule_tracks_state_truthfully_and_is_reported_flapping_not_withheld() { // A window that a handful of evaluations fills, a threshold the alternation - // clears, and a cap of one so the second emission is the suppressed one. + // clears, and a cap of one that is accepted and no longer read. let (repo, home) = flapping_fixture( "drain-flap", "\n[drain]\ninterval_ms = 0\nflap_window = 6\nflap_percent = 50\nemit_cap = 1\n", ); + // CLOUD-2075: a flapping identity is a pointer the ruling needs, so the + // signal policy REPORTS it (the per-rule count below) and withholds nothing. let mut emissions = 0; - let mut suppressed = false; for round in 0..6 { let raised = round % 2 == 0; evaluate(&repo, &home, raised); @@ -1041,20 +1054,13 @@ fn an_alternating_rule_tracks_state_truthfully_while_its_emissions_stop_at_the_c if lines.iter().any(|line| line.contains("no-todo")) { emissions += 1; } - if stored(&repo, &home)["presentation"]["not-shown"] == "flap-suppressed" { - suppressed = true; - } + assert_ne!( + stored(&repo, &home)["presentation"]["not-shown"], + "flap-suppressed", + "round {round}: a flapping identity is shown, never withheld" + ); } - - assert!( - suppressed, - "the identity is annotated as withheld by the signal policy, journalled \ - under its own reason so the false-positive rate excludes it" - ); - assert!( - emissions <= 2, - "emissions stop at the cap; got {emissions} over six evaluations" - ); + assert_eq!(emissions, 3, "every raised round shows its pointer"); // The rule-health counter, on the operator's channel: a rule id and a count, // never a finding's content. @@ -1076,149 +1082,6 @@ fn an_alternating_rule_tracks_state_truthfully_while_its_emissions_stop_at_the_c assert_eq!(occurrences(&stored(&repo, &home)), Some(0)); } -/// The drain shard's name in this store, discovered rather than hardcoded. -/// -/// A store that has taken one evaluation and one drain boundary holds exactly two -/// shards: the scan's, which is `journal::shard_id(worktree)`, and the drain's, -/// which is neither derived from nor predictable off the worktree path. Reading -/// it back is what keeps the cases below honest if that derivation ever changes; -/// a literal `c0cc…` would pass for the wrong reason the day it did. -fn drain_shard_of(repo: &Path, home: &Path) -> String { - let store = shards_dir(home); - let scan = batten::journal::shard_id(repo); - let mut names: Vec = std::fs::read_dir(&store) - .unwrap_or_else(|why| panic!("read {}: {why}", store.display())) - .flatten() - .filter_map(|entry| { - let path = entry.path(); - if path.extension().is_some_and(|ext| ext == "jsonl") { - path.file_stem() - .map(|stem| stem.to_string_lossy().into_owned()) - } else { - None - } - }) - .filter(|name| *name != scan) - .collect(); - names.sort(); - assert_eq!( - names.len(), - 1, - "one drain shard beside the scan's: {names:?}" - ); - names.into_iter().next().expect("the drain shard") -} - -/// The one bound store's shard directory under `home`. -fn shards_dir(home: &Path) -> PathBuf { - let bound = home.join("data/batten"); - let mut stores: Vec = std::fs::read_dir(&bound) - .unwrap_or_else(|why| panic!("read {}: {why}", bound.display())) - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .collect(); - stores.sort(); - assert_eq!(stores.len(), 1, "one bound store: {stores:?}"); - stores - .into_iter() - .next() - .expect("the store") - .join("journal/shards") -} - -/// Drive the alternating fixture under `repo_dir` and report whether the drain -/// ever annotated the identity as withheld by the emission policy. -/// -/// The six-round shape is acceptance (a)'s, deliberately: this is the SAME run -/// that case makes, asked under a chosen shard order rather than the ambient one. -fn suppression_fires_under(root: &Path, repo_dir: &str) -> bool { - let (repo, home) = flapping_fixture_under( - root, - repo_dir, - "\n[drain]\ninterval_ms = 0\nflap_window = 6\nflap_percent = 50\nemit_cap = 1\n", - ); - let mut suppressed = false; - for round in 0..6 { - evaluate(&repo, &home, round % 2 == 0); - let woken = hook(&repo, &home, &post_tool_batch("flap-order")); - assert_eq!(woken.status.code(), Some(0), "the drain never denies"); - if stored(&repo, &home)["presentation"]["not-shown"] == "flap-suppressed" { - suppressed = true; - } - } - suppressed -} - -// CLOUD-1252. THE ORDER THE LOG IS READ IN IS NOT THE ORDER THE SHARDS ARE NAMED -// IN, and until `journal::Entry` carried an append stamp it was exactly that. -// -// `emission::assess` scopes an identity's emission budget by POSITION in the -// merged log — an emission counts when its index is at or above the window's -// first evaluation — which is sound only if the log is chronological. -// `read_shards` concatenates whole shards in path order, so the log was grouped -// by WRITER and which writer led was decided by whether the checkout's path -// fingerprint sorted above the drain shard's name. On the losing draw every -// emission sat below the window, the count was always zero, and flap suppression -// never fired AT ALL — for that checkout, permanently, with the engine reporting -// a healthy drain the whole time. Measured at 1 store in 13, and drawn every time -// by the musl target directory, which is how it surfaced. -// -// BOTH DRAWS, CONSTRUCTED RATHER THAN AWAITED, and that is the whole case. One -// run proves only which side this machine sits on; acceptance (a) has been -// passing for months on the winning one. The directory name is searched until its -// shard id lands either side of the drain's, so each assertion names the order it -// is making. -// -// SHOWN ABLE TO FAIL (CLOUD-418): revert `read_shards` to `paths.sort()` alone -// and the `above` half reds while the `below` half stays green. A case that fires -// in one order only is a case that cannot tell the fix from the draw. -#[test] -fn flap_suppression_fires_whichever_shard_sorts_first() { - let root = scratch("drain-flap-order"); - // One boundary, purely to learn this store's drain shard. The probe's own - // verdict is not asserted — it is a read, and the cases below are the claim. - let (probe, probe_home) = flapping_fixture_under( - &root, - "probe", - "\n[drain]\ninterval_ms = 0\nflap_window = 6\nflap_percent = 50\nemit_cap = 1\n", - ); - evaluate(&probe, &probe_home, true); - hook(&probe, &probe_home, &post_tool_batch("flap-probe")); - let drain = drain_shard_of(&probe, &probe_home); - - // A directory name per side. The search is bounded and deterministic: the - // drain shard is a 64-char hex string, so roughly one name in two lands on - // each side and forty candidates is far past certainty. - let mut below = None; - let mut above = None; - for n in 0..40 { - let candidate = format!("repo{n}"); - let shard = batten::journal::shard_id(&root.join(&candidate)); - if shard < drain && below.is_none() { - below = Some(candidate); - } else if shard > drain && above.is_none() { - above = Some(candidate); - } - if below.is_some() && above.is_some() { - break; - } - } - let below = below.expect("a checkout whose scan shard sorts below the drain's"); - let above = above.expect("a checkout whose scan shard sorts above the drain's"); - - assert!( - suppression_fires_under(&root, &below), - "scan shard below the drain's ({below}): this is the draw that always \ - worked, so a failure here is the policy, not the order" - ); - assert!( - suppression_fires_under(&root, &above), - "scan shard ABOVE the drain's ({above}): the losing draw. Flap \ - suppression must not depend on a filename comparison — see CLOUD-1252" - ); -} - // Acceptance (b). The load-bearing case for the (identity × context) key: two // worktrees at two refs, each monotone, interleaved in one shared journal. #[test] diff --git a/crates/batten/tests/it/ask_disposition.rs b/crates/batten/tests/it/ask_disposition.rs index ef06a9fb7..7fa113e8b 100644 --- a/crates/batten/tests/it/ask_disposition.rs +++ b/crates/batten/tests/it/ask_disposition.rs @@ -92,11 +92,32 @@ fn asking(name: &str) -> PathBuf { fn payload(command: &str) -> String { let encoded = serde_json::to_string(command).expect("a command is encodable"); format!( - "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + "{{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"s1\",\"tool_name\":\"Bash\",\ \"tool_input\":{{\"command\":{encoded}}}}}" ) } +/// An ask carries the full arm on EVERY firing (CLOUD-2075 §7 case 13): its +/// reader is a person, who has read no earlier firing and runs no lookup. +#[test] +fn an_ask_carries_the_full_arm_on_every_firing() { + let dir = asking("ask-full-every-firing"); + for _ in 0..2 { + let (code, body, cause) = adjudicate(&dir, "claude-code", ASKED); + assert_eq!(code, Some(0), "{cause}"); + let json: serde_json::Value = + serde_json::from_str(body.trim()).expect("the host's verdict envelope is JSON"); + let rendered = json["hookSpecificOutput"]["permissionDecisionReason"] + .as_str() + .expect("an escalation carries what is being asked"); + assert!(rendered.contains(" —"), "the full arm: {rendered}"); + assert!( + rendered.contains(REASON), + "with the row's reason: {rendered}" + ); + } +} + /// Adjudicate `command` in `dir` as `harness` sees it. fn adjudicate(dir: &Path, harness: &str, command: &str) -> (Option, String, String) { let out = run_with_stdin( diff --git a/crates/batten/tests/it/board_receipts.rs b/crates/batten/tests/it/board_receipts.rs index 3f76d54f6..81138ff14 100644 --- a/crates/batten/tests/it/board_receipts.rs +++ b/crates/batten/tests/it/board_receipts.rs @@ -382,8 +382,11 @@ fn an_update_is_not_row_ones_business() { // lives behind `batten policy explain` with the rest of it, and the id on // the emitted line is the engine's own attribution and nothing else. The // negative assertion is what keeps that claim honest. - assert!( - !text.contains("issue list unread"), + // CLOUD-2075 puts the refusing row's reason on the full arm, and that prose + // names row 1 — so attribution is read off the RULE LABEL, never a substring. + assert_ne!( + crate::common::refusing_rule(&text).as_deref(), + Some("issue list unread"), "an update names an id, so the row that gates FILING must stay silent: {text}" ); assert!( @@ -714,9 +717,12 @@ fn the_same_instant_yields_the_same_verdict() { mint_read_receipt(&repo, "CLOUD-1", 5); let at = later(1000); let args = ["adjudicate", "--harness", "exit-code", "--instant", &at]; - let sighting = run_with_stdin(&repo, &args, &payload("mcp__Linear__save_issue", update)); - let first = run_with_stdin(&repo, &args, &payload("mcp__Linear__save_issue", update)); - let second = run_with_stdin(&repo, &args, &payload("mcp__Linear__save_issue", update)); + // In one session (CLOUD-2075): the lifecycle is per context. + let call = + payload("mcp__Linear__save_issue", update).replacen('{', "{\"session_id\":\"s1\",", 1); + let sighting = run_with_stdin(&repo, &args, &call); + let first = run_with_stdin(&repo, &args, &call); + let second = run_with_stdin(&repo, &args, &call); assert_eq!( first.status.code(), second.status.code(), diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 7f3f06531..1f898f90b 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -1176,7 +1176,7 @@ fn check_violation_exits_two_with_pointer_only_output() { assert_eq!(output.status.code(), Some(2), "a finding is a violation"); let stdout = String::from_utf8_lossy(&output.stdout); // Pointer only: the location and rule id, never the offending line text. - assert_eq!(stdout, "lib.rs:2 no-todo\n"); + assert_eq!(stdout, "lib.rs:2 rule 'no-todo'\n"); assert!( !stdout.contains("fix this"), "output must not leak the bytes" @@ -1278,7 +1278,8 @@ fn check_refuses_a_command_rule_rather_than_skipping_it() { // refusal carries the `batten:` prefix that belongs to 1 and 3, and no // bypass hatch, because a read-only run has nothing to bypass. assert!( - stderr.contains("Refused by dyn:") && stderr.contains("Fix: batten enforce."), + stderr.contains("verdict 'spawn run refused' rule 'dyn'") + && stderr.contains("run batten enforce"), "the refusal must adopt the one shape, got: {stderr}" ); assert!( @@ -1302,7 +1303,10 @@ fn enforce_runs_a_command_rule_and_maps_its_exit_code() { "a non-zero command exit is a violation" ); // Rule-scoped pointer: no invented line number, and never the command output. - assert_eq!(String::from_utf8_lossy(&output.stdout), "**/*.rs dyn\n"); + assert_eq!( + String::from_utf8_lossy(&output.stdout), + "**/*.rs rule 'dyn'\n" + ); } #[test] @@ -1715,7 +1719,7 @@ fn a_local_override_may_add_a_rule_but_not_redefine_one() { assert_eq!(output.status.code(), Some(2), "the added rule must fire"); assert_eq!( String::from_utf8_lossy(&output.stdout), - "lib.rs:1 no-fixme\n" + "lib.rs:1 rule 'no-fixme'\n" ); // Redefining a committed rule could weaken it, so it is refused outright. @@ -1975,6 +1979,8 @@ fn the_matrix_covers_every_supported_harness() { struct FixCase { /// A mediated command the row refuses. command: &'static str, + /// The row that refuses it, which the line must label (CLOUD-2075). + rule: &'static str, /// The remedy that row declares, which the refusal must carry. fix: &'static str, } @@ -1982,18 +1988,22 @@ struct FixCase { const FIX_CASES: &[FixCase] = &[ FixCase { command: "gh pr merge 42", + rule: "commit ship other", fix: "use `mise run land`", }, FixCase { command: "gh pr comment 7 --body /fast-forward", + rule: "review ship early", fix: "use `mise run land`", }, FixCase { command: "gh pr checks --watch", + rule: "check watch loose", fix: "use `mise run ci-wait`", }, FixCase { command: "gh run watch 123", + rule: "job watch loose", fix: "use `mise run ci-wait`", }, ]; @@ -2018,42 +2028,21 @@ fn every_hook_policy_table_deny_names_its_fix() { let output = run_hook_in(&dir, "exit-code", &claude_payload(case.command)); assert_eq!(output.status.code(), Some(2), "{}: deny", case.command); let stderr = String::from_utf8_lossy(&output.stderr); - // CLOUD-1286: the sanctioned command is ONE HOP away rather than - // inline, and this case is what proves the hop actually lands. The - // emitted line carries the rule id; `batten policy rule ` resolves - // that id to the row's own remedy. Asserting only the absence would pass - // over a refusal that points nowhere, which is worse than the repetition - // it replaced. - // - // THE ID IS THE LAST TOKEN OF THE HEAD, NOT OF THE LINE (CLOUD-1637). A - // first sighting is ` — ; `, - // so the last token of the whole line is now the final route's target - // and taking it grabbed `batten.toml`. Splitting on the em-dash reads the - // id on both arms: the repeat has no such clause and the head IS the - // line, which is the byte-prefix property doing useful work. - // - // `policy rule` rather than `policy explain`, which is the verb this hop - // was always named after: `explain` answers about the CLASS and resolves - // a rule id only as a fallback, and the two are different questions - // wherever a class has more than one raiser. - // AND THE ID IS THREE WORDS, NOT ONE (CLOUD-1638). Taking the last - // whitespace token grabbed `other` out of `commit ship other`. The head - // is ` ` where the id is present only when it - // DIFFERS from the class, so: the last three words are the id on a - // discriminating row, and on a collapsed row the class token — the first - // three words — is the id, because that is what collapsing means. - let head = stderr.split(" — ").next().unwrap_or(&stderr); - let words: Vec<&str> = head.split_whitespace().collect(); + // CLOUD-1286: the sanctioned command is ONE HOP away, and this case is + // what proves the hop lands. CLOUD-2075 LABELS the rule, so the id is + // read off `rule ''` rather than guessed from word positions. + let label = format!("rule '{}'", case.rule); assert!( - words.len() >= 3, - "a deny names the class that fired: {stderr}" + stderr.contains(&label), + "{}: the line labels its rule, got: {stderr}", + case.command ); - let tail = words[words.len() - 3..].join(" "); - let class = words[..3].join(" "); - let mut explained = batten_with(&dir, &["policy", "rule", &tail], &[]); - if explained.status.code() != Some(0) { - explained = batten_with(&dir, &["policy", "rule", &class], &[]); - } + let id = stderr + .split("rule '") + .nth(1) + .and_then(|rest| rest.split('\'').next()) + .expect("a labelled rule id"); + let explained = batten_with(&dir, &["policy", "rule", id], &[]); assert_eq!( explained.status.code(), Some(0), @@ -2276,13 +2265,15 @@ fn a_deny_names_the_path_classs_own_mutation_over_the_verbs() { stderr.contains("guarded/thing.md"), "the path class that matched is the pointer, got: {stderr}" ); + // CLOUD-2075: the full arm carries the WINNING remedy — the path class's — + // and never the verb's fallback beside it. assert!( - !stderr.contains("change it in a pull request"), - "the remedy is dereferenced rather than inlined, got: {stderr}" + stderr.contains("change it in a pull request"), + "the full arm carries the path class's remedy, got: {stderr}" ); assert!( !stderr.contains("restore it with git"), - "the verb's general remedy must not appear either, got: {stderr}" + "the verb's general remedy must not appear, got: {stderr}" ); let unclaimed = run_hook_in(&dir, "exit-code", &claude_payload("rm vendor/thing.md")); @@ -2524,21 +2515,21 @@ fn an_absent_session_degrades_to_per_invocation_without_panicking() { // adjudicated exactly as one that does — the deny is a function of the // command, and nothing here is keyed on a session yet. // - // TWO IDENTICAL FIXTURES, advancing in lockstep: a refusal renders long on - // its first sighting and short after, and the sighting store lives under the - // fixture's `$GIT_DIR` (`refusal::first_sighting`). One fixture would compare - // a first sighting with a repeat and report the store as a session effect. + // TWO FIXTURES, and a FRESH SESSION per harness (CLOUD-2075): a session's + // first firing is the full arm, and a session-less payload is the full arm + // on every firing, so the two documents match exactly when each named + // session is new to the store. let dir = repo_with_gh_policy("session-absent"); let twin = repo_with_gh_policy("session-absent-twin"); - let with = serde_json::json!({ - "hook_event_name": "PreToolUse", - "session_id": "abc123", - "tool_name": "Bash", - "tool_input": { "command": "gh pr merge 42" } - }) - .to_string(); let without = payload_at("PreToolUse", "gh pr merge 42"); for harness in harnesses() { + let with = serde_json::json!({ + "hook_event_name": "PreToolUse", + "session_id": format!("abc-{harness}"), + "tool_name": "Bash", + "tool_input": { "command": "gh pr merge 42" } + }) + .to_string(); let a = run_hook_in(&dir, harness, &with); let b = run_hook_in(&twin, harness, &without); assert_eq!( @@ -3584,9 +3575,8 @@ fn the_committed_shape_rules_fire_on_every_banned_shape() { // the head ends with the id. The strictness this case wants is preserved // exactly by reading the head: on a repeat the head IS the line, which is // the byte-prefix property the two arms are built to have. - let head = stderr.split(" — ").next().unwrap_or(&stderr); assert!( - head.trim().ends_with(&case.rule), + common::refusing_rule(&stderr).as_deref() == Some(case.rule), "{:?} must be refused by {}, got: {stderr}", case.call.describe(), case.rule @@ -6705,7 +6695,7 @@ fn the_committed_repo_config_gates_a_repository() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "crates/** source carry broken\n", + "crates/** rule 'source carry broken'\n", "a command condemns a batch, so the pointer is the glob and carries no line" ); } @@ -6922,17 +6912,17 @@ fn the_committed_repo_agnosticism_rules_fire_on_every_banned_shape() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "crates/demo/notes.txt:1 fact name other\n\ - crates/demo/notes.txt:2 path name other\n\ - crates/demo/notes.txt:3 source name other\n\ - crates/demo/notes.txt:4 issue name other\n\ - crates/demo/notes.txt:5 issue name other\n\ - crates/demo/src/lib.rs:1 fact name other\n\ - crates/demo/src/lib.rs:2 path name other\n\ - crates/demo/src/lib.rs:3 source name other\n\ - crates/demo/src/lib.rs:4 issue name other\n\ - crates/demo/src/lib.rs:5 issue name other\n\ - policy/demo.rego:1 pattern name other\n", + "crates/demo/notes.txt:1 rule 'fact name other'\n\ + crates/demo/notes.txt:2 rule 'path name other'\n\ + crates/demo/notes.txt:3 rule 'source name other'\n\ + crates/demo/notes.txt:4 rule 'issue name other'\n\ + crates/demo/notes.txt:5 rule 'issue name other'\n\ + crates/demo/src/lib.rs:1 rule 'fact name other'\n\ + crates/demo/src/lib.rs:2 rule 'path name other'\n\ + crates/demo/src/lib.rs:3 rule 'source name other'\n\ + crates/demo/src/lib.rs:4 rule 'issue name other'\n\ + crates/demo/src/lib.rs:5 rule 'issue name other'\n\ + policy/demo.rego:1 rule 'pattern name other'\n", "one sorted pointer per banned shape per file, and nothing else" ); @@ -7068,12 +7058,12 @@ fn the_committed_portability_rules_fire_on_every_banned_shape() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "mise-tasks/seed.sh:1 shell parse unsafe\n\ - mise-tasks/seed.sh:2 shell edit unsafe\n\ - mise-tasks/seed.sh:3 shell read unsafe\n\ - mise-tasks/seed.sh:4 shell list unsafe\n\ - mise-tasks/seed.sh:5 shell guard unsafe\n\ - tests/seed.bats:2 branch edit unsafe\n", + "mise-tasks/seed.sh:1 rule 'shell parse unsafe'\n\ + mise-tasks/seed.sh:2 rule 'shell edit unsafe'\n\ + mise-tasks/seed.sh:3 rule 'shell read unsafe'\n\ + mise-tasks/seed.sh:4 rule 'shell list unsafe'\n\ + mise-tasks/seed.sh:5 rule 'shell guard unsafe'\n\ + tests/seed.bats:2 rule 'branch edit unsafe'\n", "one sorted pointer per banned construct, and nothing else" ); @@ -7172,7 +7162,7 @@ fn the_committed_example_config_loads_over_the_binary() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "**/*.rs source carry broken\n", + "**/*.rs rule 'source carry broken'\n", "a command condemns a batch, so the pointer is the glob and carries no line" ); } @@ -7226,7 +7216,7 @@ fn the_shipped_starter_config_loads_over_the_binary() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "src/main.rs:1 source carry broken\n", + "src/main.rs:1 rule 'source carry broken'\n", "a forbid rule points at the line, not at the batch a command condemns" ); } @@ -7315,7 +7305,7 @@ fn warn_findings_report_without_failing_the_run() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "lib.rs:1 no-todo\n", + "lib.rs:1 rule 'no-todo'\n", "the warn finding must still be reported" ); } @@ -12418,7 +12408,7 @@ fn a_tracked_instruction_may_not_prescribe_the_denied_commit_identity() { ); assert_eq!( String::from_utf8_lossy(&output.stdout), - "HOWTO.md:2 remedy carry refused\n", + "HOWTO.md:2 rule 'remedy carry refused'\n", "one pointer, and the matched line is never echoed" ); @@ -13036,7 +13026,7 @@ fn a_met_precondition_lets_the_rule_run_normally() { "a met precondition is not a filter: {}", stderr(&output) ); - assert_eq!(stdout(&output), "lib.rs:1 needs-the-vendor-tree\n"); + assert_eq!(stdout(&output), "lib.rs:1 rule 'needs-the-vendor-tree'\n"); } /// A ratchet still fires on an empty match set, which the new skip must not @@ -13105,7 +13095,7 @@ fn a_deciding_kind_over_the_same_tree_does_block() { "the control must block, or the approximating case proves nothing: {}", stderr(&output) ); - assert_eq!(stdout(&output), "lib.rs:1 no-todo\n"); + assert_eq!(stdout(&output), "lib.rs:1 rule 'no-todo'\n"); } /// Every kind carries a classification, and the vocabulary is total. diff --git a/crates/batten/tests/it/common/mod.rs b/crates/batten/tests/it/common/mod.rs index c3147f2cc..4ede695ab 100644 --- a/crates/batten/tests/it/common/mod.rs +++ b/crates/batten/tests/it/common/mod.rs @@ -149,6 +149,36 @@ fn scan_declared_patterns() -> String { rows } +/// The pointers a refusal of `class` printed, between the class and the `rule` +/// that refused, exactly as a reader would copy them — trimmed, NOT rejoined +/// (CLOUD-1826). +/// +/// `said` is a run's stdout followed by its stderr. The one place a binary case +/// reads the refusal line's grammar, so a change to that grammar re-points this. +/// +/// # Panics +/// +/// When no line carries the class, naming what was said. +pub(crate) fn printed_pointers(said: &str, class: &str, rule: &str) -> String { + // CLOUD-2075's grammar: `verdict '' rule '' at ; …`. + let opener = format!("verdict '{class}' rule '{rule}' at "); + said.lines() + .find_map(|line| { + let rest = line.split(opener.as_str()).nth(1)?; + let pointers = rest.split("; ").next()?.split(" —").next()?; + Some(pointers.trim().to_owned()) + }) + .unwrap_or_else(|| panic!("no line refuses as `{class}`: {said}")) +} + +/// The rule that refused, read off the labelled finding line through the +/// engine's own reader (CLOUD-2075) — never guessed from word positions. +pub(crate) fn refusing_rule(said: &str) -> Option { + said.lines() + .find_map(batten::refusal::parse_finding) + .map(|parsed| parsed.rule) +} + pub(crate) fn at_root(name: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../..") diff --git a/crates/batten/tests/it/config_trust.rs b/crates/batten/tests/it/config_trust.rs index 9b7d26784..896fde072 100644 --- a/crates/batten/tests/it/config_trust.rs +++ b/crates/batten/tests/it/config_trust.rs @@ -84,7 +84,7 @@ fn a_working_tree_that_deletes_a_rule_is_still_judged_by_base_policy() { "base policy must still fire: a violation is exit 2" ); assert!( - stdout(&guarded).contains("lib.rs:2 no-todo"), + stdout(&guarded).contains("lib.rs:2 rule 'no-todo'"), "the base rule's finding must be reported, got: {}", stdout(&guarded) ); @@ -235,7 +235,7 @@ fn without_the_flag_stdout_is_exactly_the_findings() { ); let output = run(&repo, &["check"]); assert_eq!(output.status.code(), Some(2)); - assert_eq!(stdout(&output), "lib.rs:1 no-todo\n"); + assert_eq!(stdout(&output), "lib.rs:1 rule 'no-todo'\n"); } // --- a ref this binary cannot read is a usage error, never a verdict --------- @@ -515,7 +515,7 @@ fn a_deleted_working_config_still_gets_the_base_rule_verdict() { String::from_utf8_lossy(&output.stderr) ); assert!( - stdout(&output).contains("a.rs:1 no-todo"), + stdout(&output).contains("a.rs:1 rule 'no-todo'"), "got: {}", stdout(&output) ); diff --git a/crates/batten/tests/it/connector_verbs.rs b/crates/batten/tests/it/connector_verbs.rs index 567503889..b7f515055 100644 --- a/crates/batten/tests/it/connector_verbs.rs +++ b/crates/batten/tests/it/connector_verbs.rs @@ -145,9 +145,8 @@ fn every_spelling_of_a_decided_verb_is_refused() { // The head rather than the whole line since CLOUD-1637: a first // sighting appends `— ; `, so the line ends with a // route target. On a repeat the head IS the line. - let head = text.split(" — ").next().unwrap_or(&text); assert!( - head.trim().ends_with(rule), + crate::common::refusing_rule(&text).as_deref() == Some(rule), "{tool} must be refused by {rule}, got: {text}" ); } @@ -228,37 +227,11 @@ fn each_refusal_names_its_own_remedy() { assert_eq!(refusal.status.code(), Some(2), "{verb} is refused"); let text = stderr(&refusal); // CLOUD-1286: the remedy is one hop from the rule id on the line, and - // this case still asserts it PER ROW — which is what caught the test - // being wrong before, when it demanded `mise run land` from a verb whose - // remedy is to background the command. A generic assertion, or one that - // only checked the hop resolved, would pass over the same mismatch. - // THE ROUTE IS NOT PART OF THE POINTER (CLOUD-1386). A first sighting - // appends the class's route after an em dash, so "the last word of the - // line" stopped being the rule id — it became the last word of a - // sentence. The pointer half is what this reads, and taking it - // explicitly says so rather than relying on the route's absence. - // AND THE NAME IS THREE WORDS (CLOUD-1638), so the last WORD is one - // third of it. `explain` answers about the CLASS, which is the head's - // first three words on both arms — a discriminating row appends its id - // after the pointers and a collapsed row's id IS the class, so reading - // the front is right in both cases and reading the back is right in - // neither. - let pointer = text.split(" — ").next().unwrap_or(&text); - let words: Vec<&str> = pointer.split_whitespace().collect(); - assert!( - words.len() >= 3, - "{verb}: a deny names the class that fired: {text}" - ); - // THE ROW'S remedy, not the class's: what this case asserts is that the - // refusal reaches the row's own `reason`, and `explain` answers about - // the class. The id is the head's last three words on a discriminating - // row; on a collapsed row it IS the class, so the first three resolve. - let tail = words[words.len() - 3..].join(" "); - let class = words[..3].join(" "); - let mut explained = run(&repo, &["policy", "rule", &tail]); - if explained.status.code() != Some(0) { - explained = run(&repo, &["policy", "rule", &class]); - } + // this case asserts it PER ROW. CLOUD-2075 labels the id, so it is read + // off the line rather than guessed from word positions. + let id = crate::common::refusing_rule(&text) + .unwrap_or_else(|| panic!("{verb}: a deny labels its rule: {text}")); + let explained = run(&repo, &["policy", "rule", &id]); assert_eq!(explained.status.code(), Some(0), "{verb}: the row resolves"); let explained_text = String::from_utf8_lossy(&explained.stdout); assert!( diff --git a/crates/batten/tests/it/emission_census.rs b/crates/batten/tests/it/emission_census.rs index 2b5c3accd..5b0d6f651 100644 --- a/crates/batten/tests/it/emission_census.rs +++ b/crates/batten/tests/it/emission_census.rs @@ -171,3 +171,115 @@ fn the_scan_discriminates() { .is_empty() ); } + +// --- the finding label census (CLOUD-2075) ---------------------------------- + +/// The two labels a finding line carries, which only `refusal::label` spells. +const LABELS: &[&str] = &["verdict '", "rule '"]; + +/// The renderers CLOUD-2075 deleted; their return would be a second grammar. +const RETIRED: &[&str] = &[ + "fn deny_text(", + "fn ask_text(", + "fn first_sighting_line(", + "fn render_advice(", +]; + +/// Every hand-spelled label in `source`'s production half: a `verdict '` or +/// `rule '` inside a string literal, before the first `#[cfg(test)]`, on a line +/// that is not a comment. +fn hand_spelled_labels(source: &str) -> Vec<(usize, String)> { + let production = source.split("#[cfg(test)]").next().unwrap_or(source); + let mut found = Vec::new(); + for (index, line) in production.lines().enumerate() { + if line.trim_start().starts_with("//") { + continue; + } + for label in LABELS { + for (at, _) in line.match_indices(label) { + // Inside a literal when an odd number of quotes precede it. A + // `--verdict '…'` or `--rule '…'` is a route's command flag, not + // a label: the projection never spells one after `--`. + let flag = line[..at].ends_with("--"); + if !flag && line[..at].matches('"').count() % 2 == 1 { + found.push((index + 1, line.trim().to_owned())); + } + } + } + } + found +} + +/// Every `.rs` file under `dir`, recursively. +fn sources(dir: &std::path::Path, into: &mut Vec) { + for entry in fs::read_dir(dir).expect("the source tree is readable") { + let path = entry.expect("a directory entry").path(); + if path.is_dir() { + sources(&path, into); + } else if path.extension().is_some_and(|extension| extension == "rs") { + into.push(path); + } + } +} + +#[test] +fn no_finding_label_is_spelled_outside_the_projection() { + let mut files = Vec::new(); + sources(&at_root("crates/batten/src"), &mut files); + assert!(files.len() > 10, "the census reads the source tree"); + let mut spelled = Vec::new(); + for path in &files { + let source = fs::read_to_string(path).expect("a source file is readable"); + for retired in RETIRED { + assert!( + !source.contains(retired), + "{} brings back `{retired}`", + path.display() + ); + } + if path.ends_with("refusal.rs") { + continue; + } + for (line, text) in hand_spelled_labels(&source) { + spelled.push(format!("{}:{line} {text}", path.display())); + } + } + assert!( + spelled.is_empty(), + "spell a label through `refusal::label`, never by hand: {spelled:#?}" + ); +} + +#[test] +fn the_label_census_discriminates() { + let seeded = "fn a() {\n let rule = format!(\"rule '{}'\", id);\n}\n"; + assert_eq!(hand_spelled_labels(seeded).len(), 1, "a literal is found"); + assert!( + hand_spelled_labels("// rule 'x'\n").is_empty(), + "a comment line is not an emission" + ); + assert!( + hand_spelled_labels( + "const R: &str = \"batten override request --verdict 'x' --rule 'y'\";\n" + ) + .is_empty(), + "a route's command flag is not a label" + ); + let tail = "fn a() {}\n#[cfg(test)]\nmod tests { const X: &str = \"rule 'x'\"; }\n"; + assert!( + hand_spelled_labels(tail).is_empty(), + "a test tail is not production" + ); +} + +#[test] +fn every_hook_source_declares_its_finding_lifecycle() { + use batten::hook::{HookSource, Lifecycle}; + for source in HookSource::ALL { + let expected = match source { + HookSource::Harness => Lifecycle::Sighted, + HookSource::Cli | HookSource::Git | HookSource::Ci => Lifecycle::PrintedFull, + }; + assert_eq!(source.finding_lifecycle(), expected, "{}", source.as_str()); + } +} diff --git a/crates/batten/tests/it/engine_pin.rs b/crates/batten/tests/it/engine_pin.rs index 25bc7ed66..d33e5dbbd 100644 --- a/crates/batten/tests/it/engine_pin.rs +++ b/crates/batten/tests/it/engine_pin.rs @@ -82,9 +82,16 @@ fn a_release_pin_naming_another_build_is_refused_with_its_install() { !output.status.success(), "a stale engine must not load: {said}" ); + // THE ENGINE'S OWN VERB, never a consumer installer (CLOUD-2116): it is the one + // command the hook's floor admits over this same skew, so it is the only + // remedy a refused session can actually run. assert!( - said.contains("BATTEN_VERSION=v0.0.1"), - "the refusal names the install: {said}" + said.contains("v0.0.1") && said.contains("`batten engine update`"), + "the refusal names the pin and the install: {said}" + ); + assert!( + !said.contains("install.sh"), + "an installer the floor refuses is not a remedy: {said}" ); } @@ -122,7 +129,7 @@ fn a_source_pin_without_a_stamp_is_refused() { "an unstamped engine must not load: {said}" ); assert!( - said.contains("install:local"), + said.contains("`batten engine update`"), "the refusal names the build: {said}" ); } diff --git a/crates/batten/tests/it/engine_update.rs b/crates/batten/tests/it/engine_update.rs index c6605ab81..7c7749625 100644 --- a/crates/batten/tests/it/engine_update.rs +++ b/crates/batten/tests/it/engine_update.rs @@ -161,7 +161,7 @@ fn a_build_inside_the_checkout_is_never_replaced() { ); assert_eq!(std::fs::read(&binary).unwrap(), before, "nothing replaced"); assert!( - text(&output).contains("install:local"), + text(&output).contains("batten engine update"), "the stale build is refused and the update named: {}", text(&output) ); @@ -186,7 +186,7 @@ fn the_hook_path_never_updates() { text(&output) ); assert!( - text(&output).contains("install:local"), + text(&output).contains("batten engine update"), "the stale engine refuses and names the update: {}", text(&output) ); diff --git a/crates/batten/tests/it/fail_on_warning.rs b/crates/batten/tests/it/fail_on_warning.rs index 449d1e439..514dacdec 100644 --- a/crates/batten/tests/it/fail_on_warning.rs +++ b/crates/batten/tests/it/fail_on_warning.rs @@ -39,7 +39,7 @@ fn warn_only() -> String { } /// The pointer line the fixture below produces, byte for byte. -const WARN_POINTER: &str = "lib.rs:2 no-todo\n"; +const WARN_POINTER: &str = "lib.rs:2 rule 'no-todo'\n"; /// Create a fresh temp repo containing `config`, a `lib.rs` that trips the rule, /// and optionally a `batten.local.toml`. diff --git a/crates/batten/tests/it/fixture_repos.rs b/crates/batten/tests/it/fixture_repos.rs index 545223895..1a36a14f8 100644 --- a/crates/batten/tests/it/fixture_repos.rs +++ b/crates/batten/tests/it/fixture_repos.rs @@ -273,7 +273,7 @@ impl Case { /// The pointer a failure is reported under — `path:line rule-id`, the same /// shape a finding takes, and never the matched bytes (rule 4). fn pointer(&self) -> String { - format!("{}:{} {}", self.path, self.line, self.rule) + format!("{}:{} rule '{}'", self.path, self.line, self.rule) } } diff --git a/crates/batten/tests/it/forge_read_first.rs b/crates/batten/tests/it/forge_read_first.rs new file mode 100644 index 000000000..35480ba6a --- /dev/null +++ b/crates/batten/tests/it/forge_read_first.rs @@ -0,0 +1,189 @@ +//! The compiled-binary tier for `policy/forge-read-first.rego` (CLOUD-1470). +//! +//! The module's own `test_` rules pin the predicate against a hand-written +//! `programs` list; only this tier shows the ENGINE resolves the program the +//! predicate reads, and that the class's document route reaches the reader on an +//! ALLOWED call. The bench registers only this module, with the committed module +//! bytes, so nothing else in the repository's config can refuse the call first. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; + +use common::{Fixture, run, run_with_stdin, stderr, stdout}; + +const POINTER: &str = "read .serena/memories/github-access.md"; + +/// The repository root, whose committed `batten.toml` registers the module. +fn root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// A fixture copy of the committed row and class, over the committed module. +fn bench(name: &str) -> PathBuf { + bench_with(name, "") +} + +/// [`bench`] with `extra` appended to the fixture's config. +fn bench_with(name: &str, extra: &str) -> PathBuf { + let module = std::fs::read_to_string(root().join("policy/forge-read-first.rego")) + .expect("the module is readable"); + Fixture::new(name) + .config( + &("version = 1\n\n\ + [[rule]]\nid = \"forge read first\"\nkind = \"policy\"\n\ + scope = \"mediated_call\"\nmodule = \"policy/forge-read-first.rego\"\n\ + severity = \"warn\"\n\n\ + [[verdict]]\nid = \"forge read first\"\n\ + gloss = \"a code-host call; read the memory documenting this host's GitHub access first\"\n\ + class = \"A fixture copy of the committed class.\"\n\n\ + [[verdict.route]]\nid = \"memory read first\"\nkind = \"document\"\n\ + target = \".serena/memories/github-access.md\"\n" + .to_owned() + + extra), + ) + .file("policy/forge-read-first.rego", &module) + .git() + .base_commit() + .build() +} + +fn bash(command: &str) -> String { + bash_in("s1", command) +} + +/// A Bash call in `session` (CLOUD-2075: the lifecycle is per context). +fn bash_in(session: &str, command: &str) -> String { + serde_json::json!({ + "hook_event_name": "PreToolUse", + "session_id": session, + "tool_name": "Bash", + "tool_input": {"command": command}, + }) + .to_string() +} + +fn tool(name: &str) -> String { + serde_json::json!({ + "hook_event_name": "PreToolUse", + "session_id": "s1", + "tool_name": name, + "tool_input": {}, + }) + .to_string() +} + +/// Everything the door said on either stream, and its exit code. +fn adjudicate(dir: &Path, payload: &str) -> (Option, String) { + let answer = run_with_stdin(dir, &["adjudicate", "--harness", "claude-code"], payload); + ( + answer.status.code(), + format!("{}{}", stdout(&answer), stderr(&answer)), + ) +} + +#[test] +fn a_forge_call_is_handed_its_memory() { + let dir = bench("forge-read-first-selected"); + for payload in [ + bash("gh pr view 1"), + bash("cd /tmp && mise exec -- gh api repos/o/r"), + bash("git push origin HEAD"), + bash("mise run land"), + tool("mcp__github__get_me"), + tool("mcp__claude-code-remote__add_repo"), + ] { + let (code, said) = adjudicate(&dir, &payload); + assert_eq!( + code, + Some(0), + "an advisory refuses nothing: {payload} {said}" + ); + assert!(!said.contains("permissionDecision"), "{payload}: {said}"); + assert!( + said.contains(POINTER), + "{payload} carries no pointer: {said}" + ); + } +} + +#[test] +fn a_non_forge_call_hears_nothing() { + let dir = bench("forge-read-first-unselected"); + for command in [ + "ls -la", + "git status", + "git commit -m push", + "cd /tmp && echo gh", + ] { + let (_, said) = adjudicate(&dir, &bash(command)); + assert!(!said.contains("forge read first"), "{command}: {said}"); + } +} + +/// The COMMITTED table, which the bench cannot pin: the row, the class and its +/// route, and no doubled verb on any co-firing route. +#[test] +fn the_committed_policy_hands_a_gh_read_its_memory() { + let explained = run(&root(), &["policy", "explain", "forge read first"]); + let table = stdout(&explained); + assert!( + table.contains("memory read first") + && table.contains("document") + && table.contains(".serena/memories/github-access.md"), + "the class declares the memory route: {table}" + ); + let (code, said) = adjudicate(&root(), &bash("gh pr view 1")); + assert_eq!(code, Some(0), "a gh read is allowed here: {said}"); + assert!(said.contains(POINTER), "{said}"); + assert!( + !said.contains("run run "), + "no route repeats its verb: {said}" + ); +} + +/// A warn advisory is the full arm once per context, then the pointer +/// (CLOUD-2075 §7 case 10). +#[test] +fn a_warn_advisory_is_full_once_then_a_pointer() { + let dir = bench("forge-read-first-lifecycle"); + let (first_code, first) = adjudicate(&dir, &bash_in("s1", "gh pr view 1")); + let (second_code, second) = adjudicate(&dir, &bash_in("s1", "gh pr view 1")); + assert_eq!((first_code, second_code), (Some(0), Some(0))); + let labels = "verdict 'forge read first' rule 'forge read first'"; + assert!(first.contains(labels) && first.contains(" —"), "{first}"); + assert!( + first.contains("code-host call"), + "the gloss rides the full arm: {first}" + ); + assert!( + second.contains(labels) && second.contains(POINTER), + "{second}" + ); + assert!( + !second.contains(" —"), + "the second is the pointer: {second}" + ); +} + +/// A document route into a read-redirected path names its reader (CLOUD-2075 +/// §7 case 11), and only where the consumer declares one. +#[test] +fn a_document_route_into_a_read_redirected_path_names_its_reader() { + let redirected = bench_with( + "forge-read-first-reader", + "\n[[redirect]]\nglob = \".serena/memories/**\"\n\ + mutation = \"use the memory tools\"\nread = \"read_memory\"\n", + ); + let (_, said) = adjudicate(&redirected, &bash("gh pr view 1")); + assert!( + said.contains(&format!("{POINTER} via read_memory")), + "{said}" + ); + let plain = bench("forge-read-first-no-reader"); + let (_, said) = adjudicate(&plain, &bash("gh pr view 1")); + assert!(!said.contains(" via "), "{said}"); +} diff --git a/crates/batten/tests/it/harness_wiring.rs b/crates/batten/tests/it/harness_wiring.rs index fe9a79a7b..9d9395667 100644 --- a/crates/batten/tests/it/harness_wiring.rs +++ b/crates/batten/tests/it/harness_wiring.rs @@ -446,7 +446,7 @@ fn a_committed_sibling_beside_the_mediator_is_refused() { let output = check(&repo, Some(&outside)); assert!(!output.status.success(), "a sibling passed"); assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -495,7 +495,7 @@ fn a_stop_sibling_is_refused_too_so_the_scope_is_every_event() { let output = check(&repo, Some(&outside)); assert!(!output.status.success(), "a Stop sibling passed"); assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -588,7 +588,7 @@ fn the_launcher_hooks_are_refused_rather_than_tolerated() { // A COUNT AND NO PATH: a merged path is under somebody's home directory and // differs per machine, so rule 4 and §6 byte-stability both forbid it travelling. assert!( - findings(&output).contains("2 hook wire missing"), + findings(&output).contains("2 rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -642,7 +642,7 @@ fn the_committed_half_survives_an_absent_merged_surface() { "the committed half went silent with no merged surface — CLOUD-1307 has been reintroduced by recombining the two rows" ); assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "wrong finding: {}", findings(&output) ); @@ -694,7 +694,7 @@ fn a_committed_surface_that_will_not_parse_is_reported() { // was no name to assert on — a count rendered into the pointer field, which // cost a session that could not learn which file the host could not read. assert!( - findings(&output).contains(".claude/settings.json hook wire missing"), + findings(&output).contains(".claude/settings.json rule 'hook wire missing'"), "the finding does not name the unreadable surface: {}", findings(&output) ); diff --git a/crates/batten/tests/it/history_drop.rs b/crates/batten/tests/it/history_drop.rs index 69283c5ca..80f7a87d7 100644 --- a/crates/batten/tests/it/history_drop.rs +++ b/crates/batten/tests/it/history_drop.rs @@ -196,20 +196,6 @@ fn the_refusal_carries_a_count_and_shas_and_no_content() { /// The rule id, beside [`CLASS`], for the admission cases below. const RULE: &str = "history-drop"; -/// Every short sha the refusal names, in the order it named them. -/// -/// **Read off the rendered refusal rather than computed from the fixture**, and -/// that is the point: it is what an AGENT can do. The binding is the commits the -/// refusal listed, so a case that recomputed them with `git rev-parse` could pass -/// while the only spelling a reader has access to did not fit. -fn shas_in(cause: &str) -> Vec { - cause - .split_whitespace() - .filter(|token| token.len() >= 7 && token.chars().all(|char| char.is_ascii_hexdigit())) - .map(str::to_owned) - .collect() -} - /// Answer the class's three declared questions and return the issued address. fn request(dir: &Path, subject: &str) -> String { let answers = "precondition=the commits are a duplicate of what is already pushed, \ @@ -273,19 +259,23 @@ fn spend(dir: &Path, admission: &str, subject: &str) -> bool { /// `admit_mediated` returned early on a refusal naming a count and commits, and /// the class had no way through at all. Measured before the fix by two admissions /// spent against a reset that refused unchanged after each. +/// +/// **The subject is pasted off the rendered refusal**, which is what an AGENT can +/// do: `1 `, count included. The binding used to drop the count, so the +/// paste bound `1,` against `` and admitted nothing (CLOUD-1826). #[test] -fn a_spent_admission_admits_the_reset_it_was_taken_for() { +fn a_subject_copied_from_the_refusal_line_admits_the_reset() { let dir = fixture("history-drop-admits"); unpushed(&dir, "local.txt"); let (code, cause) = adjudicate(&dir, "git reset --hard HEAD~1"); assert_eq!(code, Some(2), "the premise\n{cause}"); - let subject = shas_in(&cause).join(","); + let subject = common::printed_pointers(&cause, CLASS, RULE); assert!( - !subject.is_empty(), - "the refusal must name the commits it is about, or an asker has no \ - subject to bind\n{cause}" + subject.starts_with("1 "), + "the refusal must name a count and the commit it is about, or an asker \ + has no subject to bind\n{cause}" ); let admission = request(&dir, &subject); @@ -311,7 +301,8 @@ fn an_admission_for_one_commit_does_not_admit_a_deeper_reset() { let (code, shallow) = adjudicate(&dir, "git reset --hard HEAD~1"); assert_eq!(code, Some(2), "the premise\n{shallow}"); - let one = shas_in(&shallow).join(","); + let one = common::printed_pointers(&shallow, CLASS, RULE); + assert!(one.starts_with("1 "), "the premise\n{shallow}"); let admission = request(&dir, &one); assert!(spend(&dir, &admission, &one), "spend must consume it"); diff --git a/crates/batten/tests/it/hook_sources.rs b/crates/batten/tests/it/hook_sources.rs new file mode 100644 index 000000000..763917f4b --- /dev/null +++ b/crates/batten/tests/it/hook_sources.rs @@ -0,0 +1,163 @@ +//! CLOUD-1728: the hook surface is four sources, each with a declared vocabulary. +//! +//! Every case reads through `HookSource::ALL` and `HookSource::.vocabulary()`, +//! never the tables behind them: a case reading `GIT_HOOKS` directly would +//! survive a mutation of the `vocabulary()` arm, which is the accessor every +//! consumer reaches. + +use std::collections::BTreeSet; + +use batten::hook::{Event, Harness, HookDisposition, HookSource}; +use batten::surface::SURFACE; + +fn is_kebab_id(reason: &str) -> bool { + !reason.is_empty() + && reason + .split('-') + .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_lowercase())) +} + +#[test] +fn every_hook_source_is_enumerated_with_a_nonempty_vocabulary() { + let tokens: BTreeSet<&str> = HookSource::ALL.iter().map(|s| s.as_str()).collect(); + assert_eq!( + tokens, + BTreeSet::from(["cli", "harness", "git", "ci"]), + "the four sources are the owner's split; a missing one reports clean today" + ); + assert_eq!(HookSource::ALL.len(), 4, "no source is listed twice"); + for source in HookSource::ALL { + let vocabulary = source.vocabulary(); + assert!( + !vocabulary.is_empty(), + "{} declares no vocabulary", + source.as_str() + ); + let names: BTreeSet<&str> = vocabulary.iter().map(|(n, _)| n.as_str()).collect(); + assert_eq!( + names.len(), + vocabulary.len(), + "{} names a hook twice", + source.as_str() + ); + } +} + +/// githooks(5) as git 2.43 defines it. A SNAPSHOT: a hook a later git adds is +/// not caught automatically — this set is what was reproduced against the +/// installed binary when the table was written, nothing more. +const GITHOOKS_5: [&str; 28] = [ + "applypatch-msg", + "commit-msg", + "fsmonitor-watchman", + "p4-changelist", + "p4-post-changelist", + "p4-pre-submit", + "p4-prepare-changelist", + "post-applypatch", + "post-checkout", + "post-commit", + "post-index-change", + "post-merge", + "post-receive", + "post-rewrite", + "post-update", + "pre-applypatch", + "pre-auto-gc", + "pre-commit", + "pre-merge-commit", + "pre-push", + "pre-rebase", + "pre-receive", + "prepare-commit-msg", + "proc-receive", + "push-to-checkout", + "reference-transaction", + "sendemail-validate", + "update", +]; + +#[test] +fn the_git_vocabulary_is_githooks5_with_one_disposition_each() { + let vocabulary = HookSource::Git.vocabulary(); + let names: Vec<&str> = vocabulary.iter().map(|(n, _)| n.as_str()).collect(); + let unique: BTreeSet<&str> = names.iter().copied().collect(); + assert_eq!(unique.len(), names.len(), "a git hook named twice"); + assert_eq!(unique, BTreeSet::from(GITHOOKS_5)); + for (name, disposition) in &vocabulary { + if let HookDisposition::Unsupported { reason } = disposition { + assert!( + is_kebab_id(reason), + "{name}: reason {reason:?} is not a kebab id" + ); + } + } +} + +#[test] +fn every_registered_disposition_names_a_declared_verb() { + let verbs: BTreeSet<&str> = SURFACE.iter().map(|row| row.path).collect(); + let mut registered = 0; + for source in HookSource::ALL { + for (name, disposition) in source.vocabulary() { + match disposition { + HookDisposition::Registered { check } => { + registered += 1; + assert!( + verbs.contains(check), + "{}:{name} is checked by {check:?}, which no surface row declares", + source.as_str() + ); + } + HookDisposition::Unsupported { reason } => { + assert!(is_kebab_id(reason), "{name}: {reason:?}"); + } + } + } + } + assert!( + registered > 0, + "a census with nothing registered checks nothing" + ); +} + +#[test] +fn the_harness_vocabulary_agrees_with_wiring_registrations() { + let vocabulary = HookSource::Harness.vocabulary(); + let moments = Event::ALL + .iter() + .filter(|event| **event != Event::Unrecognized) + .count(); + assert_eq!(vocabulary.len(), Harness::ALL.len() * moments); + for harness in Harness::ALL { + let prefix = format!("{}:", harness.as_str()); + let own: Vec<(&str, HookDisposition)> = vocabulary + .iter() + .filter_map(|(name, d)| name.strip_prefix(&prefix).map(|event| (event, *d))) + .collect(); + assert_eq!(own.len(), moments, "{prefix} is not one entry per moment"); + match harness.wiring() { + None => assert!( + own.iter().all(|(_, d)| *d + == HookDisposition::Unsupported { + reason: "no-wiring-surface" + }), + "{prefix} has no wiring surface, so nothing on it is registered" + ), + Some(wiring) => { + let want: BTreeSet<&str> = wiring + .registrations(*harness) + .into_iter() + .map(|(event, _)| event.as_str()) + .collect(); + let got: BTreeSet<&str> = own + .iter() + .filter(|(_, d)| matches!(d, HookDisposition::Registered { .. })) + .map(|(event, _)| *event) + .collect(); + assert!(!want.is_empty(), "{prefix} registers nothing"); + assert_eq!(got, want, "{prefix} disagrees with its wiring"); + } + } + } +} diff --git a/crates/batten/tests/it/init.rs b/crates/batten/tests/it/init.rs index 382ea35ad..41cd2714e 100644 --- a/crates/batten/tests/it/init.rs +++ b/crates/batten/tests/it/init.rs @@ -104,7 +104,8 @@ fn a_second_init_refuses_and_leaves_the_file_untouched() { // `Refusal` type makes the clause impossible to omit, and this proves the // projection reaches the channel rather than stopping at the constructor. assert!( - reason.contains(batten::init::CONFIG_EXISTS) && reason.contains("Fix:"), + reason.contains(batten::init::CONFIG_EXISTS) + && reason.contains("edit batten.toml in place"), "the refusal must name its rule and its fix: {reason}" ); assert_eq!( @@ -200,7 +201,7 @@ fn the_refusal_survives_silent() { let reason = stderr(&output); assert!(reason.contains("batten.toml")); assert!( - reason.contains("Fix:"), + reason.contains("edit batten.toml in place"), "the fix clause is not chatter: {reason}" ); } diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index 21f475611..0aa161950 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -193,6 +193,7 @@ mod fixture_repos; mod forced_push; mod forge_facts; mod forge_query; +mod forge_read_first; mod forge_window; mod frontmatter_gates; mod fuzz_corpus; @@ -222,6 +223,7 @@ mod hook_cost; mod hook_pin_check; mod hook_profile; mod hook_skip_local; +mod hook_sources; mod hook_worktree_root; mod identity_churn; mod identity_precedence; @@ -323,6 +325,7 @@ mod preset_segments; mod primitives; mod privileged_lane; mod process_group; +mod program_run_loose; mod prose_only; mod prospective_facts; mod provision; diff --git a/crates/batten/tests/it/mediated_admission.rs b/crates/batten/tests/it/mediated_admission.rs index 4ad15903a..66a8f9f46 100644 --- a/crates/batten/tests/it/mediated_admission.rs +++ b/crates/batten/tests/it/mediated_admission.rs @@ -20,6 +20,13 @@ //! cases are about. Without it a fixture whose `protected` glob silently matched //! nothing would pass the admission case for the wrong reason — the gate never //! fired, so nothing needed admitting. +//! +//! # The shape fixture +//! +//! `shape_fixture` holds two plain deny `shape` rows, the population +//! `call name refused` covers. Its cases pin that the class is admissible +//! (CLOUD-1806), that the rule id keeps one row's admission off another's call, +//! and that a request naming a subject the row cannot bind is refused. // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -78,7 +85,7 @@ fn fixture(name: &str) -> PathBuf { fn write_payload(path: &str) -> String { let escaped = serde_json::to_string(path).expect("a path is encodable"); format!( - "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Write\",\ + "{{\"hook_event_name\":\"PreToolUse\",\"session_id\":\"s1\",\"tool_name\":\"Write\",\ \"tool_input\":{{\"file_path\":{escaped},\"content\":\"x\"}}}}" ) } @@ -100,6 +107,11 @@ fn verdict(dir: &Path, path: &str) -> Option { /// and the request is NOT interactive: it reads `=` lines from stdin, /// which is what makes an override reachable from an autonomous session at all. fn request(dir: &Path, subject: &str, reason: &str) -> String { + request_as(dir, RULE, CLASS, subject, reason) +} + +/// [`request`] for any rule and class. +fn request_as(dir: &Path, rule: &str, class: &str, subject: &str, reason: &str) -> String { let answers = format!( "precondition=the owning surface is the file being refused, so it cannot express this\n\ lost={reason}\n\ @@ -111,9 +123,9 @@ fn request(dir: &Path, subject: &str, reason: &str) -> String { "override", "request", "--rule", - RULE, + rule, "--verdict", - CLASS, + class, "--subject", subject, ], @@ -133,6 +145,11 @@ fn request(dir: &Path, subject: &str, reason: &str) -> String { /// Spend an issued admission against the situation it was issued for. fn spend(dir: &Path, admission: &str, subject: &str) -> bool { + spend_as(dir, admission, RULE, CLASS, subject) +} + +/// [`spend`] for any rule and class. +fn spend_as(dir: &Path, admission: &str, rule: &str, class: &str, subject: &str) -> bool { run( dir, &[ @@ -141,9 +158,9 @@ fn spend(dir: &Path, admission: &str, subject: &str) -> bool { "--admission", admission, "--rule", - RULE, + rule, "--verdict", - CLASS, + class, "--subject", subject, ], @@ -152,6 +169,115 @@ fn spend(dir: &Path, admission: &str, subject: &str) -> bool { .success() } +/// Two plain deny `shape` rows, the population `call name refused` covers +/// (CLOUD-1806). A shape row alone makes the policy adjudicable, and the fixture +/// is committed because `admit_mediated` binds to HEAD. +const SHAPE_CONFIG: &str = "version = 1\n\n\ + [[rule]]\nid = \"no-merge\"\nkind = \"shape\"\nscope = \"mediated_call\"\n\ + pattern = \"gh pr merge\"\nreason = \"land by fast-forward\"\nseverity = \"deny\"\n\n\ + [[rule]]\nid = \"no-rebase\"\nkind = \"shape\"\nscope = \"mediated_call\"\n\ + pattern = \"git rebase\"\nreason = \"let land replay the branch\"\nseverity = \"deny\"\n"; + +const SHAPE_CLASS: &str = "call name refused"; + +fn shape_fixture(name: &str) -> PathBuf { + Fixture::new(name) + .config(SHAPE_CONFIG) + .git() + .base_commit() + .build() +} + +/// Adjudicate one shell command, returning its exit code and stdout. +fn shell(dir: &Path, command: &str) -> (Option, String) { + let escaped = serde_json::to_string(command).expect("a command is encodable"); + let output = run_with_stdin( + dir, + &["adjudicate", "--harness", "exit-code"], + &format!( + "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ + \"tool_input\":{{\"command\":{escaped}}}}}" + ), + ); + (output.status.code(), stdout(&output)) +} + +/// A plain shape deny is admissible through its class's override (CLOUD-1806). +#[test] +fn a_shape_deny_is_admissible_through_its_class_override() { + let dir = shape_fixture("mediated-admission-shape"); + assert_eq!(shell(&dir, "gh pr merge 5").0, Some(2), "the premise"); + let admission = request_as( + &dir, + "no-merge", + SHAPE_CLASS, + SHAPE_CLASS, + "the remedy cannot perform this one merge", + ); + assert!( + spend_as(&dir, &admission, "no-merge", SHAPE_CLASS, SHAPE_CLASS), + "spend must consume it" + ); + let (code, said) = shell(&dir, "gh pr merge 5"); + assert_eq!(code, Some(0), "a spent admission admits the call: {said}"); + assert!( + said.contains("batten: call name refused admitted by"), + "and names the record that admitted it: {said}" + ); +} + +/// The rule id is the only thing separating two rows that share the class +/// subject, so one row's admission must not admit another's call. +#[test] +fn a_shape_admission_does_not_admit_another_shape_row() { + let dir = shape_fixture("mediated-admission-shape-other"); + let admission = request_as( + &dir, + "no-merge", + SHAPE_CLASS, + SHAPE_CLASS, + "taken for the merge row only", + ); + assert!(spend_as( + &dir, + &admission, + "no-merge", + SHAPE_CLASS, + SHAPE_CLASS + )); + assert_eq!( + shell(&dir, "git rebase origin/main").0, + Some(2), + "an admission for one shape row must not reach another" + ); +} + +/// A shape row's subject is computable from the row, so a request naming one +/// no refusal binds is refused before it is issued (CLOUD-1806). +#[test] +fn a_shape_admission_for_an_unbindable_subject_is_refused() { + let dir = shape_fixture("mediated-admission-shape-unbindable"); + let requested = run( + &dir, + &[ + "override", + "request", + "--rule", + "no-merge", + "--verdict", + SHAPE_CLASS, + "--subject", + "no-merge", + ], + ); + let said = String::from_utf8_lossy(&requested.stderr); + assert_eq!(requested.status.code(), Some(1), "{said}"); + assert!(requested.stdout.is_empty(), "no address may be issued"); + for needle in ["1 subject(s)", "call,name,refused"] { + assert!(said.contains(needle), "{needle} missing from {said}"); + } +} + /// THE PREMISE. Every case below is about admitting this refusal, so a fixture /// where it never fires would pass them vacuously. #[test] @@ -181,6 +307,75 @@ fn a_spent_admission_admits_the_write_it_was_taken_for() { ); } +/// The spelling a reader has is the LINE, so pasting its pointers must admit +/// (CLOUD-1826). The line prints `batten.toml Write`; the binding used to be the +/// bare path, so the paste bound `batten.toml,Write` and admitted nothing. +#[test] +fn a_subject_copied_from_the_refusal_line_admits_the_write() { + let dir = fixture("mediated-admission-pasted"); + let refused = run_with_stdin( + &dir, + &["adjudicate", "--harness", "exit-code"], + &write_payload(GUARDED), + ); + assert_eq!(refused.status.code(), Some(2), "the premise"); + let said = format!( + "{}{}", + String::from_utf8_lossy(&refused.stdout), + String::from_utf8_lossy(&refused.stderr) + ); + let pasted = common::printed_pointers(&said, CLASS, RULE); + assert!( + pasted.contains(' '), + "the line must print more than one pointer, or this case cannot tell the \ + printed spelling from the bare path: {pasted:?}" + ); + + let admission = request(&dir, &pasted, "pasted straight off the refusal line"); + assert!(spend(&dir, &admission, &pasted), "spend must consume it"); + assert_eq!( + verdict(&dir, GUARDED), + Some(0), + "a subject copied off the refusal line must admit the write it refused" + ); +} + +/// The override route on the line IS the request that admits (CLOUD-2075 §7 +/// case 12): on both arms, ready to run, with the subject the refusal binds. +#[test] +fn the_override_route_on_the_line_is_the_request_that_admits() { + let dir = fixture("mediated-admission-route-on-line"); + let opener = format!( + "admit with batten override request --rule '{RULE}' --verdict '{CLASS}' --subject '" + ); + let mut subject = String::new(); + for _ in 0..2 { + let refused = run_with_stdin( + &dir, + &["adjudicate", "--harness", "exit-code"], + &write_payload(GUARDED), + ); + assert_eq!(refused.status.code(), Some(2), "the premise"); + let said = String::from_utf8_lossy(&refused.stderr).into_owned(); + let rest = said + .split(opener.as_str()) + .nth(1) + .unwrap_or_else(|| panic!("no override route on the line: {said}")); + subject = rest + .split('\'') + .next() + .expect("a quoted subject") + .to_owned(); + } + let admission = request(&dir, &subject, "read straight off the override route"); + assert!(spend(&dir, &admission, &subject), "spend must consume it"); + assert_eq!( + verdict(&dir, GUARDED), + Some(0), + "the route's request admits" + ); +} + /// An ISSUED admission does not admit — only a spent one does. /// /// `admission.rs` calls this "the whole economy": a mint that suppressed on its diff --git a/crates/batten/tests/it/mediated_verbs.rs b/crates/batten/tests/it/mediated_verbs.rs index cbb4060c7..0a21f8ff4 100644 --- a/crates/batten/tests/it/mediated_verbs.rs +++ b/crates/batten/tests/it/mediated_verbs.rs @@ -1086,7 +1086,7 @@ fn a_generic_read_of_a_memory_is_refused_and_names_the_tool_that_answers() { "not the shell-substitution class: {refusal}" ); assert!( - refusal.contains(&format!("{GUARDED} read_memory ")), + refusal.contains(&format!("at {GUARDED} read_memory;")), "the declared read route is the subject after the path: {refusal}" ); // The remedy is one hop away since CLOUD-1286, and it must reach the READ diff --git a/crates/batten/tests/it/one_pr.rs b/crates/batten/tests/it/one_pr.rs index 389f02414..1b9e96596 100644 --- a/crates/batten/tests/it/one_pr.rs +++ b/crates/batten/tests/it/one_pr.rs @@ -110,9 +110,8 @@ fn a_second_pr_while_the_first_is_unlanded_is_refused() { let refusal = open_a_pr(&dir); assert_eq!(refusal.status.code(), Some(2), "{}", stderr(&refusal)); let text = stderr(&refusal); - let head = text.split(" — ").next().unwrap_or(&text); assert!( - head.trim().ends_with("review open twice"), + crate::common::refusing_rule(&text).as_deref() == Some("review open twice"), "refused by the one-PR row: {text}" ); } diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 851b9f099..09d7beda8 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -2260,7 +2260,16 @@ const CENSUS: &[Verb] = &[ path: "adjudicate", args: &["--harness", "exit-code"], stdin: Stdin::MediatedCall, - disposition: Disposition::PointerOnly, + disposition: Disposition::Echoes( + "a finding's full arm carries the declaring row's own `reason`, the class gloss \ + and each override's precondition — the caller's declaration, once per context \ + per compaction cycle (CLOUD-2075)", + Echoed::Columns(&[ + "rule[].reason", + "verdict[].gloss", + "verdict[].route[].precondition", + ]), + ), }, // THE ONE VERB WHOSE ANSWER IS THE PAYLOAD, and `command` is deliberately the // field asked for: it is where `mediated_call` seeds its canary, so any other @@ -3065,16 +3074,16 @@ fn the_corpus_is_live_subject_matter() { let checked = run_in(&corpus, &["check"], Stdin::Nothing); let stdout = String::from_utf8_lossy(&checked.stdout).into_owned(); assert!( - stdout.contains("subject.txt:2 no-canary"), + stdout.contains("subject.txt:2 rule 'no-canary'"), "the forbid rule must fire on the seeded line, or `check` is judging nothing: {stdout}" ); assert!( - stdout.contains("budget.loaded"), + stdout.contains("rule 'budget.loaded'"), "the budget must overflow, or its per-file rendering is never reached: {stdout}" ); let stderr = String::from_utf8_lossy(&checked.stderr).into_owned(); assert!( - stderr.contains("waived subject.txt:2 no-canary-waived"), + stderr.contains("waived subject.txt:2 rule 'no-canary-waived'"), "the waiver must apply, or its audit line is never rendered: {stderr}" ); diff --git a/crates/batten/tests/it/policy_severity.rs b/crates/batten/tests/it/policy_severity.rs index 75307e090..c72c79d41 100644 --- a/crates/batten/tests/it/policy_severity.rs +++ b/crates/batten/tests/it/policy_severity.rs @@ -31,8 +31,8 @@ //! //! Which events a host can be advised on. That is the capability table's answer //! (`Harness::capabilities`), it is evidence-backed per surface, and this file -//! reads it rather than restating it: `PostToolBatch` is one Claude Code declares -//! and has been probed on, `PreToolUse` is not one at all. +//! reads it rather than restating it: Claude Code's `delivered_on` row carries +//! both `PostToolBatch` and `PreToolUse`, each by measurement (CLOUD-1131). #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -199,11 +199,11 @@ fn a_warn_row_allows_the_same_call() { /// The demotion is not a discard: where the host declares an advisory channel, /// the same violation arrives as context. /// -/// `PostToolBatch` is the event chosen because the capability table already -/// carries it as probed for Claude Code. `PreToolUse` is not an advisory surface -/// on this host at all, which is the finding CLOUD-1131 recorded rather than the -/// gap this test papers over: a `warn` module has a reader at the batch boundary -/// and none at the tool call. +/// `PostToolBatch` is the event chosen because the capability table carries it +/// as probed for Claude Code. `PreToolUse` is an advisory surface on this host +/// too — the capability row records the measurement — and +/// `a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call` +/// covers that event. #[test] fn a_warn_violation_reaches_the_advisory_channel_where_the_host_has_one() { let dir = repo("policy-severity-advisory", "warn"); @@ -356,6 +356,141 @@ fn equal_force_leaves_declaration_order_as_the_tie_break() { ); } +// --------------------------------------------------------------------------- +// EVERY ROUTE, EVERY ADVISORY, AND `allow` IS OFF (CLOUD-1470). +// --------------------------------------------------------------------------- + +/// The fixture class with ONLY a document route, the shape a memory pointer has. +fn document_config(severity: &str) -> String { + format!( + r#"version = 1 + +[[verdict]] +id = "fixture severity probe" +gloss = "the fixture predicate matched" +class = """ +A fixture class whose one way out is a document to read. +""" + +[[verdict.route]] +id = "fixture probe probe" +kind = "document" +target = "notes/fixture.md" + +[[rule]] +id = "fixture-severity" +kind = "policy" +scope = "mediated_call" +module = "policy/fixture-severity.rego" +severity = "{severity}" +"# + ) +} + +/// A class with no command route carried no pointer on the advisory channel, +/// because only the first command route rendered there. +#[test] +fn a_warn_advisory_carries_its_document_route_on_an_allowed_pre_tool_call() { + let dir = scratch_repo("policy-severity-document-route"); + fs::write(dir.join("batten.toml"), document_config("warn")).expect("write config"); + fs::create_dir_all(dir.join("policy")).expect("policy dir"); + fs::write(dir.join("policy/fixture-severity.rego"), MODULE).expect("write module"); + let output = hook(&dir, &command_payload("PreToolUse", CALL)); + let stdout = stdout_of(&output); + assert_eq!(output.status.code(), Some(0), "{stdout}"); + assert!(!stdout.contains("permissionDecision"), "{stdout}"); + assert!( + stdout.contains("read notes/fixture.md"), + "the class's document route reaches the reader: {stdout}" + ); +} + +/// Two non-blocking modules over one call each deliver their own line; keeping +/// only the first-declared shed the second silently. +#[test] +fn two_warn_advisories_on_one_call_both_arrive() { + let dir = pair_repo("policy-severity-two-advisories", "warn", "warn"); + let output = hook(&dir, &command_payload("PreToolUse", CALL)); + let stdout = stdout_of(&output); + for class in ["fixture severity probe", "fixture severity other"] { + assert!(stdout.contains(class), "{class} must arrive: {stdout}"); + } +} + +/// `allow` means the rule is off, so it says nothing on the call either. +#[test] +fn an_allow_row_advises_nothing() { + let dir = repo("policy-severity-allow", "allow"); + let output = hook(&dir, &command_payload("PreToolUse", CALL)); + let stdout = stdout_of(&output); + assert!(!stdout.contains("fixture severity probe"), "{stdout}"); + assert!(!stdout.contains("additionalContext"), "{stdout}"); +} + +/// The two fixture modules, firing at `Stop` rather than on a command. +const STOP_MODULE: &str = r#"package batten.fixture_severity + +import rego.v1 + +rules contains "fixture-severity" + +violation contains { + "rule": "fixture-severity", + "verdict": "fixture severity probe", + "subjects": [{"path": "fixture"}], +} if { + input.call.event == "stop" +} +"#; + +const STOP_OTHER_MODULE: &str = r#"package batten.fixture_severity_other + +import rego.v1 + +rules contains "fixture-severity-other" + +violation contains { + "rule": "fixture-severity-other", + "verdict": "fixture severity other", + "subjects": [{"path": "fixture"}], +} if { + input.call.event == "stop" +} +"#; + +fn stop_pair_repo(name: &str, first: &str, second: &str) -> PathBuf { + let dir = scratch_repo(name); + fs::write(dir.join("batten.toml"), pair_config(first, second)).expect("write config"); + fs::create_dir_all(dir.join("policy")).expect("policy dir"); + fs::write(dir.join("policy/fixture-severity.rego"), STOP_MODULE).expect("write module"); + fs::write( + dir.join("policy/fixture-severity-other.rego"), + STOP_OTHER_MODULE, + ) + .expect("write second module"); + dir +} + +const STOP: &str = r#"{"hook_event_name":"Stop","session_id":"s-1","stop_hook_active":false}"#; + +/// An `allow` row nudges nothing at the end of a turn either. +#[test] +fn an_allow_row_nudges_nothing_at_stop() { + let dir = stop_pair_repo("policy-severity-allow-stop", "allow", "allow"); + let stdout = stdout_of(&hook(&dir, STOP)); + assert!(!stdout.contains("fixture severity"), "{stdout}"); +} + +/// `Stop` keeps CLOUD-888's one-nudge bound however many modules warn: the new +/// collector speaks on calls, never at the end of a turn. +#[test] +fn a_stop_carries_one_module_nudge_whatever_the_warn_count() { + let dir = stop_pair_repo("policy-severity-warn-stop", "warn", "warn"); + let stdout = stdout_of(&hook(&dir, STOP)); + assert!(stdout.contains("fixture severity probe"), "{stdout}"); + assert!(!stdout.contains("fixture severity other"), "{stdout}"); +} + /// And a `deny` row is NOT demoted at the same event: it is the decision, and /// `adjudicated` allows at a post-tool event for its own reason, so nothing is /// emitted twice. diff --git a/crates/batten/tests/it/program_run_loose.rs b/crates/batten/tests/it/program_run_loose.rs new file mode 100644 index 000000000..8e6ef0b2d --- /dev/null +++ b/crates/batten/tests/it/program_run_loose.rs @@ -0,0 +1,67 @@ +//! `program run loose` over the compiled binary (CLOUD-2123): no `mise` task +//! calls the installer. +//! +//! THE COMMITTED ROW, NOT A COPY. The fixture under `tests/fixtures/repos` holds +//! the regex as text, so it would stay green over an edit to `batten.toml`; this +//! lifts the row out of the authority itself, so the `#MUTANT` row beside it has +//! something to redden. + +use crate::common; + +use common::{Fixture, run, stdout}; + +/// The `[[rule]]` block whose id is `program run loose`, as `batten.toml` spells it. +fn committed_row() -> String { + let text = std::fs::read_to_string(common::at_root("batten.toml")).expect("the authority"); + let id = text + .find("id = \"program run loose\"") + .expect("batten.toml declares the row"); + let start = text[..id].rfind("[[rule]]").expect("the row's header"); + let end = text[id..] + .find("\n\n") + .map_or(text.len(), |offset| id + offset); + text[start..end].to_owned() +} + +/// `check` the row over a fixture `mise.toml`; return the exit code and stdout. +fn check(name: &str, tasks: &str) -> (Option, String) { + let config = format!("version = 1\n\n{}\n", committed_row()); + let dir = Fixture::new(name) + .config(&config) + .files(&[("mise.toml", tasks)]) + .base_commit() + .build(); + let out = run(&dir, &["check"]); + (out.status.code(), stdout(&out)) +} + +#[test] +fn a_task_calling_the_installer_is_refused_and_prose_is_not() { + let (code, out) = check( + "program-run-loose-committed", + "# Prose naming `./install.sh` in backticks is not a call.\n\ + [tasks.a]\n\ + run = [\"./install.sh\", \"batten engine update\"]\n\ + [tasks.b]\n\ + run = \"./install.sh\"\n\ + [tasks.c]\n\ + run = [\"batten engine update\", \"batten wiring reclaim -y\"]\n", + ); + assert_eq!(code, Some(2), "{out}"); + assert!( + out.contains("mise.toml:3 rule 'program run loose'"), + "an argv entry: {out}" + ); + assert!( + out.contains("mise.toml:5 rule 'program run loose'"), + "a whole run string: {out}" + ); + assert!( + !out.contains("mise.toml:1 "), + "prose in backticks is not a call: {out}" + ); + assert!( + !out.contains("mise.toml:7 "), + "the binary installing itself is the route: {out}" + ); +} diff --git a/crates/batten/tests/it/punt_receipt.rs b/crates/batten/tests/it/punt_receipt.rs index f04103a82..b9f0ebf9f 100644 --- a/crates/batten/tests/it/punt_receipt.rs +++ b/crates/batten/tests/it/punt_receipt.rs @@ -227,14 +227,11 @@ fn the_refusal_names_the_row_and_its_remedy() { refusal.contains("verify"), "names the check it wants proved: {refusal}" ); - // THE PROSE IS NOT ON THIS CHANNEL, and asserting it were would have been - // this case arguing against the posture its own row follows. `reason` is - // reached through `batten policy rule`, which is where a remedy belongs - // (house-style §6, non-negotiable rule 4): the channel carries a pointer and - // the document carries the payload. + // THE ROW'S REMEDY RIDES THE FULL ARM (CLOUD-2075), once per context per + // compaction cycle; this payload names no session, so every firing is full. assert!( - !refusal.contains("mise run land"), - "the remedy stays in the config the refusal points at: {refusal}" + refusal.contains("mise run land"), + "the full arm carries the row's remedy: {refusal}" ); assert!( !refusal.contains("batten-receipts"), @@ -398,14 +395,10 @@ fn override_as(dir: &Path, class: &str, verb: &[&str], stdin: &str) -> std::proc String::from_utf8_lossy(&refused.stdout), stderr(&refused) ); - let subject = said - .lines() - .find_map(|line| { - let rest = line.split(&format!("{class} ")).nth(1)?; - let artifacts = rest.split(" turn mint ahead").next()?; - Some(artifacts.split_whitespace().collect::>().join(",")) - }) - .unwrap_or_else(|| panic!("the write is refused as `{class}`: {said}")); + let subject = crate::common::printed_pointers(&said, class, "turn mint ahead") + .split_whitespace() + .collect::>() + .join(","); let mut args = vec!["override"]; args.extend_from_slice(verb); args.extend_from_slice(&[ @@ -419,23 +412,55 @@ fn override_as(dir: &Path, class: &str, verb: &[&str], stdin: &str) -> std::proc run_with_stdin(dir, &args, stdin) } -// CLOUD-1889's declared mutation, and why the row is in THIS file. -// -// `test name undefined` reads the declared file for a line carrying -// `MUTANT |`, and its `line_sources` cover `crates/batten/tests/**` and not -// `crates/batten/src/**` — so the row lives here although the expression it applies -// belongs to `lib.rs`'s `admit_mediated`. It reinstates the early return on a -// path-less refusal, which is the defect exactly. -// -// INERT UNDER THE SWEEP, as `rebase.rs` records for its own rows (CLOUD-1486): -// `mutate::apply` seds the file that DECLARED the row, so this row rewrites this -// file and never reaches the engine. The kill was demonstrated BY HAND at -// implementation — the expression applied to `lib.rs`, the case below observed -// red, the file restored — and this paragraph is the only record of it. -/* -#MUTANT-SUITE crates/batten/tests/it/punt_receipt.rs -#MUTANT admission-not-honoured|s@ let subject = refusal.subject().unwrap_or(class);@ let Some(subject) = refusal.subject() else { return Ok(decision); };@|a_spent_admission_clears_a_superseded_receipt -*/ +/// A subject no refusal of the row binds is refused at REQUEST, before an +/// admission is issued that would be spent and honoured by nothing (CLOUD-1996). +/// +/// The subjects are typed, not read off the line: `verify` and the class token +/// are the two wrong spellings measured spent and refused on real PRs. +#[test] +fn an_override_request_naming_a_subject_no_refusal_binds_is_refused() { + let dir = superseded("punt-unbindable-subject"); + for typed in ["verify", "receipt read other"] { + let requested = run_with_stdin( + &dir, + &[ + "override", + "request", + "--rule", + "turn mint ahead", + "--verdict", + "receipt read other", + "--subject", + typed, + ], + ANSWERS, + ); + let said = stderr(&requested); + assert_eq!(requested.status.code(), Some(1), "{typed}: {said}"); + assert!( + requested.stdout.is_empty(), + "{typed}: no address may be issued: {}", + String::from_utf8_lossy(&requested.stdout) + ); + for needle in [ + "`turn mint ahead`", + "`receipt read other`", + "1 subject(s)", + "verify,commit", + ] { + assert!( + said.contains(needle), + "{typed}: {needle} missing from {said}" + ); + } + if typed == "verify" { + assert!( + said.contains("`verify`"), + "the bound subject is named: {said}" + ); + } + } +} #[test] fn a_spent_admission_clears_a_superseded_receipt() { diff --git a/crates/batten/tests/it/reference.rs b/crates/batten/tests/it/reference.rs index daa824508..85382b945 100644 --- a/crates/batten/tests/it/reference.rs +++ b/crates/batten/tests/it/reference.rs @@ -73,7 +73,10 @@ fn an_absent_key_is_refused_at_its_line() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("notes/n.md:2 dangling unresolved"), "{out}"); + assert!( + out.contains("notes/n.md:2 rule 'dangling' unresolved"), + "{out}" + ); assert!( !out.contains("key-missing"), "pointer-only, never the token: {out}" @@ -115,7 +118,7 @@ fn an_unescaped_pipe_widens_the_row_and_an_escaped_one_is_quiet() { ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("REGISTER.md:3 dangling register-row-width"), + out.contains("REGISTER.md:3 rule 'dangling' register-row-width"), "{out}" ); @@ -160,7 +163,7 @@ register = "keyset" ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("entries/e.md:1 group-unknown unresolved"), + out.contains("entries/e.md:1 rule 'group-unknown' unresolved"), "{out}" ); } @@ -199,7 +202,7 @@ split = ";" ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("groups.md:3 member-unknown unresolved"), + out.contains("groups.md:3 rule 'member-unknown' unresolved"), "{out}" ); } @@ -246,7 +249,7 @@ relative_to = "citer" ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("out/REGISTER.md:5 file-missing unresolved"), + out.contains("out/REGISTER.md:5 rule 'file-missing' unresolved"), "{out}" ); assert!( @@ -286,7 +289,7 @@ inverse = true ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("REGISTER.md:4 roster-phantom uncited"), + out.contains("REGISTER.md:4 rule 'roster-phantom' uncited"), "{out}" ); } @@ -344,7 +347,10 @@ fn a_document_witness_admits_unless_its_only_witness_is_refused() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("middle/r.md:1 unwitness unresolved"), "{out}"); + assert!( + out.contains("middle/r.md:1 rule 'unwitness' unresolved"), + "{out}" + ); } #[test] @@ -371,7 +377,7 @@ regex = '^workspaces/([^/]+)/' ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("workspaces/a/middle/r.md:1 unwitness unresolved"), + out.contains("workspaces/a/middle/r.md:1 rule 'unwitness' unresolved"), "{out}" ); } @@ -420,7 +426,7 @@ fn an_unparseable_witness_is_could_not_look() { ); assert_eq!(code, Some(2), "{out}"); assert!( - out.contains("witness/1.md:1 unwitness could-not-look"), + out.contains("witness/1.md:1 rule 'unwitness' could-not-look"), "{out}" ); } diff --git a/crates/batten/tests/it/refusal_ceiling.rs b/crates/batten/tests/it/refusal_ceiling.rs index facf8f9d2..b2754c61c 100644 --- a/crates/batten/tests/it/refusal_ceiling.rs +++ b/crates/batten/tests/it/refusal_ceiling.rs @@ -37,6 +37,12 @@ //! `$GIT_DIR` is the only way to observe a genuine first firing without reaching //! into the tree under test. The fixture carries the COMMITTED config, so what it //! measures is still this repository's rows. +//! +//! **EVERY PAYLOAD NAMES A SESSION (CLOUD-2075).** The store is per context — +//! the session, plus the agent id where a subagent reads — and a payload naming +//! no session is full on every firing and marks nothing. So the real-root +//! repeats fire under one session this suite owns, and the fixture cases name +//! theirs explicitly through [`fires_in`]. // Panicking on setup failure is the idiomatic way for a test to fail loudly. #![allow(clippy::unwrap_used, clippy::expect_used)] @@ -51,12 +57,28 @@ fn root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } +/// The session the real-root repeats fire under: this suite's own context, so +/// it never consumes a sighting of the working session's. +const SUITE_SESSION: &str = "refusal-ceiling-suite"; + fn payload(command: &str) -> String { - let encoded = serde_json::to_string(command).expect("a command is encodable"); - format!( - "{{\"hook_event_name\":\"PreToolUse\",\"tool_name\":\"Bash\",\ - \"tool_input\":{{\"command\":{encoded}}}}}" - ) + payload_in(Some(SUITE_SESSION), None, command) +} + +/// A Bash `PreToolUse` payload in `session` (and `agent`), or naming none. +fn payload_in(session: Option<&str>, agent: Option<&str>, command: &str) -> String { + let mut value = serde_json::json!({ + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": command}, + }); + if let Some(session) = session { + value["session_id"] = serde_json::Value::from(session); + } + if let Some(agent) = agent { + value["agent_id"] = serde_json::Value::from(agent); + } + value.to_string() } /// The refusal text a mediated call produces on a REPEAT firing, which is what @@ -98,19 +120,47 @@ fn refusal_once(payload: &str) -> Option { /// ceiling had been raised or deleted, which is the whole failure the /// `refusal-ceiling-raised` weakening exists to report. fn declared_ceiling() -> usize { + declared("max_tokens") +} + +/// The full arm's ceiling, read the same way (CLOUD-2075). +fn declared_first_sighting_ceiling() -> usize { + declared("first_sighting_max_tokens") +} + +fn declared(key: &str) -> usize { let text = std::fs::read_to_string(root().join("batten.toml")) .expect("the committed config is readable"); let config: toml::Value = toml::from_str(&text).expect("the committed config parses"); usize::try_from( config .get("refusal") - .and_then(|table| table.get("max_tokens")) + .and_then(|table| table.get(key)) .and_then(toml::Value::as_integer) - .expect("`[refusal] max_tokens` is declared"), + .unwrap_or_else(|| panic!("`[refusal] {key}` is declared")), ) .expect("a ceiling is not negative") } +/// The committed `reason` of one `[[rule]]` row. +fn rule_reason(id: &str) -> String { + let text = std::fs::read_to_string(root().join("batten.toml")) + .expect("the committed config is readable"); + let config: toml::Value = toml::from_str(&text).expect("the committed config parses"); + config + .get("rule") + .and_then(toml::Value::as_array) + .and_then(|rows| { + rows.iter() + .find(|row| row.get("id").and_then(toml::Value::as_str) == Some(id)) + }) + .and_then(|row| row.get("reason")) + .and_then(toml::Value::as_str) + .unwrap_or_else(|| panic!("`{id}` declares a reason")) + .trim() + .to_owned() +} + /// `budget.rs`'s estimator, which is what the engine's own `Ceiling::over` uses. fn estimated_tokens(line: &str) -> usize { line.len() / 4 @@ -196,8 +246,8 @@ fn a_declared_refusal_emits_its_class_and_its_pointers_and_stops() { // hatch sentence. Each of the four was a copy of something declared once. let line = refusal("sed -n '1,40p' AGENTS.md").expect("the row refuses"); assert!( - line.starts_with("tool run loose"), - "the class leads the line: {line}" + line.starts_with("verdict 'tool run loose' rule 'tool select other'"), + "the labelled class leads the line: {line}" ); for wrapper in ["Refused by", "Fix:", "Bypass with", " ("] { assert!( @@ -250,12 +300,17 @@ fn fixture(name: &str) -> PathBuf { staged.git().base_commit().build() } -/// One firing in a fixture, returning the emitted line. +/// One firing in a fixture under session `s1`, returning the emitted line. fn fires(repo: &Path, command: &str) -> String { + fires_in(repo, Some("s1"), None, command) +} + +/// One firing in a fixture in `session` (plus `agent`), returning the line. +fn fires_in(repo: &Path, session: Option<&str>, agent: Option<&str>, command: &str) -> String { let run = run_with_stdin( repo, &["adjudicate", "--harness", "exit-code"], - &payload(command), + &payload_in(session, agent, command), ); assert_eq!( run.status.code(), @@ -265,6 +320,17 @@ fn fires(repo: &Path, command: &str) -> String { stderr(&run).trim().to_owned() } +/// A hook event other than a Bash call, through the Claude Code adapter. +fn hook(repo: &Path, value: &serde_json::Value) -> std::process::Output { + run_with_stdin( + repo, + &["adjudicate", "--harness", "claude-code"], + &value.to_string(), + ) +} + +const FULL: &str = " — "; + /// The first sighting of a document-only class carries its definition. /// /// **THE CASE THE ROW WAS FILED ON.** `tool run loose` declares exactly one @@ -282,7 +348,7 @@ fn a_first_sighting_carries_the_gloss_and_its_route_by_kind() { let repo = fixture("first-sighting-document-route"); let line = fires(&repo, "head -40 batten.toml"); assert!( - line.starts_with("tool run loose"), + line.starts_with("verdict 'tool run loose'"), "the class still leads the line: {line}" ); assert!( @@ -311,33 +377,300 @@ fn a_first_sighting_carries_the_gloss_and_its_route_by_kind() { ); } -/// The repeat is compact, and a byte PREFIX of the first sighting. +/// A shape deny's first sighting names the verb that prints the ROW's remedy, +/// never the file that refused it (CLOUD-1806). +/// +/// The class's only route used to be `read batten.toml` — back to the config the +/// refusing row lives in. The `read batten.toml` negation and the `policy +/// explain` assertion stay here even once the line carries the row's real id, +/// because those are what discriminate a regression to the circular route. +#[test] +fn a_shape_first_sighting_names_the_rows_remedy_verb() { + let repo = fixture("shape-first-sighting-remedy"); + let line = fires(&repo, "gh pr merge 5"); + for needle in [ + "call name refused", + "commit ship other", + "batten policy rule '", + ] { + assert!(line.contains(needle), "{needle} missing: {line}"); + } + assert!( + !line.contains("read batten.toml"), + "the route must not send the reader back to the refusing file: {line}" + ); + let explained = common::run(&repo, &["policy", "explain", "call name refused"]); + let said = String::from_utf8_lossy(&explained.stdout); + assert!( + said.contains("articulate the call"), + "the class declares its override route: {said}" + ); + assert!( + !said.contains("batten.toml"), + "and no route names the config file: {said}" + ); +} + +/// The pointer arm is a byte PREFIX of the full arm, and sheds no pointer +/// (CLOUD-2075 §7 case 2). /// -/// The prefix property is what makes the two arms one line rather than two -/// renderings: everything the repeat says, the first sighting said first and in -/// the same order. A reader who has met the class recognises the compact form as -/// the head of what they already read. +/// This reverses the repeat that dropped its routes: everything the pointer arm +/// says, the full arm said first and in the same order, and the subjects and +/// routes — every way out — are the same set on both. #[test] -fn a_repeat_drops_the_definition_and_keeps_the_pointers() { - let repo = fixture("repeat-is-compact"); +fn the_pointer_arm_carries_every_route_and_subject_the_full_arm_does() { + let repo = fixture("pointer-carries-routes"); let first = fires(&repo, "head -40 batten.toml"); let repeat = fires(&repo, "head -40 batten.toml"); assert_ne!(first, repeat, "the two arms differ, or nothing was saved"); assert!( first.starts_with(&repeat), - "the repeat is a byte prefix of the first sighting: {repeat:?} vs {first:?}" + "the pointer is a byte prefix of the full arm: {repeat:?} vs {first:?}" + ); + assert!(!repeat.contains(FULL), "the pointer has no tail: {repeat}"); + let head = first.split(FULL).next().expect("a head"); + assert_eq!(head, repeat, "the full arm's pointers ARE the pointer arm"); + for kept in [ + "rule 'tool select other'", + "batten.toml", + "read rules/scanning.md", + "run batten policy rule 'tool select other'", + ] { + assert!( + repeat.contains(kept), + "the pointer keeps `{kept}`: {repeat}" + ); + } +} + +/// The full arm carries the row's own reason and both labels (CLOUD-2075 §7 +/// case 1), reversing the first sighting that left the reason out. +#[test] +fn a_first_sighting_carries_the_rows_reason_and_both_labels() { + let repo = fixture("first-sighting-reason"); + let line = fires(&repo, "head -40 batten.toml"); + let reason = rule_reason("tool select other"); + let opening: String = reason.chars().take(40).collect(); + for needle in [ + "verdict 'tool run loose'", + "rule 'tool select other'", + "a shell text utility stood in for the structured file surface", + opening.as_str(), + "read rules/scanning.md", + "run batten policy rule 'tool select other'", + "Run batten policy explain 'tool run loose'.", + ] { + assert!(line.contains(needle), "`{needle}` missing: {line}"); + } +} + +/// A collapsed row — id equal to its class — still labels both (CLOUD-2075 §7 +/// case 3), on both arms. +#[test] +fn a_collapsed_row_still_labels_rule_and_verdict() { + let repo = fixture("collapsed-row-labels"); + let command = "git push --force-with-lease origin main"; + let both = "verdict 'branch write unsafe' rule 'branch write unsafe'"; + let first = fires(&repo, command); + let repeat = fires(&repo, command); + assert!(first.starts_with(both), "{first}"); + assert!(repeat.starts_with(both), "{repeat}"); +} + +/// Two contexts in one clone never consume each other's sighting (CLOUD-2075 +/// §7 case 4): session A, session B, and A's subagent each get the full arm. +#[test] +fn two_contexts_in_one_clone_each_get_the_full_text() { + let repo = fixture("two-contexts"); + let command = "head -40 batten.toml"; + let a = fires_in(&repo, Some("A"), None, command); + let b = fires_in(&repo, Some("B"), None, command); + let sub = fires_in(&repo, Some("A"), Some("x"), command); + let again = fires_in(&repo, Some("A"), None, command); + assert!(a.contains(FULL), "{a}"); + assert!(b.contains(FULL), "another session is its own reader: {b}"); + assert!(sub.contains(FULL), "a subagent is its own reader: {sub}"); + assert!( + !again.contains(FULL), + "A's second firing is the pointer: {again}" + ); +} + +/// A `SessionStart` forgets one context and leaves the rest (CLOUD-2075 §7 +/// case 5). +#[test] +fn a_session_start_forgets_only_that_contexts_sightings() { + let repo = fixture("session-start-scoped"); + let command = "head -40 batten.toml"; + let _ = fires_in(&repo, Some("A"), None, command); + let _ = fires_in(&repo, Some("B"), None, command); + let started = hook( + &repo, + &serde_json::json!({ + "hook_event_name": "SessionStart", + "session_id": "A", + "source": "startup", + }), + ); + assert_eq!(started.status.code(), Some(0), "{}", stderr(&started)); + let a = fires_in(&repo, Some("A"), None, command); + let b = fires_in(&repo, Some("B"), None, command); + assert!(a.contains(FULL), "A was forgotten, so A is told again: {a}"); + assert!(!b.contains(FULL), "B was not touched: {b}"); +} + +/// A compaction re-delivers every item the cycle saw, at once (CLOUD-2075 §7 +/// case 6), and keeps it marked. +#[test] +fn a_compaction_redelivers_every_seen_item_once_at_session_start() { + let repo = fixture("compaction-redelivers"); + let command = "head -40 batten.toml"; + let full = fires_in(&repo, Some("A"), None, command); + assert!(full.contains(FULL), "{full}"); + let compacted = hook( + &repo, + &serde_json::json!({ + "hook_event_name": "SessionStart", + "session_id": "A", + "source": "compact", + }), + ); + assert_eq!(compacted.status.code(), Some(0), "{}", stderr(&compacted)); + let document: serde_json::Value = String::from_utf8_lossy(&compacted.stdout) + .lines() + .find_map(|line| serde_json::from_str(line).ok()) + .expect("the session start emits its advisory document"); + let context = document["hookSpecificOutput"]["additionalContext"] + .as_str() + .expect("an additionalContext string"); + assert!( + context.contains(&full), + "the full arm is re-delivered byte for byte: {context}" ); + let next = fires_in(&repo, Some("A"), None, command); + assert!(!next.contains(FULL), "and stays marked: {next}"); +} + +/// An edit to a definition mid-cycle is a new item (CLOUD-2075 §7 case 7, +/// CLOUD-1582's pair absorbed). +#[test] +fn an_edited_definition_is_a_new_item_mid_cycle() { + let repo = fixture("definition-edited"); + let command = "head -40 batten.toml"; + let first = fires_in(&repo, Some("A"), None, command); + assert!(first.contains(FULL), "{first}"); + let config = repo.join("batten.toml"); + let text = std::fs::read_to_string(&config).expect("the fixture config"); + let reason = rule_reason("tool select other"); + let opening: String = reason.chars().take(40).collect(); assert!( - repeat.contains("tool select other"), - "the rule id stays on the repeat arm — for 66 rows it is the only \ - discriminator (CLOUD-1637's second amendment): {repeat}" + text.contains(&opening), + "the fixture carries the committed reason" ); - for dropped in [" — ", "read rules/scanning.md"] { + let edited = text.replacen(&opening, "An edited sentence names the same remedy", 1); + std::fs::write(&config, edited).expect("rewrite the fixture config"); + let second = fires_in(&repo, Some("A"), None, command); + assert!( + second.contains(FULL) && second.contains("An edited sentence"), + "an edited definition renders in full again: {second}" + ); + let third = fires_in(&repo, Some("A"), None, command); + assert!(!third.contains(FULL), "an unchanged one does not: {third}"); +} + +/// A payload naming no session is full on every firing and marks nothing +/// (CLOUD-2075 §7 case 8). +#[test] +fn a_session_less_payload_is_full_on_every_firing() { + let repo = fixture("session-less"); + let command = "head -40 batten.toml"; + let first = fires_in(&repo, None, None, command); + let second = fires_in(&repo, None, None, command); + assert!(first.contains(FULL) && second.contains(FULL), "{second}"); + assert!( + !repo.join(".git/batten-sightings").exists(), + "a reader that cannot be named shares no key" + ); +} + +/// The `PostToolUse` boundary marks a full arm read from tool output +/// (CLOUD-2075 §7 case 9); a later hook firing in that context is the pointer. +#[test] +fn the_boundary_marks_and_collapses_a_full_arm_in_tool_output() { + let repo = fixture("boundary-marks"); + let command = "head -40 batten.toml"; + let full = fires_in(&repo, Some("B"), None, command); + assert!(full.contains(FULL), "{full}"); + let output = format!("{full}\ncanary\n{full}\n"); + let posted = hook( + &repo, + &serde_json::json!({ + "hook_event_name": "PostToolUse", + "session_id": "A", + "tool_name": "Bash", + "tool_input": {"command": "batten check"}, + "tool_response": {"stdout": output, "stderr": "", "interrupted": false}, + }), + ); + assert_eq!(posted.status.code(), Some(0), "{}", stderr(&posted)); + let rewrites = batten::hook::Harness::ClaudeCode + .capabilities() + .rewrites_tool_output + .is_capturable(); + let said = String::from_utf8_lossy(&posted.stdout); + if rewrites { + assert!(said.contains("updatedToolOutput"), "{said}"); + } else { assert!( - !repeat.contains(dropped), - "the repeat drops `{dropped}`: {repeat}" + !said.contains("updatedToolOutput"), + "no rewrite where none is measured: {said}" ); } + let next = fires_in(&repo, Some("A"), None, command); + assert!(!next.contains(FULL), "the boundary marked it: {next}"); +} + +/// Every arm the corpus emits is within its declared ceiling (CLOUD-2075 §7 +/// case 1b): the full arm against `first_sighting_max_tokens`, the pointer arm +/// against `max_tokens`. A measurement, so it prints what it measured. +#[test] +fn every_arm_the_corpus_emits_is_within_its_declared_ceiling() { + let full_ceiling = declared_first_sighting_ceiling(); + let pointer_ceiling = declared_ceiling(); + let repo = fixture("corpus-arms"); + let mut widest = (0_usize, String::new(), 0_usize, String::new()); + let mut over: Vec<(usize, String)> = Vec::new(); + for (index, command) in CORPUS.iter().enumerate() { + let session = format!("corpus-{index}"); + let full = fires_in(&repo, Some(&session), None, command); + let pointer = fires_in(&repo, Some(&session), None, command); + let (full_cost, pointer_cost) = (estimated_tokens(&full), estimated_tokens(&pointer)); + if full_cost > widest.0 { + widest.0 = full_cost; + widest.1.clone_from(&full); + } + if pointer_cost > widest.2 { + widest.2 = pointer_cost; + widest.3.clone_from(&pointer); + } + if full_cost > full_ceiling { + over.push((full_cost, full)); + } + if pointer_cost > pointer_ceiling { + over.push((pointer_cost, pointer)); + } + } + // THE MEASUREMENT IS THE OUTPUT: the `[refusal]` comment's table is read + // off these two lines (CLOUD-2075 §E), so they print pass or fail. + #[expect( + clippy::print_stderr, + reason = "the case reports the measured widest arm the ceilings are declared from" + )] + { + eprintln!("measured full {} {}", widest.0, widest.1); + eprintln!("measured pointer {} {}", widest.2, widest.3); + } + assert!(over.is_empty(), "over a declared ceiling: {over:?}"); } /// The store is WRITTEN between the two firings, which is what makes the arms @@ -367,8 +700,9 @@ fn the_sightings_store_is_written_by_a_first_firing() { /// **The second amendment's correction, as a case.** The store digested the CLASS /// token for its whole life, so under a shared class the first row to fire /// consumed the sighting for all of them and the next row's first firing rendered -/// as a repeat — its rule-specific remedy never pointed at. Fourteen `shape` rows -/// in this config raise `call name refused`; two of them are enough to decide it. +/// as a repeat — its rule-specific remedy never pointed at. Every plain `shape` +/// row (eleven in this config when CLOUD-1806 counted) raises `call name +/// refused`; two of them are enough to decide it. #[test] fn a_second_row_of_a_shared_class_still_gets_its_definition() { let repo = fixture("shared-class-two-rows"); @@ -388,6 +722,80 @@ fn a_second_row_of_a_shared_class_still_gets_its_definition() { ); } +/// Every vendored class whose only ways out end in a protected path stays +/// admissible, through its own override or through the blocker's (CLOUD-1893). +/// +/// A starter consumer protects `batten.toml`, and dozens of vendored classes +/// route only into it; one admission on `path write refused` is what keeps them +/// from being a deadlock. The population is DERIVED, never counted, so a row +/// moving a class in or out of the family touches neither side. +#[test] +fn every_class_routed_only_into_a_protected_path_is_admissible() { + use batten::verdict::{RouteKind, routed_only_into_protection, vendored}; + let starter = batten::config::parse(batten::init::STARTER, batten::config::CONFIG_FILE) + .expect("the starter parses"); + let sets = batten::rules::Sets::from_config(&starter).expect("the starter's sets compile"); + let protects = |path: &str| sets.protected.contains(path); + assert!( + protects(batten::config::CONFIG_FILE), + "the starter no longer protects batten.toml, so this gate covers nothing — re-scope it" + ); + + let registry = vendored(); + let stuck = routed_only_into_protection(®istry, protects); + assert!( + stuck.is_empty(), + "classes routed only into a protected path with no admission: {stuck:?}" + ); + + let blocker = batten::verdict::Native::ProtectedMutation.id(); + let mut without = registry.clone(); + let entry = without + .iter_mut() + .find(|entry| entry.id == blocker) + .expect("the blocker is vendored"); + let before = entry.routes.len(); + entry + .routes + .retain(|route| route.kind != RouteKind::Override); + assert!( + entry.routes.len() < before, + "the blocker declares an admission to remove" + ); + + let historical: Vec = registry + .iter() + .filter(|entry| entry.successor.is_none() && entry.withdrawn.is_none()) + .filter(|entry| { + entry.routes.len() == 1 + && entry.routes[0].kind == RouteKind::Document + && entry.routes[0].target == batten::config::CONFIG_FILE + }) + .map(|entry| entry.id.clone()) + .collect(); + let got = routed_only_into_protection(&without, protects); + assert!(!historical.is_empty(), "the CLOUD-1357 family is not empty"); + for id in &historical { + assert!( + got.contains(id), + "{id} routes only into batten.toml: {got:?}" + ); + } + for id in &got { + let entry = without + .iter() + .find(|entry| &entry.id == id) + .expect("a returned id names a class"); + assert!( + entry.routes.iter().all(|route| !matches!( + route.kind, + RouteKind::Override | RouteKind::Command | RouteKind::Issue + )), + "{id} has a way out of its own and was returned anyway" + ); + } +} + /// Every class this config declares can render a route on a first sighting. /// /// The completeness arm the row asks for, and the one that would have caught the @@ -440,11 +848,17 @@ fn the_ceiling_can_fail() { // because the tree passing is the point of the corpus and a tree that could // fail it would be a defect rather than a fixture. let ceiling = declared_ceiling(); - let long = "path write refused ".to_owned() + &"a/very/deep/".repeat(20) + "file.rs"; + let long = "path write refused ".to_owned() + &"a/very/deep/".repeat(60) + "file.rs"; assert!( estimated_tokens(&long) > ceiling, "a line this long must be over the ceiling, or the comparison decides nothing" ); + let full_ceiling = declared_first_sighting_ceiling(); + let long_full = long.clone() + " — " + &"a sentence that runs on ".repeat(80); + assert!( + estimated_tokens(&long_full) > full_ceiling, + "a full arm this long must be over its ceiling, or that comparison decides nothing" + ); let short = refusal("nohup mise run verify &").expect("the row refuses"); assert!( estimated_tokens(&short) <= ceiling, diff --git a/crates/batten/tests/it/refusal_render_bench.rs b/crates/batten/tests/it/refusal_render_bench.rs index 5b6d47dcc..f3730bddd 100644 --- a/crates/batten/tests/it/refusal_render_bench.rs +++ b/crates/batten/tests/it/refusal_render_bench.rs @@ -16,26 +16,13 @@ //! that collapsed every arm to one value would satisfy them all. //! //! The two claims that are the renderer's own are that every compact warm repeat -//! is EXACTLY `Refusal::line()`, and that the full warm rendering is longer by a +//! is EXACTLY the pointer arm, and that the full warm rendering is longer by a //! measured margin. Those are what the report prices. //! -//! **BOTH HALVES HAVE NOW BEEN MEASURED UNDER TWO RENDERERS, and the cases are -//! written to survive the difference.** Before CLOUD-1637, a first sighting -//! appended `command` routes ONLY: `tool run loose` declares none, so it -//! rendered the identical line cold and warm, and `branch write unsafe` declared -//! two whose carried line exceeded the committed `[refusal] max_tokens` of 24 — -//! so `deny_text` dropped them and every emitted margin was ZERO. The margin was -//! real in the renderer and withheld by the budget, which is why every arm -//! carries an unbounded column beside its emitted one: a single `>` over the -//! emitted column would have reported a budget decision as a renderer defect. -//! -//! Since CLOUD-1637 a first sighting carries the class's own definition, both -//! margins are real in what is EMITTED, and the ceiling withholds nothing here. -//! The cases therefore assert the relationships rather than the numbers, and the -//! report's prose is derived from the records rather than stated — the paragraph -//! that explained the zero margins had to be rewritten the moment they stopped -//! being zero, which is the failure that generated prose about a measurement -//! invites. +//! **No renderer takes a ceiling (CLOUD-2075)**, so every figure is the whole +//! arm and there is no unbounded column beside it. The cases assert the +//! relationships rather than the numbers, and the report's prose is derived +//! from the records rather than stated. //! //! # The drift check is here rather than in the task //! @@ -47,8 +34,8 @@ //! to say a number nobody measured against. That is the row's own argument //! against recording the commit SHA, one release later. Measured here twice //! while landing: 0.0.151 → 0.0.152 → 0.0.153, three regenerations, no numbers -//! changed. The baseline is the declared classes and the declared ceiling, which -//! are what the rendering reads. +//! changed. The baseline is the declared classes, which are what the rendering +//! reads. //! //! `refusal_render_report` is re-rendered in this process and diffed against the //! committed `bench/refusal-render/RESULTS.md`, so the report cannot go stale @@ -75,7 +62,7 @@ fn records() -> Vec { .expect("this repository's committed config loads"); let registry = batten::policy::registry_for(&config.verdicts).expect("the committed registry resolves"); - refusal_render(®istry, config.refusal.as_ref()).expect("every measured class is declared") + refusal_render(®istry).expect("every measured class is declared") } fn arm( @@ -94,12 +81,12 @@ fn arm( } /// The compact line the renderer produces for a class, taken from the authority -/// rather than restated: `Refusal::line` is what a repeat sighting must equal. +/// rather than restated: the pointer arm is what a repeat sighting must equal. fn compact_line(class: &str, rule: &str) -> String { let config = batten::config::load(&root().join("batten.toml")).expect("the config loads"); let registry = batten::policy::registry_for(&config.verdicts).expect("the registry resolves"); batten::refusal::Refusal::from_class(rule, ®istry, class, &[], batten::refusal::Fix::None) - .line() + .render_finding(batten::refusal::Arm::Pointer) } #[test] @@ -161,18 +148,9 @@ fn warm_current_equals_warm_first_full_then_compact() { fn a_compact_warm_repeat_is_exactly_the_refusal_line() { // The renderer's own claim, and the case the `current-warm-first-sighting` // mutation must redden: with warm `Current` projected to a first sighting, - // the command-route class renders its routes and stops equalling `line()`. - // - // **THE UNBOUNDED HALF IS WHAT MAKES THAT TRUE, and it was measured rather - // than assumed.** Asserted over the emitted line alone, this case SURVIVES - // the mutation — measured, `mutate sweep` reported exactly that — because - // the emitted line is `Refusal::line()` on BOTH sides of `first_sighting` - // here: the ceiling withholds the routes for `branch write unsafe` and the - // renderer appends none for `tool run loose`. So a projection defect is - // invisible in what this repository emits, which is a fact about the - // repository and not a reason to assert less. The unbounded rendering is - // where `first_sighting` is observable at all, and asserting the claim there - // is what makes the row's declared mutation catchable. + // the full arm carries its definition and stops equalling the pointer arm. + // No renderer takes a ceiling (CLOUD-2075), so the emitted line is where + // `first_sighting` is observable. let records = records(); for (class, rule) in MEASURED_CLASSES { let expected = compact_line(class, rule); @@ -181,15 +159,7 @@ fn a_compact_warm_repeat_is_exactly_the_refusal_line() { assert_eq!( record.line, expected, - "{class} under {} on a warm repeat is exactly Refusal::line()", - strategy.as_str() - ); - assert_eq!( - record.unbounded_characters, - expected.chars().count(), - "{class} under {} on a warm repeat is the compact line with no ceiling to \ - thank for it — a repeat that carries routes when the budget permits them is \ - not a repeat", + "{class} under {} on a warm repeat is exactly the pointer arm", strategy.as_str() ); } @@ -213,19 +183,9 @@ fn a_full_every_time_warm_rendering_is_never_shorter_than_the_compact_repeat() { } #[test] -fn the_command_route_class_pays_a_measured_margin_when_the_budget_permits_it() { - // The half of the previous case that is a strict inequality, and it is stated - // over the UNBOUNDED rendering rather than the emitted one — which is what - // measuring, rather than assuming, changed about this case. - // - // The expectation CLOUD-1606 declared was that a full warm delivery is longer - // than a compact repeat. Over the emitted column that is FALSE in this - // repository, and not because the renderer is wrong: `branch write unsafe` - // declares two command routes, the carried line is ~37 estimated tokens, and - // the committed `[refusal] max_tokens` is 24 — so `deny_text` drops the - // routes and emits the compact line on both arms. The margin the row is about - // exists in the renderer and is withheld by the budget, and asserting it over - // the unbounded column is what says both of those things at once. +fn the_command_route_class_pays_a_measured_margin() { + // The half of the previous case that is a strict inequality: a full warm + // delivery carries the definition a repeat does not (CLOUD-2075). let records = records(); let full = arm( &records, @@ -240,10 +200,10 @@ fn the_command_route_class_pays_a_measured_margin_when_the_budget_permits_it() { Residency::Warm, ); assert!( - full.unbounded_characters > compact.unbounded_characters, - "a class with command routes pays for them on every full delivery ({} against {})", - full.unbounded_characters, - compact.unbounded_characters + full.characters > compact.characters, + "a full delivery pays for the definition on every firing ({} against {})", + full.characters, + compact.characters ); } @@ -280,10 +240,6 @@ fn the_declared_ceiling_bounds_the_repeat_rather_than_the_sighting() { sighting.characters >= repeat.characters, "{class}: a first sighting is never shorter than the repeat it precedes" ); - assert!( - repeat.characters <= repeat.unbounded_characters, - "{class}: an emitted line is never longer than the same arm rendered unbounded" - ); } } @@ -392,8 +348,7 @@ fn the_committed_report_is_what_this_tree_renders() { Path::new("bench/refusal-render/RESULTS.md").display() ) }); - let config = batten::config::load(&root().join("batten.toml")).expect("the config loads"); - let rendered = refusal_render_report(&records(), config.refusal.as_ref()); + let rendered = refusal_render_report(&records()); assert_eq!( committed, rendered, "bench/refusal-render/RESULTS.md is stale — run `mise run refusal-render-bench`" diff --git a/crates/batten/tests/it/release_assets.rs b/crates/batten/tests/it/release_assets.rs index 3ce6abe54..791fe6882 100644 --- a/crates/batten/tests/it/release_assets.rs +++ b/crates/batten/tests/it/release_assets.rs @@ -467,7 +467,7 @@ fn a_list_that_cannot_be_derived_is_partial_never_complete() { // list from, while `release ship missing` carries only an artifact and // falls back to the module's own path. assert!( - text.contains(".github/workflows/release-artifacts.yml release grade other"), + text.contains(".github/workflows/release-artifacts.yml rule 'release grade other'"), "{name}: {text}" ); assert!( diff --git a/crates/batten/tests/it/release_due.rs b/crates/batten/tests/it/release_due.rs index 2d3265fe5..5aec9d491 100644 --- a/crates/batten/tests/it/release_due.rs +++ b/crates/batten/tests/it/release_due.rs @@ -298,7 +298,7 @@ fn a_release_due_window_that_is_absent_or_torn_is_partial() { let decided = against(&dir, &forge, &["check", "--rule", "release grade early"]); assert_eq!(decided.status.code(), Some(2), "{}", said(&decided)); assert!( - said(&decided).contains("release-due-latest release grade early"), + said(&decided).contains("release-due-latest rule 'release grade early'"), "partial, pointing at the window never recorded: {}", said(&decided) ); @@ -307,7 +307,7 @@ fn a_release_due_window_that_is_absent_or_torn_is_partial() { let (code, text) = verdict("empty-trunk", "[]", &latest(Some(3600))); assert_eq!(code, Some(2), "{text}"); assert!( - text.contains("release-due-activity release grade early"), + text.contains("release-due-activity rule 'release grade early'"), "{text}" ); @@ -326,7 +326,7 @@ fn a_release_due_window_that_is_absent_or_torn_is_partial() { let decided = common::run(&torn, &["check", "--rule", "release grade early"]); assert_eq!(decided.status.code(), Some(2), "{}", said(&decided)); assert!( - said(&decided).contains("release-due-activity release grade early"), + said(&decided).contains("release-due-activity rule 'release grade early'"), "torn, not a hold: {}", said(&decided) ); diff --git a/crates/batten/tests/it/released.rs b/crates/batten/tests/it/released.rs index b737df8b5..3ec565480 100644 --- a/crates/batten/tests/it/released.rs +++ b/crates/batten/tests/it/released.rs @@ -167,7 +167,7 @@ fn row(id: &str, column: &str, hold: &str) -> String { /// Which kind it is — held or refused, and the board gate's rule — is the /// record's `issue` and `refusal` lines, echoed above it. fn refusal(text: &str, pointer: &str) -> bool { - let line = format!("{pointer} {RULE}"); + let line = format!("{pointer} rule '{RULE}'"); text.lines().any(|said| said.trim() == line) } @@ -176,7 +176,7 @@ fn refuses(text: &str, id: &str) -> bool { let lead = format!("{id} "); text.lines() .map(str::trim) - .any(|said| said.starts_with(&lead) && said.ends_with(RULE)) + .any(|said| said.starts_with(&lead) && said.ends_with(&format!("rule '{RULE}'"))) } #[test] diff --git a/crates/batten/tests/it/report_only.rs b/crates/batten/tests/it/report_only.rs index 30b800675..3580d5e21 100644 --- a/crates/batten/tests/it/report_only.rs +++ b/crates/batten/tests/it/report_only.rs @@ -222,7 +222,7 @@ fn a_manifest_with_no_verify_task_cannot_be_judged_and_says_so() { let lines: Vec<&str> = said.lines().filter(|line| !line.is_empty()).collect(); assert_eq!( lines, - vec!["mise.toml gate report silent"], + vec!["mise.toml rule 'gate report silent'"], "the could-not-look arm names the manifest, once: {lines:?}" ); } diff --git a/crates/batten/tests/it/review_answered.rs b/crates/batten/tests/it/review_answered.rs index 7af9ae45f..c97d59563 100644 --- a/crates/batten/tests/it/review_answered.rs +++ b/crates/batten/tests/it/review_answered.rs @@ -510,7 +510,10 @@ fn the_measured_shape_a_head_carrying_unresolved_threads_is_refused_naming_the_c // beside it. The retired case read `4 blocking` out of a free string; the // number is the same and it is now a decoded subject, and since CLOUD-1286 // the gloss that used to sit between them is one hop away. - assert!(decision.contains("review answer missing 4"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 4"), + "{decision}" + ); // Pointer-only (non-negotiable rule 4): the ids are not in the engine, so a // refusal naming one would be a payload this channel refuses to carry. assert!(!decision.contains("PRRT_"), "{decision}"); @@ -545,8 +548,14 @@ fn the_discriminating_pair_two_matching_beside_three_that_do_not_records_two() { reviewed(&dir, &declared); let decision = ready(&dir); denied(&decision); - assert!(decision.contains("review answer missing 2"), "{decision}"); - assert!(!decision.contains("review answer missing 5"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 2"), + "{decision}" + ); + assert!( + !decision.contains("rule 'review answer missing' at 5"), + "{decision}" + ); } // --- the conditions the projection carried, restored ------------------------ @@ -569,7 +578,10 @@ fn the_page_guard_an_unread_page_refuses_where_a_full_page_of_the_same_threads_a reviewed(&truncated, &declared); let decision = ready(&truncated); denied(&decision); - assert!(decision.contains("review answer missing 1"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 1"), + "{decision}" + ); } #[test] @@ -584,7 +596,10 @@ fn the_page_guard_adds_to_the_thread_count_rather_than_replacing_it() { reviewed(&dir, &declared); let decision = ready(&dir); denied(&decision); - assert!(decision.contains("review answer missing 3"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 3"), + "{decision}" + ); } #[test] @@ -791,7 +806,10 @@ fn the_bypass_a_compound_command_is_still_a_ready() { reviewed(&dir, &declared); let decision = call(&dir, "cd /repo && gh pr ready 702"); denied(&decision); - assert!(decision.contains("review answer missing 2"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 2"), + "{decision}" + ); } #[test] @@ -880,7 +898,10 @@ fn an_undeclared_class_refuses_with_the_token_and_says_the_registry_is_silent() ); // The count still travels: a subject is decoded from the violation, never // from the registry, which is what makes the undeclared case still useful. - assert!(decision.contains("review answer missing 3"), "{decision}"); + assert!( + decision.contains("rule 'review answer missing' at 3"), + "{decision}" + ); } /// The rows that judge the call: ONE RECEIPT ROW PER CHECK, as the committed diff --git a/crates/batten/tests/it/secrets_kind.rs b/crates/batten/tests/it/secrets_kind.rs index 3fdcd0bb0..1e8a3e648 100644 --- a/crates/batten/tests/it/secrets_kind.rs +++ b/crates/batten/tests/it/secrets_kind.rs @@ -263,7 +263,7 @@ fn a_planted_secret_is_a_pointer_and_never_its_bytes() { ); assert_eq!( String::from_utf8_lossy(&out.stdout), - "app.conf:1 source carry unsafe\n", + "app.conf:1 rule 'source carry unsafe'\n", "stdout is the pointer line and nothing else" ); nowhere(&env, &out, &secret, "text output"); @@ -367,7 +367,7 @@ fn the_same_input_twice_is_byte_identical_and_ordered() { assert_eq!(first.stdout, second.stdout, "text output is byte-stable"); assert_eq!( String::from_utf8_lossy(&first.stdout), - "a.conf:1 source carry unsafe\nb.conf:1 source carry unsafe\n", + "a.conf:1 rule 'source carry unsafe'\nb.conf:1 rule 'source carry unsafe'\n", "ordered by path, not by the order the scanner happened to emit" ); @@ -624,7 +624,7 @@ fn an_erroring_gate_does_not_suppress_another_gates_findings() { ); assert_eq!( String::from_utf8_lossy(&out.stdout), - "lib.rs:1 no-todo\n", + "lib.rs:1 rule 'no-todo'\n", "the surviving gate's finding still reaches stdout" ); let stderr = String::from_utf8_lossy(&out.stderr); diff --git a/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap b/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap index 2276ee2bf..1f89c4681 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__pointer_output_is_frozen.snap @@ -2,5 +2,5 @@ source: crates/batten/tests/it/snapshots.rs expression: stdout_of(&output) --- -a.rs:1 no-todo -b.rs:1 no-todo +a.rs:1 rule 'no-todo' +b.rs:1 rule 'no-todo' diff --git a/crates/batten/tests/it/traversal_chain.rs b/crates/batten/tests/it/traversal_chain.rs index 0f3c2bdd2..15239676d 100644 --- a/crates/batten/tests/it/traversal_chain.rs +++ b/crates/batten/tests/it/traversal_chain.rs @@ -125,7 +125,7 @@ fn a_closing_chain_exits_zero() { fn no_record_for_the_slug_breaks_at_the_entry() { let (code, out) = check("chain-no-record", 8, &[("db/e.md", ENTRY)]); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-entry"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-entry'"), "{out}"); } #[test] @@ -136,7 +136,7 @@ fn a_record_citing_no_capture_breaks_at_the_record() { &[("db/e.md", ENTRY), ("middle/e.md", "---\nother: x\n---\n")], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-record"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-record'"), "{out}"); } #[test] @@ -147,7 +147,7 @@ fn a_missing_capture_file_breaks_at_the_capture() { &[("db/e.md", ENTRY), ("middle/e.md", RECORD)], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-leaf"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-leaf'"), "{out}"); } #[test] @@ -162,7 +162,7 @@ fn a_capture_present_but_unregistered_breaks_at_the_register() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-register"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-register'"), "{out}"); } #[test] @@ -179,7 +179,7 @@ fn a_cycle_terminates() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md broke-at-"), "{out}"); + assert!(out.contains("db/e.md rule 'broke-at-"), "{out}"); } #[test] @@ -195,7 +195,7 @@ fn a_bound_is_reported_as_bound_exceeded_never_as_a_break() { ], ); assert_eq!(code, Some(2), "{out}"); - assert!(out.contains("db/e.md chain-bound"), "{out}"); + assert!(out.contains("db/e.md rule 'chain-bound'"), "{out}"); assert!( !out.contains("broke-at"), "a bound says nothing about the chain: {out}" diff --git a/crates/batten/tests/it/waivers.rs b/crates/batten/tests/it/waivers.rs index 2c4161c53..a2ac452d2 100644 --- a/crates/batten/tests/it/waivers.rs +++ b/crates/batten/tests/it/waivers.rs @@ -115,7 +115,7 @@ fn without_a_waiver_the_rule_denies() { let (repo, home) = repo("waiver-baseline", RULE); let (code, stdout, _) = run(&repo, &home, &["check"]); assert_eq!(code, 2, "a deny finding is a policy verdict"); - assert!(stdout.contains("lib.rs:2 no-todo"), "got: {stdout}"); + assert!(stdout.contains("lib.rs:2 rule 'no-todo'"), "got: {stdout}"); } // subsumed: "an exempted entry passes only through a waiver carrying a reason" crates/batten/tests/it/waivers.rs that case was about the waiver SURFACE rather than about `pin add unsafe` — a live waiver clears the verdict and leaves a pointer-only audit line on stderr — and this drives the compiled binary over a `forbid` row to assert exactly that (CLOUD-1137) @@ -129,7 +129,10 @@ fn a_live_waiver_clears_the_verdict_and_audits_on_stderr() { "and is absent from the answer channel: {stdout}" ); // The compensating control: the suppression is on the record. - assert!(stderr.contains("waived lib.rs:2 no-todo"), "got: {stderr}"); + assert!( + stderr.contains("waived lib.rs:2 rule 'no-todo'"), + "got: {stderr}" + ); assert!( stderr.contains(&format!("expires {LIVE}")), "the audit line names the expiry it relied on: {stderr}" @@ -152,7 +155,7 @@ fn a_lapsed_waiver_leaves_the_finding_and_the_verdict_alone() { let (repo, home) = repo("waiver-lapsed", &format!("{RULE}{}", waiver(LAPSED))); let (code, stdout, stderr) = run(&repo, &home, &["check"]); assert_eq!(code, 2, "the rule fires again"); - assert!(stdout.contains("lib.rs:2 no-todo"), "got: {stdout}"); + assert!(stdout.contains("lib.rs:2 rule 'no-todo'"), "got: {stdout}"); assert!( !stderr.contains("waived"), "and nothing is audited as waived: {stderr}" @@ -236,7 +239,10 @@ fn a_narrowed_waiver_leaves_the_rest_of_the_rule_gating() { let home = Fixture::at(root.join("home")).build(); let (code, stdout, stderr) = run(&repo, &home, &["check"]); assert_eq!(code, 2, "the un-waived finding still blocks"); - assert!(stdout.contains("src/mine.rs:1 no-todo"), "got: {stdout}"); + assert!( + stdout.contains("src/mine.rs:1 rule 'no-todo'"), + "got: {stdout}" + ); assert!(!stdout.contains("vendor/dep.rs"), "got: {stdout}"); assert!(stderr.contains("waived vendor/dep.rs:1"), "got: {stderr}"); } @@ -327,7 +333,7 @@ fn a_live_waiver_lets_the_mediated_call_through_and_audits_it() { // The compensating control, in the tree side's shape minus the pointer a // mediated call does not have. assert!( - err.contains(&format!("waived no-merge (expires {LIVE})")), + err.contains(&format!("waived rule 'no-merge' (expires {LIVE})")), "got: {err}" ); // Pointer-only (non-negotiable 4): never the command, never the reason. diff --git a/crates/batten/tests/it/zero_config.rs b/crates/batten/tests/it/zero_config.rs index 6e5fa65fd..aaa36b438 100644 --- a/crates/batten/tests/it/zero_config.rs +++ b/crates/batten/tests/it/zero_config.rs @@ -96,7 +96,7 @@ fn a_seeded_violation_of_a_default_rule_is_a_violation() { assert_eq!(output.status.code(), Some(2), "stderr: {}", stderr(&output)); assert_eq!( stdout(&output), - "src/lib.rs:2 source carry broken\n", + "src/lib.rs:2 rule 'source carry broken'\n", "the finding is a pointer — `path:line rule-id`, never the matched line" ); assert!(stderr(&output).contains(config::DEFAULTS_NOTE)); diff --git a/mise.toml b/mise.toml index 92d226a59..b10b74046 100644 --- a/mise.toml +++ b/mise.toml @@ -647,7 +647,7 @@ CI_VERDICT_STEPS = "Run mise run ,Run mise exec -- " # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs" +MUTANT_GATES = ".config/nextest.toml,engine-testing,crates/batten/tests/it/common/mod.rs,agentic-experiment-record,answer-the-operator,claude-code-cloud,engine-disk-watch,engine-prune,awk-regex,cap-drift,cfg-gated-test,ci-cache-declared,ci-hygiene,ci-parity,ci-slow-inert,ci-suite-lane,ci-tools,claim-before-code,claim-order-is-stated,coderabbit-config,commit-hygiene,dead-capability,denials-outlive-the-turn,digest-major-agreement,egress-fencing,engine-checks-green,engine-config,engine-doctor,engine-exec,engine-handler,engine-hook,engine-land,engine-landed,engine-lease,engine-lib,engine-mcp,engine-perf,engine-semver,engine-identity,engine-pinned,engine-pipeline,engine-policy,engine-ready,engine-speculation,engine-surface,engine-verdict,engine-wiring,filed-here,could-not-look-laundered,fixture-forks,forge-verdict-required,glob-containment,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,landing-loop,landing-roster-guarded,leased-push,license-table,lock-complete,mcp-timeout-budget,mise,mise-action-floor,mise-pin-agreement,module-map,msrv-pin-agreement,mutation-declared-case,nextest-slow,no-doctests,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential,release-due,release-provision-parity,release-tag-shape,remedy-authorship,repetition-without-progress,report-only,review-answered,review-dispatched,rules-paths-trigger,run-shape,rust-paths-check,sbom-inventory,shell-hygiene,shell-retirement,shell-write-advisory,skill-frontmatter-complete,spawn-widening,stop-posture,suite-subject-retirable,task-substitution,test-targets,timeout-budget,trunk-based,validator-verdict-clean,verdict-routes-resolve,weakens-declared,worktree-registration,engine-mutate,engine-task,run-arg-shape,engine-cargo-graph,branch-age,engine-released,evaluator-closure,evaluator-io-probe,agent-spawn,macos-link,ntia,release-tracking,sbom-actions,task-callable,transcript-corpus,engine-rules,release-assets,durable-write,spawn-factory,turn-ask,engine-forge,engine-forge-query,engine-git,engine-census,ci-signal,engine-ci-signal,docs-tree-absent,ripcord-untracked,hk-pin-agreement,engine-record,engine-suites,engine-admission,engine-ci-step,engine-gitwrite,engine-refusal,engine-repair,crates/batten/tests/it/mutant_rows.rs,hk-fix-selection.pkl,engine-dist,engine-sbom,supply-chain,engine-reclaim,engine-durable,engine-step,engine-step-table,engine-mcp-grant,engine-mcp-posture,engine-preflight,engine-trust,engine-sweep,sweep-exit-table,tracker-hygiene,engine-tracker-reading,task-duplicate-close-check,engine-release,release-hygiene,engine-hk,hook-profile,engine-attestation,engine-turn,engine-unsubscribe,engine-probe,finding-sink,engine-commit,engine-receipt,engine-board-check,check-verdict,engine-budget,engine-codemod,engine-remedy,engine-config-edit,engine-propose,crates/batten/tests/it/stub_portability.rs,crates/batten/tests/it/truncate_handle.rs,git,engine-engine,engine-attribution,crates/batten/tests/it/pointer_only.rs,engine-hookcost,engine-contract,forge-read-first,engine-advisory,engine-drain,engine-patch,engine-provision,batten.toml" # The file inline tasks are declared in, for `mutate`'s `task-` route (CLOUD-1909). # The crate may not spell a consumer's filename (non-negotiable rule 1), so the # manifest is named here, beside the set it serves. Unset, a `task-` gate resolves @@ -2985,7 +2985,13 @@ description = "Provision the host dependencies mise does not: the released `batt # and what mise does to an array: the entries run in order and the first failure # ends the task with its own exit code, so `install.sh`'s `1`/`2` still reach the # caller unreworded. -run = ["./install.sh", "batten wiring reclaim -y"] +# +# THE INSTALLER HAS NO JOB IN ANY TASK (CLOUD-2123). `mise` is itself a +# `[[provision]]` row batten installs, so a host that can run this task already +# has a `batten`: the bootstrap is the setup script's `curl … | sh`, and from +# then on the binary installs the binary. Reinstalling the latest release here +# is how an engine came to disagree with its pin. `program run loose` holds it. +run = ["batten engine update", "batten wiring reclaim -y"] # The report half of the same seam, and the setup script calls this one too. # @@ -3231,10 +3237,13 @@ description = "Session start: install the latest RELEASE over whatever `deps-ins # own rather than a flattened `1`. quiet = true silent = "stdout" -# THEN THE PIN (CLOUD-2062): `install.sh` provisions a release so a binary -# exists at all; `batten engine update` then converges it on the engine the -# committed `batten.toml` pins. An unpinned checkout pays nothing for it. -run = ["./install.sh", "batten engine update"] +# THE BINARY INSTALLS THE BINARY (CLOUD-2123). `deps-install` has already put a +# `batten` on PATH by the time this runs, so the installer has no job left here: +# it fetched the LATEST release over that binary every session, and a pin naming +# any other build then refused every call until something rebuilt it, measured +# twice on PR #1102. `batten engine update` converges on the committed pin, and +# an unpinned checkout pays nothing for it. `program run loose` holds the line. +run = "batten engine update" [tasks."session:git-hooks"] description = "Session start: install the repo-owned git hooks, the per-clone step nothing performed for 24 commits (CLOUD-476)" diff --git a/policy/forge-read-first.rego b/policy/forge-read-first.rego new file mode 100644 index 000000000..1a049306f --- /dev/null +++ b/policy/forge-read-first.rego @@ -0,0 +1,159 @@ +#MUTANT-SUITE crates/batten/tests/it/forge_read_first.rs +#MUTANT forge-call-unselected|s@^\tprogram.name == "gh"$@\tfalse@|a_forge_call_is_handed_its_memory +# A call that reaches the code host is handed the memory documenting this host's +# GitHub access (CLOUD-1470). +# +# MEASURED TWICE, and both are why this exists. 2026-08-31 (CLOUD-1259): an agent +# took an `add_repo` detour the memory itself says is blocked. 2026-09-05: a +# session spent six turns telling the owner the lander could not run — every +# measurement true, the conclusion false — and `mise run land` drove the loop +# first try. The memory opens with exactly that warning, and nothing said to read +# it at the moment it mattered. +# +# A WARN ROW, NEVER A DENY. The call is allowed; the pointer rides it on the +# advisory channel, where the class's one `document` route renders as `read …`. +# +# SELECTED ON THE PROGRAM, never on a segment's first word (CLOUD-1382), so +# `cd /tmp && mise exec -- gh …` resolves to `gh`. `git` is judged on its FIRST +# argument on purpose: `git commit -m push` is not a forge call. `git -C +# push` is therefore not selected — a stated narrowing, not a miss. +# +# `pre-tool` only, so one call gets one pointer and the batch boundary does not +# repeat it. The tool names are this host's, which is why they live in consumer +# data rather than in the engine. +# METADATA +# description: | +# Bound to the mediated-call surface: this module is `scope = "mediated_call"`, +# so it reads `{call, facts}` and NOT the tree document. +# schemas: +# - input: schema["policy-call.schema"] +package batten.forge_read_first + +import rego.v1 + +rules contains "forge read first" + +forge_verbs := {"push", "fetch", "pull", "clone", "ls-remote"} + +reached contains program.name if { + some program in input.call.programs + program.name == "gh" +} + +reached contains program.name if { + some program in input.call.programs + program.name == "git" + program.arguments[0] in forge_verbs +} + +reached contains program.name if { + some program in input.call.programs + program.name == "mise" + program.arguments[0] == "run" + program.arguments[1] == "land" +} + +reached contains input.call.tool if startswith(input.call.tool, "mcp__github__") + +reached contains input.call.tool if input.call.tool == "mcp__claude-code-remote__add_repo" + +violation contains { + "rule": "forge read first", + "verdict": "forge read first", + "subjects": [{"artifact": name}], +} if { + input.call.event == "pre-tool" + some name in reached +} + +# Every case passes `programs`, and at least one is compound: `batten policy +# test` refuses a mediated-call suite of bare commands (CLOUD-857). These hand the +# predicate a resolution the engine is supposed to produce; +# `crates/batten/tests/it/forge_read_first.rs` is the tier that drives the engine. +test_a_gh_read_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "gh", "name": "gh", "arguments": ["pr", "view", "1"], "mediated": false}], + }} +} + +test_gh_through_the_pin_in_a_compound_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "command": "cd /tmp && mise exec -- gh api repos/o/r", + "programs": [ + {"program": "cd", "name": "cd", "arguments": ["/tmp"], "mediated": false}, + {"program": "gh", "name": "gh", "arguments": ["api", "repos/o/r"], "mediated": true}, + ], + }} +} + +test_a_git_push_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "git", "name": "git", "arguments": ["push", "origin", "HEAD"], "mediated": false}], + }} +} + +test_land_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "mise", "name": "mise", "arguments": ["run", "land"], "mediated": false}], + }} +} + +test_a_github_tool_is_selected if { + some _ in violation with input as {"call": {"event": "pre-tool", "tool": "mcp__github__get_me", "programs": []}} +} + +test_add_repo_is_selected if { + some _ in violation with input as {"call": { + "event": "pre-tool", + "tool": "mcp__claude-code-remote__add_repo", + "programs": [], + }} +} + +test_a_listing_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "ls", "name": "ls", "arguments": ["-la"], "mediated": false}], + }} +} + +test_a_local_git_call_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "git", "name": "git", "arguments": ["status"], "mediated": false}], + }} +} + +test_a_commit_message_saying_push_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "programs": [{"program": "git", "name": "git", "arguments": ["commit", "-m", "push"], "mediated": false}], + }} +} + +test_an_echo_naming_gh_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "pre-tool", + "command": "cd /tmp && echo gh", + "programs": [ + {"program": "cd", "name": "cd", "arguments": ["/tmp"], "mediated": false}, + {"program": "echo", "name": "echo", "arguments": ["gh"], "mediated": false}, + ], + }} +} + +test_a_gh_call_at_stop_is_not_selected if { + count(violation) == 0 with input as {"call": { + "event": "stop", + "programs": [{"program": "gh", "name": "gh", "arguments": ["pr", "view", "1"], "mediated": false}], + }} +} + +deny contains message if { + some v in violation + message := v.verdict +} diff --git a/policy/turn-ask.rego b/policy/turn-ask.rego index cc8fac2ea..b37582f97 100644 --- a/policy/turn-ask.rego +++ b/policy/turn-ask.rego @@ -76,7 +76,7 @@ violation contains { # silent — measured, the first cut of these tests passed their silent half for # exactly that reason. -punt_regex := "Your call, |(?i:waiting on your call|both your call|a checkpoint with you)" +punt_regex := "Your call, |(?i:waiting on your call|both your call|a checkpoint with you|once you run|restart the session)" offered(text) := v if { v := violation with input as {"call": {"final-message": text}} @@ -91,6 +91,21 @@ test_waiting_on_the_human_is_named if { count(offered("Stopping. Waiting on your call about the downgrade.")) == 1 } +# CLOUD-2117: the container's own step handed to the human, both witnessed on +# PR #1102. Nobody but the agent can run a command in its container. +test_a_command_handed_to_the_human_is_named if { + count(offered("Once you run `BATTEN_VERSION=v0.0.202 ./install.sh` in `/home/user/batten`, I'll push.")) == 1 +} + +test_a_session_restart_handed_to_the_human_is_named if { + count(offered("To unblock, either:\n- run `./install.sh`, or\n- restart the session, since start rebuilds it.")) == 1 +} + +test_the_agent_running_it_is_not_a_punt if { + # THE NARROWNESS BOUNDARY: the agent reporting its own run is the work, not an offer. + count(offered("I ran `batten engine update`, then pushed. Once it ran, the hook loaded.")) == 0 +} + test_a_decision_already_made_is_not_a_punt if { # The measured false positives of bare `your call`: each reports a choice the # human already made. diff --git a/rules/toolchain.md b/rules/toolchain.md index 47c5cf72b..550bf2225 100644 --- a/rules/toolchain.md +++ b/rules/toolchain.md @@ -441,9 +441,11 @@ mediating. policy explain ""` on the CLASS token (not the rule id) lists its routes, and where one is an override, `batten override request` then `batten override spend` is the way through that leaves a record. These four - rows render under `call name refused`, which declares no override route yet — - CLOUD-1806 owns that gap, and the residual hatch surviving there is the - migration row's business, not a remedy this file prescribes. + rows render under `call name refused`, which declares an override route + (CLOUD-1806): read the row's remedy with `batten policy rule ''` first, + and where it cannot perform the change, `batten override request` then + `spend` is the recorded way through, bound to the row id at the current + commit. - **`memory-guard` is retired** (CLOUD-442), and what it denied is now the engine's protected-path gate: `.serena/memories/**` in `protected` crossed with the `[[verb]]` table, which covers the Write/Edit tools and a command's