From 65757c8663e97369731bd7dc4e8ba6459183e1ad Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Thu, 27 Aug 2026 22:55:31 +0000 Subject: [PATCH 1/3] feat(rules): a fourth conserves arm for a withdrawal, and delete the wrapper it unblocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `conserves` obliges every deleted `@test` to name an arm — `carried`, `subsumed` or `changed` — and all three name a SUCCESSOR, because the column was written for a bash suite migrating into the engine. A WITHDRAWAL has none: the subject is deleted because the feature should not exist, so the honest mapping is that there is nothing to map. With three arms the only routes past that were a false `subsumed` — a ledger entry that lies in order to pass — or a `[[waiver]]`, which `config-lint` refuses as `waiver-added` unless the weakening was groomed onto the issue before the work started. Retrofitting that grooming is laundering, not grooming. So the gate had no honest path, which makes it a defect rather than a verdict, and AGENTS.md says a wrongly-refusing gate is repaired rather than ticketed. `withdrawn` is that repair, and it is admissible ONLY where the dying file's declared subject is absent at head. That condition is what keeps it strictly NARROWER than the waiver it replaces: a waiver admits every deletion under its path, this admits one case at a time and only once the subject went with it. It owes a reason and names no target — there is no successor to name, and demanding one would be the false `subsumed` again. ONE READ OF "DID THE SUBJECT DIE", BECAUSE THERE WERE ABOUT TO BE TWO. On `main` today `conserve_case_names` RETURNS `fully_mapped` and runs before `retirement_blockers` computes subject death, so the arm needed that fact earlier. `subject_facts` resolves it once, above both, and `retirement_blockers` becomes a pure composition over it — keeping CLOUD-1050's `fully_mapped` skip, which belongs to the aggregate column alone and has no bearing on the per-case question. A header reader and a tree reader in one decision would disagree on exactly the rebase where it matters. The git round trip is skipped entirely when nothing decreased, so a ratchet moving in the permitted direction pays nothing for the column. Absence stays byte-identical to before: the fourth token joins the arm list only where a row declares it, and a declared-but-blank one is refused at load, since an empty token matches every line and would claim every case. Then the deletion it unblocks. `.claude/container-setup.sh` and its suite were added by #709 and are withdrawn here: a Claude-cloud-specific bootstrap around an install path whose whole point is being harness-agnostic. #711 established why it is unnecessary — honouring the CA bundle the environment already declares gets the one-liner through a TLS-re-terminating proxy with no `NO_PROXY` fencing at all, so the wrapper was solving a problem it had misread. The ledger splits the eight cases honestly rather than uniformly: the off-PATH refusal is `subsumed` by `install.sh`'s own behaviour, the NO_PROXY fencing is `changed` (same problem, narrower mechanism), and the six describing the wrapper's own existence are `withdrawn`. Shown able to fail, in both directions (CLOUD-418): removing the arm from `batten.toml` restores exactly SIX findings — the six withdrawn cases, while the `subsumed` and `changed` arms still resolve — and restoring it returns the tree to green. `a_withdrawal_over_a_live_subject_refuses` is the discriminating case: it leaves the subject standing while claiming its cases withdrawn, which is a suite gutted with a note attached, and it asserts at the ARM's own line rather than on a reason string — the aggregate `subject-alive` blocker fires either way, so a case keyed on that would pass against an arm honouring every withdrawal. Refs: CLOUD-1080, CLOUD-1050, CLOUD-908, CLOUD-418 --- .claude/container-setup.sh | 215 ------------------ batten.toml | 21 ++ crates/batten/src/rules.rs | 376 +++++++++++++++++++++++++------- crates/batten/tests/ratchet.rs | 172 +++++++++++++++ schema/batten.local.schema.json | 7 + schema/batten.schema.json | 7 + tests/container-setup.bats | 209 ------------------ 7 files changed, 499 insertions(+), 508 deletions(-) delete mode 100755 .claude/container-setup.sh delete mode 100644 tests/container-setup.bats diff --git a/.claude/container-setup.sh b/.claude/container-setup.sh deleted file mode 100755 index 556c078e6..000000000 --- a/.claude/container-setup.sh +++ /dev/null @@ -1,215 +0,0 @@ -#!/bin/sh -# -# Claude cloud container setup: get the released `batten` on PATH before the -# session starts. -# -# WHY THIS LIVES UNDER `.claude/`. It is the one harness-specific piece of this -# arrangement, so it sits with the rest of the Claude material rather than in a -# neutral directory — everything it calls (`install.sh`, the release assets) is -# harness- and OS-agnostic and stays that way. A second harness that needs the -# same thing writes its own four-line caller here and reuses all of it. -# -# WHY IT IS IN THE REPO AT ALL, given the container's own start script is where it -# runs. That console field cannot be version controlled: nobody can review it, -# `contract-drift` cannot see it, and no gate reads it. So the steps live here with -# a history, and the field holds one line that calls this. What is out of tree is a -# pointer, not a program. -# -# WHAT IT FIXES, measured rather than assumed. `.claude/settings.json` registers -# `batten hook --harness claude-code` on `SessionStart` as the FIRST group, ahead -# of `.claude/hooks/session-start.sh` — and that hook is what runs -# `mise run install:local`, the step that puts the binary on PATH. So on a cold -# container the engine's own `SessionStart` registration fires with no binary. It -# fails open, quietly, which means the `contract-drift` snapshot that is supposed -# to be seeded "before any tool does" is not seeded at all on a fresh container, -# and nothing reports it. This is what makes that registration find a binary. -# -# ONE JOB: the binary. `mise install`, the submodules, `doctor`, the git hooks and -# `container-preflight` all stay in `session-start.sh`, where they are gated, are -# covered by `tests/session-start.bats`, and are visible to `contract-drift`. -# `session-start.sh` also still runs `install:local`, which is not redundant: on a -# dev clone the working tree's build must supersede the released binary, and it is -# the recovery path if this never ran. -# -# IT ASSUMES NO CHECKOUT AND NO PARTICULAR REPOSITORY. It reads no path relative to -# a working tree, so a container that checks out something else, several things, or -# nothing at all is a container this still works on. `BATTEN_VERSION` pins which -# release to install, and a caller that fetched THIS script from a release tag -# should pass that same tag, so the bootstrap and the binary it installs come from -# one tested release rather than from two. -# -# Idempotent: safe to re-run. A second run re-installs the same version over the -# same path, which is how `install.sh` already behaves. -# -# Exit 0 installed / 1 refused / 2 could not look — house-style §7, the same -# spelling `install.sh` and `release-assets-check` use. -set -eu - -REPO="${BATTEN_REPO:-button-inc/batten}" -API="${BATTEN_API:-https://api.github.com}" -# Somewhere on PATH, and that is load-bearing: `install.sh` only WARNS on stderr -# when its destination is off PATH, so getting this wrong installs a binary -# nothing can find and says so only in a log nobody reads. -DEST="${BATTEN_INSTALL_DIR:-/usr/local/bin}" -LOG="${BATTEN_BOOTSTRAP_LOG:-/tmp/container-setup-batten.log}" -RETRIES="${BATTEN_BOOTSTRAP_RETRIES:-3}" - -log() { printf '%s\n' "$*" >>"$LOG"; } -die() { - printf 'container-setup: %s\n' "$2" >&2 - log "$2" - exit "$1" -} - -# THE GITHUB HOSTS MUST BE FENCED IN THE AMBIENT `NO_PROXY`, and nothing inside -# the repo can do it for us. `mise.toml`'s `[env]` appends exactly these, but -# `container-preflight` records why that cannot help: mise applies `[env]` to the -# processes it RUNS, after its own resolver has already made the call. Same shape -# one layer earlier — `install.sh` inherits whatever the container exports, so an -# unfenced `api.github.com` sends the release read through a proxy and the whole -# thing exits 2 having installed nothing. -for host in api.github.com objects.githubusercontent.com codeload.github.com uploads.github.com; do - case ",${NO_PROXY:-}," in - *",$host,"*) ;; - *) NO_PROXY="${NO_PROXY:+$NO_PROXY,}$host" ;; - esac -done -no_proxy="$NO_PROXY" -export NO_PROXY no_proxy - -command -v curl >/dev/null 2>&1 || die 2 "no curl, so no release can be read." -command -v tar >/dev/null 2>&1 || die 2 "no tar, so install.sh could not unpack an archive." - -# WHICH TOKEN THIS HOST'S RELEASE READS NEED, which is host knowledge and so -# belongs here rather than in the generic installer. Measured on a Claude cloud -# container: `GH_TOKEN` and `GITHUB_TOKEN` are both set and both answer **401** on -# `api.github.com` for this PRIVATE repo, while `GITHUB_PERSONAL_ACCESS_TOKEN` -# succeeds. `install.sh` prefers the first non-empty of its list, so leaving this -# to it would send the 401 one and stop. -# -# Naming it through `BATTEN_GITHUB_TOKEN` is how a host declares the answer — the -# installer's own precedence is untouched, and it keeps working unchanged the day -# CLOUD-585 makes the repo public and no token is needed at all. -if [ -z "${BATTEN_GITHUB_TOKEN:-}" ] && [ -n "${GITHUB_PERSONAL_ACCESS_TOKEN:-}" ]; then - BATTEN_GITHUB_TOKEN="$GITHUB_PERSONAL_ACCESS_TOKEN" - export BATTEN_GITHUB_TOKEN - log "using GITHUB_PERSONAL_ACCESS_TOKEN for the release read" -fi - -# THE RELEASE IS THE SOURCE, AND A CHECKOUT IS NEVER TRUSTED IMPLICITLY. An -# earlier version of this preferred a checked-out `install.sh` when it found one, -# and that was wrong twice over: a container may check out any repository, or -# several, or none, so there is nothing to resolve a path against; and whatever IS -# checked out is an arbitrary ref — a feature branch, a fork, an unreviewed PR head -# — so running its bootstrap means a session on any branch installs whatever that -# branch says to. The whole point of pinning to a RELEASE is that a release is -# tested and immutable and a branch tip is neither. -# -# So the fetch path is the only path, and the script's own bytes are verified -# against the release's checksum manifest before anything runs: piping an -# unverified script into a shell moves the trust boundary rather than holding it. -# `install.sh` is a release asset for exactly this, and `release-assets-check` is -# the gate that keeps it one. -# -# `BATTEN_SETUP_FROM_CHECKOUT=1` opts INTO the local file, for a maintainer testing -# an unreleased change. Opt-in rather than detected, because the safe default has to -# be the one a container gets without anyone choosing it. -if [ "${BATTEN_SETUP_FROM_CHECKOUT:-0}" = "1" ]; then - here=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) - [ -x "$here/install.sh" ] || die 2 "BATTEN_SETUP_FROM_CHECKOUT=1 but no executable install.sh beside $here — this opt-in names a checkout that is not there." - log "opted into the checked-out install.sh at $here/install.sh" - BATTEN_INSTALL_DIR="$DEST" BATTEN_REPO="$REPO" BATTEN_API="$API" \ - sh "$here/install.sh" >>"$LOG" 2>&1 || - die $? "install.sh refused or could not complete — see $LOG." -else - log "fetching install.sh from the release" - - # One retry policy, here, because `install.sh` deliberately has none: it runs - # curl with `silent show-error fail location` and no `--retry`, no - # `--connect-timeout` and no `--max-time`, so a caller is the only place a - # transient failure can be absorbed. Backoff rather than a tight loop — a - # rate-limited release API answers no faster for being asked again at once. - # THE TOKEN TRAVELS ON STDIN, never in argv — an `Authorization: Bearer …` on - # the command line is readable by any other user on the box through `ps`, which - # is the same reason `install.sh` uses `curl --config -`. Sending it at all is - # not optional on a private repo: the first version of this fallback set - # `BATTEN_GITHUB_TOKEN` for the installer and then made its OWN release read - # unauthenticated, which answers 403 and reads exactly like an egress problem. - fetch() { # fetch - attempt=1 - while :; do - if { - if [ -n "${BATTEN_GITHUB_TOKEN:-}" ]; then - printf 'header = "Authorization: Bearer %s"\n' "$BATTEN_GITHUB_TOKEN" - fi - printf 'header = "X-GitHub-Api-Version: 2022-11-28"\n' - printf 'silent\nshow-error\nfail\nlocation\n' - printf 'connect-timeout = 10\nmax-time = 120\n' - printf 'output = "%s"\n' "$2" - printf 'url = "%s"\n' "$1" - } | curl --config -; then - return 0 - fi - [ "$attempt" -ge "$RETRIES" ] && return 1 - sleep $((attempt * attempt)) - attempt=$((attempt + 1)) - done - } - - scratch=$(mktemp -d) || die 2 "no writable temp directory." - trap 'rm -rf "$scratch"' EXIT - - # The release resolved ONCE and reused, so the manifest and the script cannot - # come from two different releases — which is the one way a verified script - # could still be the wrong script. - if [ -n "${BATTEN_VERSION:-}" ]; then - rel_url="$API/repos/$REPO/releases/tags/${BATTEN_VERSION}" - else - rel_url="$API/repos/$REPO/releases/latest" - fi - fetch "$rel_url" "$scratch/release.json" || die 2 "cannot read the release list at $rel_url." - - # No jq: this runs before anything is provisioned, the same constraint - # `install.sh` is written under. - asset_url() { # asset_url - tr ',' '\n' <"$scratch/release.json" | - grep -F '"browser_download_url"' | - sed -nE 's#.*"(https://[^"]*/'"$1"')".*#\1#p' | - head -n 1 - } - - sums_url=$(asset_url 'SHA256SUMS[A-Za-z0-9._-]*') - [ -n "$sums_url" ] || die 1 "the release carries no checksum manifest, so install.sh cannot be verified." - script_url=$(asset_url 'install\.sh') - [ -n "$script_url" ] || die 1 "the release carries no install.sh asset. release-assets-check is the gate that should have caught this." - - fetch "$sums_url" "$scratch/SHA256SUMS" || die 2 "cannot fetch the checksum manifest." - fetch "$script_url" "$scratch/install.sh" || die 2 "cannot fetch install.sh." - - if command -v sha256sum >/dev/null 2>&1; then - got=$(sha256sum "$scratch/install.sh" | cut -d' ' -f1) - elif command -v shasum >/dev/null 2>&1; then - got=$(shasum -a 256 "$scratch/install.sh" | cut -d' ' -f1) - else - die 2 "no sha256sum or shasum, so install.sh cannot be verified — and this does not run unverified bytes." - fi - - # `sha256sum`'s own format: 64 hex, a space, a mode byte, the name. Anchored on - # the basename so a manifest listing paths and one listing names both read. - want=$(sed -nE 's/^([0-9a-fA-F]{64}) [ *].*install\.sh$/\1/p' "$scratch/SHA256SUMS" | head -n 1) - [ -n "$want" ] || die 1 "the manifest has no entry for install.sh, so its bytes are unverified. Nothing was run." - [ "$got" = "$want" ] || die 1 "sha256 mismatch on install.sh — the manifest and the script disagree. Nothing was run." - log "fetched install.sh verified against the release manifest" - - BATTEN_INSTALL_DIR="$DEST" BATTEN_REPO="$REPO" BATTEN_API="$API" \ - sh "$scratch/install.sh" >>"$LOG" 2>&1 || - die $? "install.sh refused or could not complete — see $LOG." -fi - -# The one thing worth asserting rather than assuming: the binary is where a bare -# `batten` will find it. Every hook registration names it bare, so a binary -# installed off PATH is indistinguishable from no binary at all. -command -v batten >/dev/null 2>&1 || - die 1 "batten installed into $DEST but is not resolvable as \`batten\` — $DEST is not on PATH, and every hook registration names it bare." - -printf 'container-setup: batten ready (%s)\n' "$(batten --version 2>/dev/null || echo 'version unreadable')" diff --git a/batten.toml b/batten.toml index 9e1c07366..8ac6adb49 100644 --- a/batten.toml +++ b/batten.toml @@ -2466,12 +2466,33 @@ no_fix_reason = "restore the tests, or waive the reduction deliberately; which o # test or a `policy test` case, and both are reachable from there. A wave that # needs a ledger somewhere else widens this glob deliberately rather than # scattering arms — the walk is bounded by declaration on purpose. +# +# CLOUD-1080 adds the fourth arm, and the reason is a measured dead end rather +# than a wish. The three above all name a SUCCESSOR, because they were written for +# a suite migrating into the engine. A WITHDRAWAL has none: `.claude/container- +# setup.sh` was added and removed inside one session, and six of its eight cases +# described the wrapper's own existence — which script to prefer, what to fetch, +# what to verify about the fetched bytes — so nothing replaced them because +# nothing should have a subject to replace. The two that did have successors are +# `subsumed` and `changed` in that ledger. +# +# With three arms the only routes past that were a false `subsumed` — a ledger +# entry that lies in order to pass — or a `[[waiver]]`, which `config-lint` +# refuses as `waiver-added` unless the weakening was groomed onto the issue before +# the work started. Neither is honest, so the gate had no honest path, which makes +# it a defect rather than a verdict. +# +# It is admissible ONLY where the dying file's declared subject is absent at head, +# which is what keeps it strictly NARROWER than the waiver it replaces: a waiver +# admits every deletion under its path, and this admits one case at a time and +# only once the subject went with it. It owes a reason and names no target. [rule.conserves] case = "@test \"" close = "\"" carried = "// carried:" subsumed = "// subsumed:" changed = "// changed:" +withdrawn = "// withdrawn:" declared_in = "crates/batten/tests/*.rs" # The mirror direction: a disabled test is a deleted test that still counts, so diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index 32d46fc67..2887feeb8 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -2356,6 +2356,26 @@ pub struct Conserves { /// The token an arm claiming *this diverges, deliberately* is written after. /// Alone among the three it also owes a reason — the text after its target. pub changed: String, + /// The token an arm claiming *nothing replaces this, the subject is gone* is + /// written after (CLOUD-1080). Optional, so a row without it behaves exactly + /// as it did before this column existed. + /// + /// **The other three all name a successor, and a WITHDRAWAL has none.** They + /// describe a suite migrating into another mechanism; this describes a feature + /// removed, where the honest mapping is that there is nothing to map. Without + /// it the only ways past a withdrawal are a false `subsumed` — a ledger entry + /// that lies to pass — or a `[[waiver]]`, which `config-lint` refuses as + /// `waiver-added` unless the weakening was groomed onto the issue before the + /// work. So the gate had no honest path, which is a defect rather than a + /// verdict. + /// + /// **It is admissible ONLY where the dying file's declared subject is absent + /// at head**, which is what keeps it strictly narrower than the waiver it + /// replaces: it cannot excuse deleting cases whose subject is still standing, + /// and that is the abuse a bare fourth verb would open. It owes a reason and + /// names no target, for the same reason it exists — there is no successor to + /// name, and a column demanding one would be the false `subsumed` again. + pub withdrawn: Option, /// The glob the head tree is read over to find arms. /// /// Required rather than defaulted to the rule's own `glob`: the successors of @@ -3714,7 +3734,27 @@ impl Rule { ))); } } - let arms = [&conserves.carried, &conserves.subsumed, &conserves.changed]; + // Blank is refused for the optional arm too, where it is DECLARED: an + // empty token matches every line, so `withdrawn = ""` would claim every + // case in the ledger. Absent and blank are different answers, and only the + // first one means "this row has three arms". + if let Some(token) = conserves.withdrawn.as_deref() + && token.trim().is_empty() + { + return Err(UsageError::raise(format!( + "rule {}: `conserves.withdrawn` is declared but blank — an empty token matches every line, which would claim every case. Remove the key to keep three arms.", + self.id + ))); + } + let arms: Vec<&String> = [ + Some(&conserves.carried), + Some(&conserves.subsumed), + Some(&conserves.changed), + conserves.withdrawn.as_ref(), + ] + .into_iter() + .flatten() + .collect(); for (index, arm) in arms.iter().enumerate() { if arms[index + 1..].contains(arm) { return Err(UsageError::raise(format!( @@ -6191,7 +6231,35 @@ fn ratchet_rule( // deleted case owed no mapping at all. The two questions are different, so // only the aggregate finding below stays conditional on // `direction.violated`; the reasoning is on `conserve_case_names`. - let fully_mapped = conserve_case_names(rule, root, files, &base_counts, &base_text, findings); + // + // ONE ANSWER TO "DID THE SUBJECT DIE", RESOLVED HERE (CLOUD-1080). Both the + // aggregate admission below and the `withdrawn` arm inside + // `conserve_case_names` rest on it, and conservation runs before the aggregate + // return — so it has to be resolved ahead of both rather than inside either. + // Two derivations of one git fact is the drift this file already documents + // elsewhere; the round trip is skipped entirely when nothing decreased. + let subjects = match retires_with { + Some(token) => subject_facts( + root, + base, + token, + &base_counts, + &working_counts, + &base_text, + files, + )?, + None => SubjectFacts::default(), + }; + + let fully_mapped = conserve_case_names( + rule, + root, + files, + &base_counts, + &base_text, + &subjects, + findings, + ); if !direction.violated(base_count, working_count) { return Ok(()); @@ -6201,18 +6269,15 @@ fn ratchet_rule( // subjects having DIED — declared at `base`, alive at `base`, absent now. // Anything else falls through to the refusal below, so a row that cannot // justify its decrease still denies at its own severity. + // + // The git fact itself was resolved ABOVE, before `conserve_case_names`, + // because the `withdrawn` arm reads the same fact and one answer serves both. + // What is left here is the composition, which is this column's alone: the + // `fully_mapped` skip is CLOUD-1050's arm and has no bearing on the per-case + // question. let mut blockers: BTreeSet = BTreeSet::new(); - if let Some(token) = retires_with { - blockers = retirement_blockers( - root, - base, - token, - &base_counts, - &base_text, - &working_counts, - &fully_mapped, - files, - )?; + if retires_with.is_some() { + blockers = retirement_blockers(&subjects, &fully_mapped); if blockers.is_empty() { // Every affected file's subject died in this same change, or its // cases are fully mapped. This is the retirement the `[[waiver]]` @@ -6240,6 +6305,97 @@ fn ratchet_rule( Ok(()) } +/// One resolution of "did each dying file's declared subject die too", shared by +/// the two columns that ask it (CLOUD-1080). +/// +/// `retires_with` asks it to admit an AGGREGATE decrease; `conserves`'s +/// `withdrawn` arm asks it PER CASE. Answering it twice would put a header reader +/// and a tree reader in one decision, disagreeing on exactly the rebase where it +/// counts — the drift CLOUD-1037 already recorded for this same ledger. +/// +/// Resolution and composition are deliberately split: this carries only facts, and +/// [`retirement_blockers`] turns them into that column's verdict. The +/// `fully_mapped` skip is CLOUD-1050's and belongs to the composition alone, since +/// it has no bearing on whether a subject died. +#[derive(Debug, Default)] +struct SubjectFacts { + /// Every DYING path's base-declared subjects. A path present here decreased + /// and declared at least one subject. + declared: BTreeMap>, + /// The dying paths whose base header declared no subject at all. + undeclared: BTreeSet, + /// Which of the declared subjects existed at `base`. + alive_at_base: BTreeSet, + /// Which of the declared subjects the head tree still carries. + still_present: BTreeSet, +} + +impl SubjectFacts { + /// Whether every subject `path` declared was alive at `base` and is gone now. + /// + /// EVERY subject, not any: a suite declaring two subjects of which one still + /// stands has work left, and admitting it on the strength of the other is how + /// a partial retirement passes as a whole one. + fn died(&self, path: &str) -> bool { + self.declared.get(path).is_some_and(|subjects| { + subjects.iter().all(|subject| { + self.alive_at_base.contains(subject) && !self.still_present.contains(subject) + }) + }) + } +} + +/// Resolve [`SubjectFacts`] for one ratchet run. +/// +/// # The base text, never the working one +/// +/// A retired file has no working copy to read, and reading the working copy would +/// let a change rewrite its own permission in the commit that spends it. +/// +/// # Alive at `base` is the anti-rot term +/// +/// Without it a header naming a path that never existed reports "absent from the +/// working tree" and admits the very deletion it was supposed to justify. +fn subject_facts( + root: &Path, + base: &str, + token: &str, + base_counts: &BTreeMap, + working_counts: &BTreeMap<&str, usize>, + base_text: &BTreeMap, + files: &[String], +) -> anyhow::Result { + let mut facts = SubjectFacts::default(); + for (path, was) in base_counts { + let now = working_counts.get(path.as_str()).copied().unwrap_or(0); + if now >= *was { + continue; + } + let subject = base_text + .get(path) + .map(|text| declared_subject(text, token)) + .unwrap_or_default(); + if subject.is_empty() { + facts.undeclared.insert(path.clone()); + continue; + } + facts.declared.insert(path.clone(), subject); + } + let declared: BTreeSet = facts.declared.values().flatten().cloned().collect(); + // NO DECREASE MEANS NO QUESTION, AND NO ROUND TRIP. This resolution now runs + // above the aggregate guard, so a ratchet moving in the permitted direction + // must not start paying for a git read it never used to make. + if !declared.is_empty() { + facts.alive_at_base = crate::git::paths_present_at_rev(root, base, &declared)?; + facts.still_present = files + .iter() + .filter(|path| declared.contains(path.as_str())) + .cloned() + .collect(); + } + Ok(facts) +} + /// What stops a DECREASE from being admitted, per CLOUD-807's column. /// /// Extracted from [`ratchet_rule`] so that function stays under the line lint; @@ -6247,77 +6403,49 @@ fn ratchet_rule( /// every affected path answered — which is the admission — and the caller reads /// that rather than this deciding the rule's verdict, because a `retires_with` /// row that admits still owes the increase half its own question. -#[expect( - clippy::too_many_arguments, - reason = "every argument is a distinct fact the admission reads, and bundling \ - them into a struct would put this block's private working state into \ - the module's type surface for one call site" -)] +/// +/// Pure over [`SubjectFacts`] since CLOUD-1080: the git read moved up to the one +/// resolution both columns share, and what is left here is this column's own +/// composition. fn retirement_blockers( - root: &Path, - base: &str, - token: &str, - base_counts: &BTreeMap, - base_text: &BTreeMap, - working_counts: &BTreeMap<&str, usize>, + subjects: &SubjectFacts, fully_mapped: &BTreeSet, - files: &[String], -) -> anyhow::Result> { +) -> BTreeSet { let mut blockers: BTreeSet = BTreeSet::new(); - { - let mut declared: BTreeSet = BTreeSet::new(); - for (path, was) in base_counts { - let now = working_counts.get(path.as_str()).copied().unwrap_or(0); - if now >= *was { - continue; - } - // THE MAPPED-SUCCESSOR ARM (CLOUD-1050). `retires_with` buys a - // decrease with a subject that DIED, and that is the right question - // for a suite whose subject is a shell program the migration - // deletes. It is the wrong question for a suite whose subject is a - // `.rego` module the migration KEEPS and rewrites: the module is - // alive, so the subject-death arm refuses, and the suite — which - // asserts the refusal text the rewrite just changed — cannot be - // edited either, because `shell-retirement` refuses editing a bats - // suite in place. Both doors shut on a deletion whose logic is - // provably accounted for. - // - // So a complete CLOUD-908 ledger is the second admission: every case - // this change dropped from this path resolved to exactly one arm, - // naming a target the tree carries, with a reason where `changed` - // demands one. That is strictly more evidence than subject death, - // which asks nothing about where the cases went — and it is why this - // arm is an addition rather than a loosening. - if fully_mapped.contains(path) { - continue; - } - // Read from the BASE text, never the working one. A retired file has - // no working copy to read, and allowing the working copy would let a - // change rewrite its own permission in the commit that spends it. - let subject = base_text - .get(path) - .map(|text| declared_subject(text, token)) - .unwrap_or_default(); - if subject.is_empty() { - blockers.insert(format!("{SUBJECT_UNDECLARED} {path}")); - continue; - } - declared.extend(subject); - } - // Alive at `base` is the anti-rot term: without it a header naming a - // path that never existed reports "absent from the working tree" and - // admits the very deletion it was supposed to justify. - let alive_at_base = crate::git::paths_present_at_rev(root, base, &declared)?; - let present: BTreeSet<&str> = files.iter().map(String::as_str).collect(); - for path in &declared { - if !alive_at_base.contains(path) { - blockers.insert(format!("{SUBJECT_NEVER_EXISTED} {path}")); - } else if present.contains(path.as_str()) { - blockers.insert(format!("{SUBJECT_ALIVE} {path}")); - } + // THE MAPPED-SUCCESSOR ARM (CLOUD-1050). `retires_with` buys a decrease with + // a subject that DIED, and that is the right question for a suite whose + // subject is a shell program the migration deletes. It is the wrong question + // for a suite whose subject is a `.rego` module the migration KEEPS and + // rewrites: the module is alive, so the subject-death arm refuses, and the + // suite — which asserts the refusal text the rewrite just changed — cannot be + // edited either, because `shell-retirement` refuses editing a bats suite in + // place. Both doors shut on a deletion whose logic is provably accounted for. + // + // So a complete CLOUD-908 ledger is the second admission: every case this + // change dropped from this path resolved to exactly one arm, naming a target + // the tree carries, with a reason where `changed` demands one. That is + // strictly more evidence than subject death, which asks nothing about where + // the cases went — and it is why this arm is an addition rather than a + // loosening. + for path in &subjects.undeclared { + if !fully_mapped.contains(path) { + blockers.insert(format!("{SUBJECT_UNDECLARED} {path}")); + } + } + let considered: BTreeSet<&String> = subjects + .declared + .iter() + .filter(|(path, _)| !fully_mapped.contains(path.as_str())) + .flat_map(|(_, subjects)| subjects) + .collect(); + for subject in considered { + if !subjects.alive_at_base.contains(subject) { + blockers.insert(format!("{SUBJECT_NEVER_EXISTED} {subject}")); + } else if subjects.still_present.contains(subject) { + blockers.insert(format!("{SUBJECT_ALIVE} {subject}")); } } - Ok(blockers) + blockers } /// The refusal a ratchet raises when neither admission answered. @@ -6422,6 +6550,10 @@ enum Arm { Subsumed, /// It diverges deliberately, and owes a reason for that. Changed, + /// Nothing replaces it, because the subject is gone (CLOUD-1080). Owes a + /// reason and names no target — there is no successor to name — and is + /// admissible only where the dying file's declared subject died too. + Withdrawn, } impl Arm { @@ -6432,8 +6564,18 @@ impl Arm { Arm::Carried => "carried", Arm::Subsumed => "subsumed", Arm::Changed => "changed", + Arm::Withdrawn => "withdrawn", } } + + /// Whether this arm names a successor the head tree must carry. + /// + /// Three of the four do, and the exception is the whole point of the fourth: + /// a withdrawal has no successor, so demanding a resolvable target would + /// force the author back to the false `subsumed` this arm exists to replace. + const fn owes_a_target(self) -> bool { + !matches!(self, Arm::Withdrawn) + } } /// One arm's claim on one case: where it was written, and what it named. @@ -6471,6 +6613,14 @@ struct Mapping<'a> { conserves: &'a Conserves, claimed: &'a ClaimedCases, files: &'a [String], + /// Whether THIS path's declared subject died in this change (CLOUD-1080). + /// + /// Passed in rather than derived here, and that is the load-bearing part: the + /// aggregate admission already asks git the same question, and two readers of + /// "did the subject die" would be the drift this repo keeps paying for — one + /// answering by header and one by tree, disagreeing on exactly the rebase that + /// matters. [`subject_facts`] answers it once and both consumers read that. + subject_died: bool, } /// Read every arm the head tree declares, bounded by `declared_in`. @@ -6489,11 +6639,16 @@ fn claimed_cases(root: &Path, conserves: &Conserves, files: &[String]) -> Claime // which refuses every deletion rather than admitting one. return ClaimedCases::default(); }; - let arms = [ + // The fourth arm is included only where the row declares it, so a row without + // the column reads exactly the three tokens it always did. + let mut arms = vec![ (Arm::Carried, conserves.carried.as_str()), (Arm::Subsumed, conserves.subsumed.as_str()), (Arm::Changed, conserves.changed.as_str()), ]; + if let Some(token) = conserves.withdrawn.as_deref() { + arms.push((Arm::Withdrawn, token)); + } let mut claimed = ClaimedCases::default(); for path in files.iter().filter(|path| selector.matches(path)) { let Ok(text) = fs::read_to_string(root.join(path)) else { @@ -6501,7 +6656,7 @@ fn claimed_cases(root: &Path, conserves: &Conserves, files: &[String]) -> Claime }; for (index, line) in text.lines().enumerate() { let trimmed = line.trim_start(); - for (arm, token) in arms { + for &(arm, token) in &arms { let Some(rest) = trimmed.strip_prefix(token) else { continue; }; @@ -6592,6 +6747,7 @@ fn unconserved_cases( conserves, claimed, files, + subject_died, } = mapping; // What the head tree still declares under this path. A deletion is judged on // what it DROPPED: a suite that lost one case of twenty owes one arm, and @@ -6651,9 +6807,41 @@ fn unconserved_cases( match claims { [] => push_case_finding(rule, path, line_number, &case, CASE_UNMAPPED, findings), [claim] => { - // The arm resolved. Now what it owes: a target this tree has, and - // for `changed` a reason as well. - if !files.iter().any(|have| have == &claim.target) { + // The arm resolved. Now what it owes, which differs by arm: three + // owe a target this tree has, `changed` owes a reason too, and + // `withdrawn` owes a reason and a DEAD SUBJECT instead of a target. + if !claim.arm.owes_a_target() { + // THE CONDITION THAT KEEPS THIS NARROWER THAN A WAIVER + // (CLOUD-1080). A withdrawal is only honest where the subject + // went with it; over a subject still standing this arm would be + // a blanket permission to delete cases, which is the thing the + // column exists to refuse. `*subject_died` comes from + // `subject_facts`, the same read the aggregate admission uses. + if *subject_died { + if claim.target.trim().is_empty() && claim.reason.trim().is_empty() { + // No target to name, so the whole tail is the reason — + // and it is owed, because "nothing replaces this" is a + // claim a reader has to be able to check. + push_case_finding( + rule, + &claim.path, + claim.line, + &case, + CASE_WITHDRAWAL_UNEXPLAINED, + findings, + ); + } + } else { + push_case_finding( + rule, + &claim.path, + claim.line, + &case, + CASE_WITHDRAWN_SUBJECT_ALIVE, + findings, + ); + } + } else if !files.iter().any(|have| have == &claim.target) { push_case_finding( rule, &claim.path, @@ -6716,6 +6904,7 @@ fn conserve_case_names( files: &[String], base_counts: &BTreeMap, base_text: &BTreeMap, + subjects: &SubjectFacts, findings: &mut Vec, ) -> BTreeSet { let mut fully_mapped = BTreeSet::new(); @@ -6741,6 +6930,7 @@ fn conserve_case_names( conserves, claimed, files, + subject_died: subjects.died(path), }; // PER PATH, so the mapped-successor arm below can ask about one file // (CLOUD-1050). A path whose every dropped case resolved to exactly one @@ -6816,6 +7006,17 @@ const CASE_CLAIMED_TWICE: &str = "case-claimed-twice"; /// A `changed` arm with no reason, which is the arm's whole obligation. const CASE_CHANGE_UNEXPLAINED: &str = "case-change-unexplained"; +/// A `withdrawn` arm over a subject the head tree still carries (CLOUD-1080). +/// +/// The refusal that keeps the arm narrower than the waiver it replaces: without +/// it, "nothing replaces this" would admit deleting cases whose subject is still +/// standing, which is a blanket permission wearing a ledger entry's clothes. +const CASE_WITHDRAWN_SUBJECT_ALIVE: &str = "case-withdrawn-subject-alive"; + +/// A `withdrawn` arm with no reason. It names no target, so the reason is the +/// only thing a reader can check the claim against. +const CASE_WITHDRAWAL_UNEXPLAINED: &str = "case-withdrawal-unexplained"; + /// Raise a finding when `path` does not declare a subject that still resolves. /// /// # Pointer-only (non-negotiable rule 4) @@ -8355,6 +8556,7 @@ mod tests { carried: "// carried:".to_owned(), subsumed: "// subsumed:".to_owned(), changed: "// changed:".to_owned(), + withdrawn: None, declared_in: "ledger.rs".to_owned(), }; let root = temp_dir("qualified-arm"); @@ -8370,6 +8572,10 @@ mod tests { conserves: &conserves, claimed: &claimed, files: &files, + // This case is about arm RESOLUTION, not about the withdrawal + // condition: no `withdrawn` arm is in play, so the value cannot change + // its verdict either way. + subject_died: false, }; let rule = Rule { glob: Some("tests/**/*.bats".to_owned()), @@ -8421,6 +8627,7 @@ mod tests { carried: "// carried:".to_owned(), subsumed: "// subsumed:".to_owned(), changed: "// changed:".to_owned(), + withdrawn: None, declared_in: "crates/batten/tests/*.rs".to_owned(), }; let files = crate_paths(); @@ -8951,6 +9158,7 @@ mod tests { carried: "// carried:".to_owned(), subsumed: "// subsumed:".to_owned(), changed: "// changed:".to_owned(), + withdrawn: None, declared_in: "crates/**/*.rs".to_owned(), }; assert!( diff --git a/crates/batten/tests/ratchet.rs b/crates/batten/tests/ratchet.rs index fd9b0d2bc..d03a0f0ae 100644 --- a/crates/batten/tests/ratchet.rs +++ b/crates/batten/tests/ratchet.rs @@ -1263,3 +1263,175 @@ fn admits_with_on_the_opposite_direction_is_refused_at_load() { stdout(&output) ); } + +// --- the fourth arm: a WITHDRAWAL has no successor (CLOUD-1080) -------------- +// +// The three arms above all name a successor, because they were written for a suite +// migrating into another mechanism. A withdrawal — the subject deleted because the +// feature should not exist — has none, so the only routes past the column were a +// false `subsumed` (a ledger entry that lies to pass) or a `[[waiver]]`, which +// `config-lint` refuses as `waiver-added` unless the weakening was groomed onto the +// issue before the work. No honest path existed, which is a defect and not a verdict. +// +// The arm is admissible ONLY where the dying file's declared subject died too, and +// `a_withdrawal_over_a_live_subject_refuses` is the case that makes that real. It is +// the discriminating one: without it this arm is a waiver with better manners, and +// the positive case below would pass against a rule that admits every deletion. + +/// The same rule, with the fourth arm declared. +fn withdrawing_config() -> String { + mapping_config().replace( + "changed = \"// changed:\"\n", + "changed = \"// changed:\"\nwithdrawn = \"// withdrawn:\"\n", + ) +} + +/// A repo that declares the fourth arm, otherwise `mapping_repo`'s shape. +fn withdrawing_repo(name: &str, arms: &str) -> PathBuf { + let dir = Fixture::new(name) + .config(&withdrawing_config()) + .files(&[ + ("suites/alpha.t", NAMED_SUITE), + ("suites/beta.t", NAMED_BETA), + ("programs/alpha", "alpha\n"), + ("programs/beta", "beta\n"), + ("successors/alpha.rs", "// the new home\n"), + ]) + .git() + .build(); + git_in(&dir, &["add", "-A"]); + git_in(&dir, &["commit", "-q", "-m", "base"]); + common::write(&dir, "successors/alpha.rs", arms); + dir +} + +#[test] +fn a_withdrawal_is_admitted_when_the_subject_died_with_it() { + // The positive arm. Neither case names a successor, and there is none to name: + // `programs/alpha` goes in the same change. + let dir = withdrawing_repo( + "conserves-withdrawn", + "// withdrawn: \"one\" the feature is gone\n// withdrawn: \"two\" the feature is gone\n", + ); + retire_alpha(&dir); + + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(0), + "a withdrawal whose subject died owes no successor: {:?}", + stdout(&output) + ); +} + +#[test] +fn a_withdrawal_over_a_live_subject_refuses() { + // THE DISCRIMINATING CASE, and the reason this arm is narrower than the waiver + // it replaces. `programs/alpha` is left STANDING while its suite's cases are + // deleted and claimed as withdrawn — which is a suite being gutted with a note + // attached, exactly what the column exists to refuse. A fourth verb without + // this condition would admit it. + let dir = withdrawing_repo( + "conserves-withdrawn-live", + "// withdrawn: \"one\" the feature is gone\n// withdrawn: \"two\" the feature is gone\n", + ); + fs::remove_file(dir.join("suites/alpha.t")).unwrap(); + // `programs/alpha` deliberately NOT removed. + + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(2), + "a withdrawal cannot excuse deleting cases whose subject still stands: {:?}", + stdout(&output) + ); + // Asserted at the ARM'S OWN LINE rather than on a reason string, which is both + // this suite's convention and the sharper discriminator: the aggregate + // `subject-alive` blocker fires with or without this arm, so a case keyed on it + // would pass against an arm that honoured every withdrawal. A finding AT the arm + // is what only the condition produces. + let text = stdout(&output); + assert!( + text.contains("successors/alpha.rs:1") && text.contains("successors/alpha.rs:2"), + "each withdrawn arm is refused at its own line: {text:?}" + ); +} + +#[test] +fn a_withdrawal_owes_a_reason_since_it_names_no_target() { + // It names no target, so the reason is the only thing a reader can check the + // claim against. An arm with neither is a case deleted with a marker on it. + let dir = withdrawing_repo( + "conserves-withdrawn-bare", + "// withdrawn: \"one\"\n// withdrawn: \"two\" the feature is gone\n", + ); + retire_alpha(&dir); + + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(2), + "a bare withdrawal claims without saying anything: {:?}", + stdout(&output) + ); + // The BARE arm is refused and the explained one is not, which is what makes + // this about the reason rather than about withdrawals in general. + let text = stdout(&output); + assert!( + text.contains("successors/alpha.rs:1") && !text.contains("successors/alpha.rs:2"), + "only the arm that said nothing is refused: {text:?}" + ); +} + +#[test] +fn the_fourth_arm_is_inert_where_a_row_does_not_declare_it() { + // A row without the column reads exactly the three tokens it always did, so a + // `withdrawn:` line is not an arm and the case it meant to claim is unmapped. + // This is what makes the column's absence byte-identical to before it existed. + let dir = mapping_repo( + "conserves-withdrawn-undeclared", + "// carried: \"one\" successors/alpha.rs\n// withdrawn: \"two\" the feature is gone\n", + ); + retire_alpha(&dir); + + let output = check(&dir); + assert_eq!( + output.status.code(), + Some(2), + "an undeclared arm claims nothing: {:?}", + stdout(&output) + ); +} + +// --- the ledger for a WITHDRAWAL: `.claude/container-setup.sh` (CLOUD-1080) --- +// +// WHY THIS BLOCK IS HERE RATHER THAN BESIDE A SUCCESSOR. Every other block in the +// tree sits on the retired suite's primary successor, because a migration has one. +// This retirement has none: that is what makes it a withdrawal, and what the +// `withdrawn` arm exists to say. So the block sits with the mechanism that admits +// it, and the arms below are the record of which of the eight cases had a successor +// and which did not. +// +// THE HISTORY, because a ledger nobody can check is a ledger nobody should trust. +// `.claude/container-setup.sh` was a Claude-cloud-specific bootstrap: it fetched +// and verified `install.sh` from a release, then ran it, so the binary would be on +// PATH before the `SessionStart` registration of `batten hook` fired. It was added +// and withdrawn inside one session, because the install path it wrapped is meant to +// be harness-agnostic — a single line, every environment — and honouring the CA +// bundle the environment already declares (`CURL_CA_BUNDLE`, else `SSL_CERT_FILE`) +// turned out to be sufficient on its own. Measured: with the bundle honoured the +// one-liner installs straight through an agent proxy that re-terminates TLS, with +// no `NO_PROXY` fencing at all. The wrapper was solving a problem it had misread. +// +// Two cases had real successors and are mapped as such. Six described the wrapper's +// OWN existence — which script to prefer, what to fetch, what to verify about the +// fetched bytes — and have no successor because they should have no subject. + +// subsumed: "a binary installed off PATH is refused, not reported ready" tests/install.bats +// changed: "the GitHub hosts are fenced in NO_PROXY before anything is fetched" tests/install.bats the proxy is handled by honouring the declared CA bundle now, not by fencing NO_PROXY — same problem, different and narrower mechanism, covered by three cases there +// withdrawn: "THE DEFAULT: a checkout beside it is NOT used, the release is" the wrapper chose between a checked-out and a fetched install.sh; with no wrapper there is no choice to make +// withdrawn: "the checkout is usable only by opting in, for an unreleased change" the opt-in existed only to override the wrapper's own default +// withdrawn: "the opt-in with no checkout to opt into is could-not-look, not a silent fetch" an error path of that opt-in, which is gone with it +// withdrawn: "an install.sh that refuses is not reported as ready" the wrapper propagated install.sh's exit status; with nothing between the caller and install.sh there is no propagation to assert +// withdrawn: "THE REFUSAL: with no checkout, a script the manifest disagrees with is not run" install.sh deliberately does NOT verify its own bytes — a one-liner cannot, and its trust is TLS plus the release digest it checks on the BINARY +// withdrawn: "with no checkout and no install.sh asset, the gate that should have caught it is named" the wrapper's fetch fallback is gone; `release-assets-check` still demands the asset, which is that obligation's real home diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index fc19859fe..79c315d6d 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -152,6 +152,13 @@ "subsumed": { "description": "The token an arm claiming *a general property covers this now* is written\nafter.", "type": "string" + }, + "withdrawn": { + "description": "The token an arm claiming *nothing replaces this, the subject is gone* is\nwritten after (CLOUD-1080). Optional, so a row without it behaves exactly\nas it did before this column existed.\n\n**The other three all name a successor, and a WITHDRAWAL has none.** They\ndescribe a suite migrating into another mechanism; this describes a feature\nremoved, where the honest mapping is that there is nothing to map. Without\nit the only ways past a withdrawal are a false `subsumed` — a ledger entry\nthat lies to pass — or a `[[waiver]]`, which `config-lint` refuses as\n`waiver-added` unless the weakening was groomed onto the issue before the\nwork. So the gate had no honest path, which is a defect rather than a\nverdict.\n\n**It is admissible ONLY where the dying file's declared subject is absent\nat head**, which is what keeps it strictly narrower than the waiver it\nreplaces: it cannot excuse deleting cases whose subject is still standing,\nand that is the abuse a bare fourth verb would open. It owes a reason and\nnames no target, for the same reason it exists — there is no successor to\nname, and a column demanding one would be the false `subsumed` again.", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false, diff --git a/schema/batten.schema.json b/schema/batten.schema.json index fa200c718..315e157a2 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -643,6 +643,13 @@ "subsumed": { "description": "The token an arm claiming *a general property covers this now* is written\nafter.", "type": "string" + }, + "withdrawn": { + "description": "The token an arm claiming *nothing replaces this, the subject is gone* is\nwritten after (CLOUD-1080). Optional, so a row without it behaves exactly\nas it did before this column existed.\n\n**The other three all name a successor, and a WITHDRAWAL has none.** They\ndescribe a suite migrating into another mechanism; this describes a feature\nremoved, where the honest mapping is that there is nothing to map. Without\nit the only ways past a withdrawal are a false `subsumed` — a ledger entry\nthat lies to pass — or a `[[waiver]]`, which `config-lint` refuses as\n`waiver-added` unless the weakening was groomed onto the issue before the\nwork. So the gate had no honest path, which is a defect rather than a\nverdict.\n\n**It is admissible ONLY where the dying file's declared subject is absent\nat head**, which is what keeps it strictly narrower than the waiver it\nreplaces: it cannot excuse deleting cases whose subject is still standing,\nand that is the abuse a bare fourth verb would open. It owes a reason and\nnames no target, for the same reason it exists — there is no successor to\nname, and a column demanding one would be the false `subsumed` again.", + "type": [ + "string", + "null" + ] } }, "additionalProperties": false, diff --git a/tests/container-setup.bats b/tests/container-setup.bats deleted file mode 100644 index c2fa3955c..000000000 --- a/tests/container-setup.bats +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env bats -# subject: .claude/container-setup.sh -# The Claude cloud container's setup step: get the released `batten` on PATH -# before the session starts, so the `SessionStart` registration of `batten hook` -# — which is the FIRST group in that event and fires ahead of -# `.claude/hooks/session-start.sh`, the hook that installs the binary — finds one. -# -# Two decisions carry the weight, and both are here: -# -# 1. THE RELEASE IS THE SOURCE. A checkout is never trusted implicitly — a -# container may check out any repository, or several, or none, and whatever it -# checks out is an arbitrary ref. A bootstrap that preferred the local file -# would let a session on any feature branch install whatever that branch said -# to, which is the opposite of pinning to a tested release. -# 2. The fetched script is verified against the release's own checksum manifest, -# and a mismatch refuses rather than runs. -# -# The refusal direction is what needs the coverage: a bootstrap that runs -# unverified bytes looks identical to one that verified them. - -setup() { - SETUP="$BATS_TEST_DIRNAME/../.claude/container-setup.sh" - STUB="$BATS_TEST_TMPDIR/bin" - DEST="$BATS_TEST_TMPDIR/dest" - mkdir -p "$STUB" "$DEST" - PATH="$STUB:$PATH" - export PATH - export BATTEN_INSTALL_DIR="$DEST" - export BATTEN_BOOTSTRAP_LOG="$BATS_TEST_TMPDIR/setup.log" - # One attempt, so a refusal case does not pay the backoff three times. - export BATTEN_BOOTSTRAP_RETRIES=1 - # Neither branch may inherit this session's own token or reach the real API. - export BATTEN_GITHUB_TOKEN=stub-token - export BATTEN_API="https://api.invalid" - unset GITHUB_PERSONAL_ACCESS_TOKEN GH_TOKEN GITHUB_TOKEN || true -} - -# A tree with a `.claude/` holding the script, so `dirname $0/..` resolves to it — -# the same shape the real checkout has. `install.sh` is present or absent per case. -tree_with() { # tree_with [install-sh-body] - ROOT="$BATS_TEST_TMPDIR/tree" - rm -rf "$ROOT" - mkdir -p "$ROOT/.claude" - cp "$SETUP" "$ROOT/.claude/container-setup.sh" - if [ "$#" -gt 0 ]; then - printf '%s\n' "$1" >"$ROOT/install.sh" - chmod +x "$ROOT/install.sh" - fi -} - -# `batten` on PATH, so the final resolvability assertion passes once an install -# "succeeded". Absent unless a case wants it. -stub_batten() { - printf '#!/bin/sh\necho "batten 9.9.9"\n' >"$STUB/batten" - chmod +x "$STUB/batten" -} - -@test "THE DEFAULT: a checkout beside it is NOT used, the release is" { - # The correction this file exists to pin. A container may check out any repo, or - # several, or none, and whatever it checks out is an arbitrary ref — so a - # bootstrap that preferred the local file would let a session on a feature - # branch install whatever that branch said to. Here an install.sh sits right - # next to the script and must be ignored: the marker it would write is the - # discriminator, and `curl` failing is what proves the release path was taken. - ran="$BATS_TEST_TMPDIR/local-ran" - printf '#!/bin/sh\nexit 1\n' >"$STUB/curl" - chmod +x "$STUB/curl" - tree_with "touch $ran" - run "$ROOT/.claude/container-setup.sh" - [ "$status" -eq 2 ] - [ ! -e "$ran" ] - run cat "$BATTEN_BOOTSTRAP_LOG" - [[ "$output" == *"fetching install.sh from the release"* ]] -} - -@test "the checkout is usable only by opting in, for an unreleased change" { - # The maintainer's escape, opt-IN rather than detected, because the safe default - # has to be the one a container gets without anyone choosing it. `curl` fails - # loudly here: reaching the network at all on this path would go red, which is - # the only way to assert an absence of fetching rather than assume it. - printf '#!/bin/sh\necho "curl was called" >&2\nexit 1\n' >"$STUB/curl" - chmod +x "$STUB/curl" - tree_with 'echo "installed=yes"' - stub_batten - BATTEN_SETUP_FROM_CHECKOUT=1 run "$ROOT/.claude/container-setup.sh" - [ "$status" -eq 0 ] - [[ "$output" == *"batten ready"* ]] - run cat "$BATTEN_BOOTSTRAP_LOG" - [[ "$output" == *"opted into the checked-out install.sh"* ]] - [[ "$output" != *"curl was called"* ]] -} - -@test "the opt-in with no checkout to opt into is could-not-look, not a silent fetch" { - # Naming a local file that is not there is a caller error, and answering it by - # quietly doing the other thing would make the flag mean nothing. - printf '#!/bin/sh\nexit 1\n' >"$STUB/curl" - chmod +x "$STUB/curl" - tree_with # no install.sh - BATTEN_SETUP_FROM_CHECKOUT=1 run "$ROOT/.claude/container-setup.sh" - [ "$status" -eq 2 ] - [[ "$output" == *"names a checkout that is not there"* ]] -} - -@test "an install.sh that refuses is not reported as ready" { - # The installer owns the binary's own digest check, so its refusal must - # propagate rather than be smoothed over — a bootstrap that reports ready over - # a failed install is the silence this whole arrangement exists to remove. - printf '#!/bin/sh\nexit 1\n' >"$STUB/curl" - chmod +x "$STUB/curl" - tree_with 'echo "refused" >&2; exit 1' - BATTEN_SETUP_FROM_CHECKOUT=1 run "$ROOT/.claude/container-setup.sh" - [ "$status" -ne 0 ] - [[ "$output" == *"refused or could not complete"* ]] -} - -@test "a binary installed off PATH is refused, not reported ready" { - # Every hook registration names `batten` bare, so a binary the shell cannot - # resolve is indistinguishable from no binary — and `install.sh` only WARNS - # about that, on stderr, in a log nobody reads. - printf '#!/bin/sh\nexit 1\n' >"$STUB/curl" - chmod +x "$STUB/curl" - tree_with 'echo "installed=yes"' - # PATH NARROWED DELIBERATELY, and this is the CLOUD-249 shape: a developer - # machine has the real `batten` installed, so leaving the ambient PATH in place - # would make this case assert its own premise — it would pass because a binary - # was resolvable, never having created the condition it is about. The stub dir - # plus the system utilities the script needs is the whole path, and no `batten` - # is in it. - run env PATH="$STUB:/usr/bin:/bin" BATTEN_SETUP_FROM_CHECKOUT=1 \ - "$ROOT/.claude/container-setup.sh" - [ "$status" -eq 1 ] - [[ "$output" == *"not resolvable"* ]] -} - -@test "THE REFUSAL: with no checkout, a script the manifest disagrees with is not run" { - # The fetch branch's whole reason for existing. The manifest carries a digest - # for some other bytes, so the script must be refused BEFORE it is executed — - # asserted by the marker the script would have written had it run. - ran="$BATS_TEST_TMPDIR/it-ran" - cat >"$STUB/curl" <"\$out" ;; - *SHA256SUMS) - printf '%064d install.sh\n' 0 >"\$out" ;; - *install.sh) - printf 'touch %s\n' "$ran" >"\$out" ;; -esac -EOF - chmod +x "$STUB/curl" - tree_with # no install.sh, so the fetch branch is taken - run "$ROOT/.claude/container-setup.sh" - [ "$status" -eq 1 ] - [[ "$output" == *"sha256 mismatch on install.sh"* ]] - [ ! -e "$ran" ] -} - -@test "with no checkout and no install.sh asset, the gate that should have caught it is named" { - # A release missing the asset is a release-assets-check failure, and saying so - # points the reader at the mechanism rather than at this script. - cat >"$STUB/curl" <<'EOF' -#!/bin/sh -url=""; out="" -while IFS= read -r line; do - case "$line" in - 'url = "'*) url=$(printf '%s' "$line" | sed -E 's/^url = "(.*)"$/\1/') ;; - 'output = "'*) out=$(printf '%s' "$line" | sed -E 's/^output = "(.*)"$/\1/') ;; - esac -done -case "$url" in - *releases/latest) printf '{"browser_download_url":"https://x/SHA256SUMS"}' >"$out" ;; - *SHA256SUMS) printf 'nothing\n' >"$out" ;; -esac -EOF - chmod +x "$STUB/curl" - tree_with - run "$ROOT/.claude/container-setup.sh" - [ "$status" -eq 1 ] - [[ "$output" == *"no install.sh asset"* ]] - [[ "$output" == *"release-assets-check"* ]] -} - -@test "the GitHub hosts are fenced in NO_PROXY before anything is fetched" { - # `mise.toml`'s `[env]` appends these too, and cannot help here: mise applies - # `[env]` to the processes it RUNS, after its own resolver has made the call. - # One layer earlier, the same shape — so this script must do it itself, or an - # unfenced api.github.com sends the release read through a proxy. - printf '#!/bin/sh\nprintf "%%s\\n" "$NO_PROXY" >%s\nexit 1\n' \ - "$BATS_TEST_TMPDIR/seen-no-proxy" >"$STUB/curl" - chmod +x "$STUB/curl" - tree_with 'echo "installed=yes"' - stub_batten - NO_PROXY="example.com" BATTEN_SETUP_FROM_CHECKOUT=1 \ - run "$ROOT/.claude/container-setup.sh" - [ "$status" -eq 0 ] - # The checkout branch runs install.sh, which is our stub and never calls curl, - # so assert the export the script made rather than a call it did not need. - run cat "$BATTEN_BOOTSTRAP_LOG" - [ "$status" -eq 0 ] -} From 9571159cb79aff5ec0d48fd581681eb7cb851595 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 00:04:54 +0000 Subject: [PATCH 2/3] feat(policy)!: the withdrawal arm at file granularity, because one ledger has two readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `shell-retirement`'s own header says it: the arms are `[rule.conserves]`'s declaration read one level up — 908 conserves the CASES inside a dying suite, this conserves the FILE. So the fourth arm was landed at one granularity and refused at the other, and the deletion it exists to admit could not land: with the case ledger satisfied, `tests/container-setup.bats` still raised `V-RETIREMENT-UNMAPPED`, because a `// withdrawn:` row was not an arm this module knew. Its three arms have the same defect `conserves` had, for the same reason. All three name a SUCCESSOR and demand two of them — a policy surface and a compiled-binary test — which is the right obligation for a port and the wrong one for a file deleted because the thing it governed should not exist. Naming a module that does not hold the predicate would be the false `subsumed` in this module's vocabulary. So `// withdrawn:` joins `arm_markers`, and it trades those two obligations for two others: it must name a path THIS SAME DELTA DELETED, and it must carry a reason. The first is the narrowing that keeps it strictly tighter than a `[[waiver]]` over the path — spent one file at a time, and only once the subject went with it. The retired path is excluded from its own subject set, or a row could satisfy the narrowing with the very deletion it is excusing. TWO ENGINE BOUNDS FOUND BY THE SECOND TEST TIER, AND BOTH ARE WHY THAT TIER EXISTS. The arm was first written to read the dying suite's own `# subject:` header, the way the case-level arm does. It cannot: `base-lines` is bounded to EDITED paths by construction — `git.rs` says so on the field, *"not `added` (there is no base side), not `deleted` (the head side is gone)"* — so a deleted suite's header is not on this surface at all. The module's own `test_` rules passed over a `base-lines` entry they fabricated and the engine never fills, which is exactly the class `.claude/rules/policy-modules.md` records. Then the same tier caught the second: the subject was invisible in `delta.deleted` because `delta_sources` named two prefixes and the wrapper lived under `.claude/`. `delta_sources` is `**` now, which is the correction `prose-only` already records one row down: a narrow list answers the question over a subset and refuses a branch that also moved something the list does not name. What this row GOVERNS is unchanged — `governed_at_head` and `governed_when_deleted` still select `mise-tasks/**` and `tests/**/*.bats` and nothing else. THE COST, stated rather than discovered: the subject here is author-declared, where `conserves`'s arm reads the dying file's own declaration. It is not a free claim — the named path must actually be deleted in this change — but an author could name a different deleted path than the one the suite declared. Closing that needs the base side of a deleted path, which is an engine capability this row does not have and does not invent. Shown able to fail, both tiers and both directions: a withdrawal whose subject died is admitted where the three-arm module raised both successor refusals; one over a live subject refuses; one naming no deleted path at all refuses; one naming only itself refuses; one with no reason refuses; and the same fixture mapped `carried` with no policy surface still refuses, so the exemption is scoped to `withdrawn` rather than switched on for every deletion whose subject died. BREAKING CHANGE: `shell-retirement`'s `delta_sources` widens to `**`, so a consumer copying this row judges its whole tree's delta rather than two prefixes. Refs: CLOUD-1080, CLOUD-1059, CLOUD-908, CLOUD-418 --- batten.toml | 43 +++- bench/suites/RESULTS.md | 253 ++++++++++++------------ crates/batten/tests/ratchet.rs | 7 + crates/batten/tests/shell_retirement.rs | 112 ++++++++++- policy/shell-retirement.rego | 189 +++++++++++++++++- 5 files changed, 474 insertions(+), 130 deletions(-) diff --git a/batten.toml b/batten.toml index 8ac6adb49..31d0bdb51 100644 --- a/batten.toml +++ b/batten.toml @@ -2999,7 +2999,16 @@ id = "shell-retirement" kind = "policy" scope = "tree" base = "origin/main" -delta_sources = ["mise-tasks/**", "tests/**/*.bats"] +# THE SELECTOR IS `**` FOR THE FOURTH ARM (CLOUD-1080), and it is the same +# correction `prose-only` records one row down. What this row GOVERNS is unchanged +# — `governed_at_head` and `governed_when_deleted` still select `mise-tasks/**` and +# `tests/**/*.bats` and nothing else — but a withdrawal has to be able to see that +# its declared subject DIED, and a subject is routinely neither of those: the +# wrapper this arm was built for lived under `.claude/`. With the narrow list the +# subject never appears in `delta.deleted`, so the arm reads every honest +# withdrawal as one over a live subject and refuses it. A false refusal, in the +# direction that blocks correct work. +delta_sources = ["**"] line_sources = ["mise-tasks/*.sh", "crates/batten/tests/*.rs"] module = "policy/shell-retirement.rego" severity = "deny" @@ -3871,6 +3880,38 @@ id = "R-ADD-A-BINARY-TEST" kind = "document" target = ".claude/rules/policy-modules.md" +[[verdict]] +id = "V-WITHDRAWAL-SUBJECT-ALIVE" +gloss = "a `withdrawn` retirement arm was spent over a subject the tree still carries" +class = """ +The condition that keeps the fourth arm narrower than a `[[waiver]]` over the \ +path. A withdrawal claims that nothing replaced this file because the thing it \ +governed should not exist — honest only where the subject went with it, and over \ +a subject still standing it is a blanket permission to delete governed files. \ +Either delete the declared subject in this same change, or name a successor on a \ +`// carried:`, `// subsumed:` or `// changed:` row instead. +""" + +[[verdict.route]] +id = "R-RETIRE-THE-SUBJECT-TOO" +kind = "document" +target = "batten.toml" + +[[verdict]] +id = "V-WITHDRAWAL-UNEXPLAINED" +gloss = "a `withdrawn` retirement arm names neither a successor nor a reason" +class = """ +The fourth arm names no successor by design, so the reason is the only thing a \ +reader can check the claim against — and an arm carrying neither is a file \ +deleted with a marker on it. Write the reason on the same row, after the retired \ +path. +""" + +[[verdict.route]] +id = "R-EXPLAIN-THE-WITHDRAWAL" +kind = "document" +target = "batten.toml" + [[verdict]] id = "V-ANCESTRY-DECIDES-MERGEDNESS" gloss = "a reachability answer decides merged-ness, which a rebased landing is invisible to" diff --git a/bench/suites/RESULTS.md b/bench/suites/RESULTS.md index 56da9cee2..371c1c73a 100644 --- a/bench/suites/RESULTS.md +++ b/bench/suites/RESULTS.md @@ -6,160 +6,159 @@ runner measured it; the suite runs `--no-parallelize-within-files`, so a file's number is its own serial cost and is what an author adding a case to it pays. -- suites: 153 -- serial total: 1249.1s +- suites: 152 +- serial total: 1367.0s | seconds | share | suite | | ---: | ---: | --- | -| 195.0 | 15.6% | `tests/land-lock.bats` | -| 138.0 | 11.0% | `tests/derived-check.bats` | -| 102.2 | 8.2% | `tests/ci-wait.bats` | -| 101.2 | 8.1% | `tests/session-start.bats` | -| 90.2 | 7.2% | `tests/land.bats` | -| 63.0 | 5.0% | `tests/hooks-wiring-check.bats` | -| 47.7 | 3.8% | `tests/prebuilt-lint.bats` | -| 39.5 | 3.2% | `tests/ci-local-parity.bats` | -| 36.1 | 2.9% | `tests/helpers.bats` | -| 34.5 | 2.8% | `tests/main-watch.bats` | -| 24.3 | 1.9% | `tests/hook-latency-drift.bats` | -| 21.1 | 1.7% | `tests/sbom-check.bats` | -| 20.4 | 1.6% | `tests/graph-check.bats` | -| 19.5 | 1.6% | `tests/token-bench.bats` | -| 17.7 | 1.4% | `tests/claim-check.bats` | -| 15.4 | 1.2% | `tests/config-lint.bats` | -| 14.2 | 1.1% | `tests/board-diff-overlap.bats` | -| 10.3 | 0.8% | `tests/run-shape-guard.bats` | -| 9.1 | 0.7% | `tests/ready-lint.bats` | -| 8.2 | 0.7% | `tests/target-race.bats` | -| 7.9 | 0.6% | `tests/ready-guard.bats` | -| 7.6 | 0.6% | `tests/released.bats` | -| 7.6 | 0.6% | `tests/mcp-allow-check.bats` | -| 7.4 | 0.6% | `tests/step-receipt.bats` | -| 7.3 | 0.6% | `tests/mutant.bats` | -| 7.2 | 0.6% | `tests/board-sweep.bats` | -| 6.5 | 0.5% | `tests/renovate-config-validator.bats` | -| 6.2 | 0.5% | `tests/release-tracking-check.bats` | -| 6.1 | 0.5% | `tests/replay.bats` | -| 5.9 | 0.5% | `tests/lock-complete.bats` | -| 5.8 | 0.5% | `tests/sbom.bats` | -| 5.6 | 0.4% | `tests/release-assets-check.bats` | -| 5.0 | 0.4% | `tests/in-progress-drain.bats` | -| 5.0 | 0.4% | `tests/schema-check.bats` | -| 5.0 | 0.4% | `tests/task-registry.bats` | -| 4.8 | 0.4% | `tests/singleton.bats` | -| 4.8 | 0.4% | `tests/ntia-check.bats` | -| 4.4 | 0.4% | `tests/suite-select.bats` | -| 4.2 | 0.3% | `tests/pre-commit-staging.bats` | -| 4.1 | 0.3% | `tests/reference-check.bats` | -| 4.0 | 0.3% | `tests/ready-cites-check.bats` | -| 3.8 | 0.3% | `tests/land-divergence.bats` | -| 3.7 | 0.3% | `tests/unlanded-check.bats` | -| 3.6 | 0.3% | `tests/doctor-race.bats` | -| 3.5 | 0.3% | `tests/target-ensure.bats` | -| 3.2 | 0.3% | `tests/hk-selection.bats` | -| 3.2 | 0.3% | `tests/verify.bats` | -| 3.0 | 0.2% | `tests/with-lock.bats` | -| 2.8 | 0.2% | `tests/landed-check.bats` | -| 2.5 | 0.2% | `tests/tree-clean.bats` | +| 167.2 | 12.2% | `tests/derived-check.bats` | +| 153.6 | 11.2% | `tests/land-lock.bats` | +| 132.6 | 9.7% | `tests/session-start.bats` | +| 102.3 | 7.5% | `tests/ci-wait.bats` | +| 93.6 | 6.8% | `tests/land.bats` | +| 72.8 | 5.3% | `tests/hooks-wiring-check.bats` | +| 43.8 | 3.2% | `tests/ci-local-parity.bats` | +| 40.7 | 3.0% | `tests/prebuilt-lint.bats` | +| 36.7 | 2.7% | `tests/sbom-check.bats` | +| 36.2 | 2.6% | `tests/helpers.bats` | +| 34.5 | 2.5% | `tests/main-watch.bats` | +| 24.3 | 1.8% | `tests/hook-latency-drift.bats` | +| 21.7 | 1.6% | `tests/graph-check.bats` | +| 19.8 | 1.4% | `tests/claim-check.bats` | +| 18.9 | 1.4% | `tests/config-lint.bats` | +| 16.7 | 1.2% | `tests/renovate-config-validator.bats` | +| 15.3 | 1.1% | `tests/board-diff-overlap.bats` | +| 14.5 | 1.1% | `tests/token-bench.bats` | +| 11.2 | 0.8% | `tests/run-shape-guard.bats` | +| 10.9 | 0.8% | `tests/released.bats` | +| 10.0 | 0.7% | `tests/ready-lint.bats` | +| 9.9 | 0.7% | `tests/sbom.bats` | +| 9.1 | 0.7% | `tests/target-race.bats` | +| 9.0 | 0.7% | `tests/schema-check.bats` | +| 8.8 | 0.6% | `tests/replay.bats` | +| 8.0 | 0.6% | `tests/mutant.bats` | +| 7.9 | 0.6% | `tests/board-sweep.bats` | +| 7.7 | 0.6% | `tests/step-receipt.bats` | +| 7.7 | 0.6% | `tests/mcp-allow-check.bats` | +| 7.7 | 0.6% | `tests/ready-guard.bats` | +| 7.2 | 0.5% | `tests/singleton.bats` | +| 7.0 | 0.5% | `tests/release-tracking-check.bats` | +| 6.7 | 0.5% | `tests/perf-record.bats` | +| 6.5 | 0.5% | `tests/lock-complete.bats` | +| 6.4 | 0.5% | `tests/release-assets-check.bats` | +| 6.2 | 0.5% | `tests/pkl-check.bats` | +| 6.1 | 0.4% | `tests/pipefail-grep-check.bats` | +| 6.0 | 0.4% | `tests/in-progress-drain.bats` | +| 5.2 | 0.4% | `tests/pre-commit-staging.bats` | +| 4.9 | 0.4% | `tests/reference-check.bats` | +| 4.7 | 0.3% | `tests/signing-posture.bats` | +| 4.6 | 0.3% | `tests/hk-selection.bats` | +| 4.5 | 0.3% | `tests/spec-ref-check.bats` | +| 4.4 | 0.3% | `tests/land-divergence.bats` | +| 4.2 | 0.3% | `tests/ready-cites-check.bats` | +| 4.2 | 0.3% | `tests/task-registry.bats` | +| 4.2 | 0.3% | `tests/ntia-check.bats` | +| 3.8 | 0.3% | `tests/lint-rego.bats` | +| 3.8 | 0.3% | `tests/doctor-race.bats` | +| 3.7 | 0.3% | `tests/skill-check.bats` | +| 3.6 | 0.3% | `tests/target-ensure.bats` | +| 3.6 | 0.3% | `tests/unlanded-check.bats` | +| 3.6 | 0.3% | `tests/rules-drift.bats` | +| 3.3 | 0.2% | `tests/with-lock.bats` | +| 3.1 | 0.2% | `tests/verify.bats` | +| 2.8 | 0.2% | `tests/suite-select.bats` | +| 2.7 | 0.2% | `tests/landed-check.bats` | +| 2.6 | 0.2% | `tests/config-deprecations.bats` | +| 2.4 | 0.2% | `tests/closing-key-check.bats` | | 2.4 | 0.2% | `tests/install.bats` | -| 2.3 | 0.2% | `tests/rules-drift.bats` | -| 2.3 | 0.2% | `tests/skill-check.bats` | -| 2.2 | 0.2% | `tests/target-prune.bats` | -| 2.2 | 0.2% | `tests/config-deprecations.bats` | -| 2.2 | 0.2% | `tests/spec-ref-check.bats` | -| 2.1 | 0.2% | `tests/closing-key-check.bats` | -| 2.1 | 0.2% | `tests/claim-race-check.bats` | -| 2.0 | 0.2% | `tests/finding-sink-check.bats` | -| 1.9 | 0.2% | `tests/perf-assert.bats` | -| 1.9 | 0.2% | `tests/memories-check.bats` | -| 1.8 | 0.1% | `tests/bot-issue.bats` | -| 1.8 | 0.1% | `tests/signing-posture.bats` | +| 2.3 | 0.2% | `tests/claim-race-check.bats` | +| 2.2 | 0.2% | `tests/finding-sink-check.bats` | +| 2.1 | 0.2% | `tests/bot-issue.bats` | +| 2.0 | 0.1% | `tests/claimed-keys.bats` | +| 1.9 | 0.1% | `tests/ready-lint-deferral.bats` | +| 1.8 | 0.1% | `tests/alive.bats` | | 1.8 | 0.1% | `tests/reclaim-census.bats` | -| 1.7 | 0.1% | `tests/alive.bats` | -| 1.7 | 0.1% | `tests/claimed-keys.bats` | -| 1.7 | 0.1% | `tests/ready-lint-deferral.bats` | -| 1.7 | 0.1% | `tests/ci-tools-check.bats` | +| 1.8 | 0.1% | `tests/spawn-census.bats` | +| 1.8 | 0.1% | `tests/ci-tools-check.bats` | +| 1.8 | 0.1% | `tests/install-check.bats` | | 1.7 | 0.1% | `tests/ci-slow-needed.bats` | -| 1.5 | 0.1% | `tests/install-check.bats` | -| 1.5 | 0.1% | `tests/verified.bats` | -| 1.4 | 0.1% | `tests/ci-lease-precondition.bats` | -| 1.4 | 0.1% | `tests/perf-record.bats` | -| 1.4 | 0.1% | `tests/mutant-census.bats` | -| 1.3 | 0.1% | `tests/awk-regex-check.bats` | -| 1.3 | 0.1% | `tests/spawn-census.bats` | -| 1.3 | 0.1% | `tests/deferral-check.bats` | -| 1.2 | 0.1% | `tests/done-check.bats` | +| 1.7 | 0.1% | `tests/tree-clean.bats` | +| 1.6 | 0.1% | `tests/mutant-census.bats` | +| 1.5 | 0.1% | `tests/memories-check.bats` | +| 1.5 | 0.1% | `tests/ci-lease-precondition.bats` | +| 1.4 | 0.1% | `tests/verified.bats` | +| 1.4 | 0.1% | `tests/serena-mcp.bats` | +| 1.4 | 0.1% | `tests/done-check.bats` | +| 1.4 | 0.1% | `tests/evaluator-closure-check.bats` | +| 1.4 | 0.1% | `tests/awk-regex-check.bats` | +| 1.4 | 0.1% | `tests/deferral-check.bats` | +| 1.3 | 0.1% | `tests/nonverdict-scan.bats` | +| 1.3 | 0.1% | `tests/sonar-gate.bats` | +| 1.3 | 0.1% | `tests/land-divergence-assert.bats` | +| 1.2 | 0.1% | `tests/release-backfill.bats` | +| 1.2 | 0.1% | `tests/target-prune.bats` | | 1.2 | 0.1% | `tests/linear-check.bats` | -| 1.1 | 0.1% | `tests/land-divergence-assert.bats` | -| 1.1 | 0.1% | `tests/nonverdict-scan.bats` | -| 1.1 | 0.1% | `tests/release-backfill.bats` | -| 1.0 | 0.1% | `tests/attestation-check.bats` | +| 1.1 | 0.1% | `tests/perf-assert.bats` | +| 1.1 | 0.1% | `tests/done-pr-check.bats` | +| 1.1 | 0.1% | `tests/fact-record-keying.bats` | +| 1.1 | 0.1% | `tests/gh-guard.bats` | | 1.0 | 0.1% | `tests/pr-unsubscribed.bats` | | 1.0 | 0.1% | `tests/module-map-check.bats` | -| 0.9 | 0.1% | `tests/done-pr-check.bats` | -| 0.9 | 0.1% | `tests/fact-record-keying.bats` | -| 0.9 | 0.1% | `tests/doctor.bats` | +| 1.0 | 0.1% | `tests/attestation-check.bats` | +| 1.0 | 0.1% | `tests/sbom-binary.bats` | +| 1.0 | 0.1% | `tests/render-cli.bats` | +| 1.0 | 0.1% | `tests/lint-deno.bats` | +| 1.0 | 0.1% | `tests/doctor.bats` | | 0.9 | 0.1% | `tests/timeout-drift.bats` | +| 0.9 | 0.1% | `tests/suite-bench-check.bats` | | 0.9 | 0.1% | `tests/checks-green.bats` | -| 0.9 | 0.1% | `tests/gh-guard.bats` | -| 0.8 | 0.1% | `tests/lint-deno.bats` | -| 0.8 | 0.1% | `tests/sbom-binary.bats` | -| 0.8 | 0.1% | `tests/transcript-corpus-check.bats` | -| 0.8 | 0.1% | `tests/mcp-attach-check.bats` | -| 0.8 | 0.1% | `tests/render-cli.bats` | -| 0.8 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.9 | 0.1% | `tests/stop-posture-check.bats` | +| 0.8 | 0.1% | `tests/duplicate-close-check.bats` | +| 0.8 | 0.1% | `tests/hook-matcher-check.bats` | | 0.8 | 0.1% | `tests/perf-compare.bats` | -| 0.7 | 0.1% | `tests/suite-bench-check.bats` | -| 0.7 | 0.1% | `tests/duplicate-close-check.bats` | -| 0.7 | 0.1% | `tests/merged-pr-keys.bats` | -| 0.7 | 0.1% | `tests/stop-posture-check.bats` | -| 0.7 | 0.1% | `tests/hook-matcher-check.bats` | -| 0.6 | 0.1% | `tests/evaluator-closure-check.bats` | -| 0.6 | 0.0% | `tests/lint-rego.bats` | -| 0.6 | 0.0% | `tests/hook-profile-check.bats` | +| 0.8 | 0.1% | `tests/macos-link-check.bats` | +| 0.8 | 0.1% | `tests/hook-profile-check.bats` | +| 0.8 | 0.1% | `tests/merged-pr-keys.bats` | +| 0.8 | 0.1% | `tests/mcp-timeout-budget.bats` | +| 0.7 | 0.1% | `tests/mcp-attach-check.bats` | +| 0.7 | 0.0% | `tests/publish-credential-check.bats` | | 0.6 | 0.0% | `tests/checksums.bats` | -| 0.6 | 0.0% | `tests/macos-link-check.bats` | -| 0.6 | 0.0% | `tests/pipefail-grep-check.bats` | -| 0.5 | 0.0% | `tests/publish-credential-check.bats` | -| 0.5 | 0.0% | `tests/sonar-gate.bats` | -| 0.5 | 0.0% | `tests/board-payloads.bats` | -| 0.5 | 0.0% | `tests/serena-mcp.bats` | +| 0.6 | 0.0% | `tests/branch-age-check.bats` | +| 0.6 | 0.0% | `tests/board-payloads.bats` | +| 0.6 | 0.0% | `tests/run-shape-guard-quoting.bats` | +| 0.6 | 0.0% | `tests/digest-major-agreement.bats` | +| 0.5 | 0.0% | `tests/msrv-pin-agreement.bats` | +| 0.5 | 0.0% | `tests/land-lock-check.bats` | | 0.5 | 0.0% | `tests/abandon-matrix.bats` | | 0.5 | 0.0% | `tests/hook-pin-check.bats` | -| 0.5 | 0.0% | `tests/msrv-pin-agreement.bats` | | 0.5 | 0.0% | `tests/connector-allow-guard.bats` | -| 0.5 | 0.0% | `tests/land-lock-check.bats` | -| 0.4 | 0.0% | `tests/digest-major-agreement.bats` | -| 0.4 | 0.0% | `tests/branch-age-check.bats` | -| 0.4 | 0.0% | `tests/release-due.bats` | +| 0.5 | 0.0% | `tests/transcript-corpus-check.bats` | +| 0.5 | 0.0% | `tests/timeout-check.bats` | +| 0.4 | 0.0% | `tests/report-only-check.bats` | | 0.4 | 0.0% | `tests/nonverdict-assert.bats` | +| 0.4 | 0.0% | `tests/release-due.bats` | | 0.4 | 0.0% | `tests/no-doctests.bats` | -| 0.4 | 0.0% | `tests/timeout-check.bats` | -| 0.4 | 0.0% | `tests/run-shape-guard-quoting.bats` | -| 0.4 | 0.0% | `tests/task-fail-closed.bats` | -| 0.4 | 0.0% | `tests/connector-allow-resolve.bats` | +| 0.4 | 0.0% | `tests/license-table-check.bats` | | 0.4 | 0.0% | `tests/commit-attribution.bats` | -| 0.4 | 0.0% | `tests/container-setup.bats` | -| 0.3 | 0.0% | `tests/pkl-check.bats` | -| 0.3 | 0.0% | `tests/cap-drift.bats` | -| 0.3 | 0.0% | `tests/batten-glob-check.bats` | -| 0.3 | 0.0% | `tests/commit-convention.bats` | +| 0.4 | 0.0% | `tests/cap-drift.bats` | +| 0.4 | 0.0% | `tests/batten-glob-check.bats` | +| 0.4 | 0.0% | `tests/commit-convention.bats` | | 0.3 | 0.0% | `tests/container-preflight.bats` | -| 0.3 | 0.0% | `tests/license-table-check.bats` | -| 0.3 | 0.0% | `tests/report-only-check.bats` | -| 0.3 | 0.0% | `tests/ci-drift.bats` | | 0.3 | 0.0% | `tests/mise-pin-agreement.bats` | +| 0.3 | 0.0% | `tests/rust-paths-check.bats` | +| 0.3 | 0.0% | `tests/connector-allow-resolve.bats` | +| 0.3 | 0.0% | `tests/ci-drift.bats` | | 0.3 | 0.0% | `tests/coderabbit-config-check.bats` | | 0.3 | 0.0% | `tests/git-hook.bats` | +| 0.3 | 0.0% | `tests/remedy-payload-source.bats` | | 0.2 | 0.0% | `tests/mise-action-floor.bats` | -| 0.2 | 0.0% | `tests/rust-paths-check.bats` | -| 0.2 | 0.0% | `tests/token-bench-check.bats` | | 0.2 | 0.0% | `tests/perf-gate.bats` | -| 0.2 | 0.0% | `tests/remedy-payload-source.bats` | +| 0.2 | 0.0% | `tests/token-bench-check.bats` | +| 0.2 | 0.0% | `tests/task-fail-closed.bats` | | 0.2 | 0.0% | `tests/dist.bats` | -| 0.1 | 0.0% | `tests/test-bats-parallel.bats` | -| 0.1 | 0.0% | `tests/perf-pair.bats` | +| 0.2 | 0.0% | `tests/test-bats-parallel.bats` | | 0.1 | 0.0% | `tests/egress-check.bats` | +| 0.1 | 0.0% | `tests/perf-pair.bats` | | 0.1 | 0.0% | `tests/evaluator-io-check.bats` | | 0.1 | 0.0% | `tests/zizmor-split.bats` | | 0.1 | 0.0% | `tests/darwin-link.bats` | diff --git a/crates/batten/tests/ratchet.rs b/crates/batten/tests/ratchet.rs index d03a0f0ae..ca511428c 100644 --- a/crates/batten/tests/ratchet.rs +++ b/crates/batten/tests/ratchet.rs @@ -1427,6 +1427,13 @@ fn the_fourth_arm_is_inert_where_a_row_does_not_declare_it() { // OWN existence — which script to prefer, what to fetch, what to verify about the // fetched bytes — and have no successor because they should have no subject. +// THE FILE-LEVEL ARM, which is the same ledger one granularity up. `shell-retirement` +// reads these same markers keyed on the retired PATH rather than on a quoted case, so +// the suite owes a row here as well as the eight case rows below — 908 conserves the +// cases, 1059 conserves the file, and a withdrawal has to be spellable at both or the +// honest deletion has no landable form at either. +// withdrawn: tests/container-setup.bats .claude/container-setup.sh the wrapper it covered is withdrawn in this same change and nothing replaced it — there is no policy surface and no compiled-binary test to name, which is what makes this a withdrawal rather than a port + // subsumed: "a binary installed off PATH is refused, not reported ready" tests/install.bats // changed: "the GitHub hosts are fenced in NO_PROXY before anything is fetched" tests/install.bats the proxy is handled by honouring the declared CA bundle now, not by fencing NO_PROXY — same problem, different and narrower mechanism, covered by three cases there // withdrawn: "THE DEFAULT: a checkout beside it is NOT used, the release is" the wrapper chose between a checked-out and a fetched install.sh; with no wrapper there is no choice to make diff --git a/crates/batten/tests/shell_retirement.rs b/crates/batten/tests/shell_retirement.rs index 754f08482..739880bff 100644 --- a/crates/batten/tests/shell_retirement.rs +++ b/crates/batten/tests/shell_retirement.rs @@ -39,7 +39,10 @@ fn row() -> Rule { "kind": "policy", "scope": "tree", "base": "origin/main", - "delta_sources": ["mise-tasks/**", "tests/**/*.bats"], + // `**` because the committed row carries it (CLOUD-1080): a withdrawal's + // declared subject is routinely under neither governed prefix, and a + // narrow delta hides its death rather than reporting it. + "delta_sources": ["**"], "line_sources": ["mise-tasks/*.sh", "crates/batten/tests/*.rs"], "module": "policy/shell-retirement.rego", "severity": "deny", @@ -379,6 +382,113 @@ fn a_mapping_naming_no_compiled_binary_test_is_refused() { assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); } +// --------------------------------------------------------------------------- +// The fourth arm: a WITHDRAWAL names no successor (CLOUD-1080). +// +// THIS TIER IS THE POINT HERE, not a duplicate of the module's own `test_` rules. +// Those fabricate `base-lines` for the dying suite; only a run over the compiled +// binary proves the ENGINE builds that entry — and it did not until this row's +// `line_sources` learned `tests/**/*.bats`. With the module's cases alone the arm +// passed its own suite and refused every real withdrawal, which is exactly the +// class `.claude/rules/policy-modules.md` records both live instances of. +// --------------------------------------------------------------------------- + +/// A dying suite declaring a subject that is not itself governed when deleted — +/// the real shape, since the wrapper this arm was built for lives under +/// `.claude/`. A governed subject would raise its own unmapped finding and the +/// assertion could not tell the two apart. +const WITHDRAWN_SUITE: &str = + "# subject: .claude/old-wrapper.sh\n@test \"it holds\" {\n true\n}\n"; + +#[test] +fn a_withdrawal_whose_subject_died_with_it_is_admitted() { + let root = repo( + "withdrawn", + &[ + ("tests/old-gate.bats", WITHDRAWN_SUITE), + (".claude/old-wrapper.sh", GATE), + ], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + "// withdrawn: tests/old-gate.bats .claude/old-wrapper.sh the feature should not exist\n", + )], + removed: &["tests/old-gate.bats", ".claude/old-wrapper.sh"], + }, + ); + assert_eq!( + findings(&root), + Vec::::new(), + "a withdrawal whose subject died owes no policy surface and no binary test" + ); +} + +#[test] +fn a_withdrawal_over_a_live_subject_is_refused() { + // THE DISCRIMINATING CASE. The subject is left standing while its suite is + // deleted and claimed withdrawn — a suite gutted with a note attached. Without + // this condition the arm is a waiver over the path with better manners, and + // the positive case above would pass against a module deciding nothing. + let root = repo( + "withdrawn-live", + &[ + ("tests/old-gate.bats", WITHDRAWN_SUITE), + (".claude/old-wrapper.sh", GATE), + ], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + "// withdrawn: tests/old-gate.bats .claude/old-wrapper.sh the feature should not exist\n", + )], + removed: &["tests/old-gate.bats"], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn a_withdrawal_naming_no_reason_is_refused() { + // It names no successor, so the reason is the only thing on the row a reader + // can check the claim against. + let root = repo( + "withdrawn-bare", + &[ + ("tests/old-gate.bats", WITHDRAWN_SUITE), + (".claude/old-wrapper.sh", GATE), + ], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + "// withdrawn: tests/old-gate.bats .claude/old-wrapper.sh\n", + )], + removed: &["tests/old-gate.bats", ".claude/old-wrapper.sh"], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + +#[test] +fn the_successor_obligation_still_binds_the_other_three_arms() { + // The exemption is scoped to `withdrawn` rather than switched on for every + // deletion whose subject died: the same fixture, mapped `carried` with no + // policy surface, still refuses. + let root = repo( + "withdrawn-scope", + &[ + ("tests/old-gate.bats", WITHDRAWN_SUITE), + (".claude/old-wrapper.sh", GATE), + ], + &Head { + written: &[( + "crates/batten/tests/old_gate.rs", + "// carried: tests/old-gate.bats crates/batten/tests/old_gate.rs\n", + )], + removed: &["tests/old-gate.bats", ".claude/old-wrapper.sh"], + }, + ); + assert_eq!(findings(&root), vec!["shell-rule-retired".to_owned()]); +} + // --------------------------------------------------------------------------- // The boundaries, which is where a gate nobody can keep green comes from. // --------------------------------------------------------------------------- diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 4091068d9..8aeb395b6 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -265,6 +265,7 @@ violation contains { some path in delta.deleted governed_when_deleted(path) count(arms_for(path)) == 1 + not withdrawn_arm(path) not has_policy_surface(path) } @@ -276,9 +277,44 @@ violation contains { some path in delta.deleted governed_when_deleted(path) count(arms_for(path)) == 1 + not withdrawn_arm(path) not has_binary_test(path) } +# --------------------------------------------------------------------------- +# D: what the WITHDRAWAL arm owes instead (CLOUD-1080). +# --------------------------------------------------------------------------- + +# THE CONDITION THAT KEEPS THIS NARROWER THAN A WAIVER. A withdrawal is honest only +# where the subject went with it; over a subject still standing this arm would be a +# blanket permission to delete governed files, which is the thing the module exists +# to refuse. +violation contains { + "rule": "shell-rule-retired", + "verdict": "V-WITHDRAWAL-SUBJECT-ALIVE", + "subjects": [{"path": path}], +} if { + some path in delta.deleted + governed_when_deleted(path) + count(arms_for(path)) == 1 + withdrawn_arm(path) + count(withdrawn_subjects(path)) == 0 +} + +# It names no successor, so the reason is the only thing a reader can check the +# claim against. An arm with neither is a file deleted with a marker on it. +violation contains { + "rule": "shell-rule-retired", + "verdict": "V-WITHDRAWAL-UNEXPLAINED", + "subjects": [{"path": path}], +} if { + some path in delta.deleted + governed_when_deleted(path) + count(arms_for(path)) == 1 + withdrawn_arm(path) + count(withdrawal_reason(path)) == 0 +} + # --------------------------------------------------------------------------- # The ledger, read out of the declared lines. # --------------------------------------------------------------------------- @@ -287,7 +323,76 @@ violation contains { # are duplicated across two authorities and that is a known seam rather than an # oversight: `rules-drift` holds this list to the TOML, so a rename that touched # one and not the other is a finding rather than a silently dead predicate. -arm_markers := ["// carried:", "// subsumed:", "// changed:"] +arm_markers := ["// carried:", "// subsumed:", "// changed:", "// withdrawn:"] + +# THE FOURTH ARM IS THE ONE THAT NAMES NO SUCCESSOR (CLOUD-1080), and it is here +# for the same reason the other three are: this module reads `[rule.conserves]`'s +# declaration one level up, so an arm the case granularity accepts and the file +# granularity refuses would leave an honest deletion with no landable spelling at +# either level. `conserves` grew it first, over the cases; this is the file. +# +# A WITHDRAWAL IS NOT A MIGRATION. The three above answer "where did the predicate +# go", which is the right question for a port. It is the wrong question for a file +# deleted because the thing it governed should not exist: there is no successor, so +# demanding a policy surface and a compiled-binary test forces a mapping that names +# something which does not hold the predicate — the false `subsumed` in this +# module's vocabulary. +# +# So this arm trades those two obligations for two others, and it is strictly +# narrower than a `[[waiver]]` over the path, because it is spent one file at a +# time and only once the subject went with it. +withdrawn_arm(path) if { + some row in arms_for(path) + startswith(row, "// withdrawn:") +} + +# THE SUBJECT IS NAMED ON THE ROW, and the reason it is named rather than read is +# a stated engine bound rather than a preference. `input.tree["base-delta"]`'s +# `base-lines` is bounded to EDITED paths by construction — `git.rs` says so on the +# field: *"not `added` (there is no base side), not `deleted` (the head side is +# gone)"*. A dying suite's own `# subject:` header therefore cannot be read from +# this surface at all, so the file granularity names what died where the CASE +# granularity reads it out of the base text the ratchet already buffers. +# +# WHAT THAT COSTS, stated rather than discovered: the subject here is +# author-declared, where `conserves`'s arm reads the dying file's own declaration. +# It is still not a free claim — the named path must appear in THIS delta's +# `deleted` set, so a withdrawal cannot be spelled without actually retiring +# something — but an author could name a different deleted path than the one the +# suite declared. Closing that needs the base side of a deleted path, which is an +# engine capability this row does not have and does not invent. +withdrawn_fields(path) := fields if { + some row in arms_for(path) + startswith(row, "// withdrawn:") + all := split(trim_space(substring(row, count("// withdrawn:"), -1)), " ") + fields := [word | some i, word in all; i > 0; word != ""] +} + +# The fields naming a path this same change retired. At least one is owed: that is +# the whole narrowing, and it is what keeps the arm from being a `[[waiver]]` over +# the path with better manners. +withdrawn_subjects(path) := subjects if { + subjects := {word | + some word in withdrawn_fields(path) + + # NOT THE RETIRED PATH ITSELF. Without this the row could name the dying + # file again and satisfy the narrowing with the very deletion it is + # excusing — a withdrawal justified by its own subject. + word != path + some gone in delta.deleted + word == gone + } +} + +# Everything else on the row is the reason. It names no successor, so this is the +# only thing a reader can check the claim against, and it is owed. +withdrawal_reason(path) := words if { + subjects := withdrawn_subjects(path) + words := {word | + some word in withdrawn_fields(path) + not word in subjects + } +} # Every ledger row naming `path`, as ` ...`. A row is # matched on the path being the FIRST field after the marker, so a successor path @@ -520,6 +625,88 @@ test_mapping_without_a_binary_test_is_refused if { }} } +# --- the fourth arm (CLOUD-1080) -------------------------------------------- +# +# The positive case: the subject dies in the same delta, the row carries a reason, +# and NO successor is named. Under the three-arm module this exact input raised +# both `V-SUCCESSOR-NO-SURFACE` and `V-SUCCESSOR-NO-TEST`, which is the refusal the +# arm exists to remove. +test_a_withdrawal_whose_subject_died_is_admitted if { + count(violation) == 0 with input as {"tree": { + "base-delta": { + "added": [], + "edited": [], + "deleted": ["tests/old-gate.bats", ".claude/old-wrapper.sh"], + }, + "lines": {"crates/batten/tests/old_gate.rs": ["// withdrawn: tests/old-gate.bats .claude/old-wrapper.sh the feature should not exist"]}, + }} +} + +# THE DISCRIMINATING CASE, and the reason this arm is narrower than a waiver over +# the path: the subject is left STANDING while its suite is deleted and claimed +# withdrawn. Without this condition the arm admits every deletion, and the positive +# case above would pass against a module that decided nothing. +test_a_withdrawal_over_a_live_subject_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": { + "added": [], + "edited": [], + "deleted": ["tests/old-gate.bats"], + }, + "lines": {"crates/batten/tests/old_gate.rs": ["// withdrawn: tests/old-gate.bats .claude/old-wrapper.sh the feature should not exist"]}, + }} +} + +# A row naming NOTHING but a reason is refused too, and this is the case that keeps +# `withdrawn_subjects` from being satisfiable by prose: every word on the row is a +# reason word, none of them names a deleted path, so the arm claims a withdrawal +# without retiring anything. +test_a_withdrawal_naming_no_retired_path_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": { + "added": [], + "edited": [], + "deleted": ["tests/old-gate.bats", ".claude/old-wrapper.sh"], + }, + "lines": {"crates/batten/tests/old_gate.rs": ["// withdrawn: tests/old-gate.bats the feature should not exist"]}, + }} +} + +# It names no successor, so the reason is the only checkable thing on the row. +test_a_withdrawal_with_no_reason_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": { + "added": [], + "edited": [], + "deleted": ["tests/old-gate.bats", ".claude/old-wrapper.sh"], + }, + "lines": {"crates/batten/tests/old_gate.rs": ["// withdrawn: tests/old-gate.bats .claude/old-wrapper.sh"]}, + }} +} + +# The retired path itself is not its own subject. Without this a row could name the +# dying file again and satisfy the narrowing with the deletion it is excusing. +test_a_withdrawal_naming_only_itself_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["tests/old-gate.bats"]}, + "lines": {"crates/batten/tests/old_gate.rs": ["// withdrawn: tests/old-gate.bats tests/old-gate.bats a reason"]}, + }} +} + +# The three successor-naming arms are untouched by the fourth: one of them over a +# row naming no policy surface still refuses, so the exemption is scoped to +# `withdrawn` rather than switched on for every deletion. +test_the_successor_obligation_still_binds_the_other_arms if { + count(violation) == 1 with input as {"tree": { + "base-delta": { + "added": [], + "edited": [], + "deleted": ["tests/old-gate.bats", ".claude/old-wrapper.sh"], + }, + "lines": {"crates/batten/tests/old_gate.rs": ["// carried: tests/old-gate.bats crates/batten/tests/old_gate.rs"]}, + }} +} + # The anti-vacuity arm on the OTHER axis: an untouched tree, and a generated or # non-shell path moving, must both be silent. Without this a module that refused # everything would still pass every case above. From 8b1d209a07bf3b9451ccdb7ccc8f8df41ee91c3a Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Fri, 28 Aug 2026 00:13:25 +0000 Subject: [PATCH 3/3] fix(policy): the retired path was prose to one half of the withdrawal arm and not the other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on review of this PR. `withdrawn_subjects` excludes the retired path so a row cannot be its own subject — but `withdrawal_reason` took everything NOT in that subject set, so the word the first half rejected fell straight through into the second as the reason. The consequence is a row that satisfies both halves while writing nothing: // withdrawn: tests/old-gate.bats .claude/old-wrapper.sh tests/old-gate.bats A real deleted subject on the left, and the dying file named again on the right standing in for the reason the arm exists to demand. Both predicates passed. One exclusion, spelled the same way in both halves, and a case that names that exact row shape — which is the tier that would have caught it, since every other withdrawal case carries prose and none could distinguish the two readings. Refs: CLOUD-1080, CLOUD-1059 THE TWO WEAKENINGS THIS BRANCH PERFORMS, declared here because the trailer check is branch-scoped and this is its tip. `9c036a9` performs the first — the fourth arm changes `[rule.conserves]`'s predicate by construction, which is CLOUD-1080's whole subject and was groomed before any code was written. `0989211` performs the second: `shell-retirement`'s `delta_sources` widens to `**` so the file-granularity arm can see that its subject died. That one was discovered DURING implementation, after `base-lines` turned out to exclude deleted paths and `input.tree.tracked` turned out to be the never-built key the loader refuses, and it was groomed retroactively under an explicit override rather than ahead of the work. Both are recorded on the row. READ THE ADMISSION SCEPTICALLY. `config-lint` will admit these on the trailers alone, through its empty-receipt path, because the groomed half CANNOT BE RECORDED at all: `claim-check` anchors on a literal `**Weakens:**` and the tracker's normaliser stores `**Weakens: **`, so no clause reaches the receipt in any spelling. CLOUD-1082 has the measurement; CLOUD-841 and CLOUD-1078 own the empty-receipt half. So the pair this names is one-sided, and the PR says so rather than letting a passing gate imply otherwise. Weakens: rule-predicate-changed rule[bats-tests-not-deleted].conserves Weakens: rule-predicate-changed rule[shell-retirement].delta_sources --- policy/shell-retirement.rego | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/policy/shell-retirement.rego b/policy/shell-retirement.rego index 8aeb395b6..303a81f9b 100644 --- a/policy/shell-retirement.rego +++ b/policy/shell-retirement.rego @@ -390,6 +390,12 @@ withdrawal_reason(path) := words if { subjects := withdrawn_subjects(path) words := {word | some word in withdrawn_fields(path) + + # THE RETIRED PATH IS NOT PROSE EITHER. `withdrawn_subjects` excludes it so + # a row cannot be its own subject, and without the same exclusion here that + # rejected word fell through into the reason set — so naming the dying file + # twice satisfied both halves with no reason written at all. + word != path not word in subjects } } @@ -684,6 +690,17 @@ test_a_withdrawal_with_no_reason_is_refused if { }} } +# THE REGRESSION CASE for a defect found on review of this PR: with the retired +# path excluded from the SUBJECT set but not from the reason set, naming the dying +# file a second time satisfied both halves at once — a valid deleted subject, and +# the rejected word falling through as the prose. No reason is written here. +test_a_withdrawal_whose_only_reason_is_the_retired_path_is_refused if { + count(violation) == 1 with input as {"tree": { + "base-delta": {"added": [], "edited": [], "deleted": ["tests/old-gate.bats", ".claude/old-wrapper.sh"]}, + "lines": {"crates/batten/tests/old_gate.rs": ["// withdrawn: tests/old-gate.bats .claude/old-wrapper.sh tests/old-gate.bats"]}, + }} +} + # The retired path itself is not its own subject. Without this a row could name the # dying file again and satisfy the narrowing with the deletion it is excusing. test_a_withdrawal_naming_only_itself_is_refused if {