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..31d0bdb51 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 @@ -2978,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" @@ -3850,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/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..ca511428c 100644 --- a/crates/batten/tests/ratchet.rs +++ b/crates/batten/tests/ratchet.rs @@ -1263,3 +1263,182 @@ 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. + +// 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 +// 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/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..303a81f9b 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,82 @@ 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) + + # 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 + } +} # 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 +631,99 @@ 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 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 { + 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. 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 ] -}