diff --git a/.github/workflows/arch-probe.yml b/.github/workflows/arch-probe.yml index 117cf47d4..751d76856 100644 --- a/.github/workflows/arch-probe.yml +++ b/.github/workflows/arch-probe.yml @@ -113,6 +113,12 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672), and + # here the pin is load-bearing for the EXPERIMENT rather than only for + # the install: this probe's validity rests on both arms differing only + # in `runs-on`, so an unpinned mise lets the two arms install different + # toolchains and destroys the comparison silently. + version: 2026.9.1 # Only what the build needs. This job runs no gate, so the `ci` job's # wide list would be paying install time on both arms for tools # nothing here invokes — and install time is exactly the term the diff --git a/.github/workflows/auto-bot-land.yml b/.github/workflows/auto-bot-land.yml index 6050d193c..43dbcc9a2 100644 --- a/.github/workflows/auto-bot-land.yml +++ b/.github/workflows/auto-bot-land.yml @@ -291,6 +291,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 if: steps.target.outputs.num != '' # THE LANE NEEDS THE ENGINE NOW, AND NOTHING WAS PROVIDING IT (CLOUD-1143's # consequence). `checks-green` was retired onto the engine, so `mise.toml`'s diff --git a/.github/workflows/auto-release-land.yml b/.github/workflows/auto-release-land.yml index 00cd27f25..6b7e4be50 100644 --- a/.github/workflows/auto-release-land.yml +++ b/.github/workflows/auto-release-land.yml @@ -210,6 +210,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 if: steps.target.outputs.num != '' # THE LANE NEEDS THE ENGINE NOW, AND NOTHING WAS PROVIDING IT (CLOUD-1143's # consequence). `checks-green` was retired onto the engine, so `mise.toml`'s diff --git a/.github/workflows/branch-hygiene.yml b/.github/workflows/branch-hygiene.yml index 982d6f594..42fefb75f 100644 --- a/.github/workflows/branch-hygiene.yml +++ b/.github/workflows/branch-hygiene.yml @@ -45,6 +45,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # `gh` for both readings, `jq` for the GraphQL projection. Narrow, for # the reason ci.yml states: the whole toolchain is ~10s a job that # this one has no use for. diff --git a/.github/workflows/ci-drift.yml b/.github/workflows/ci-drift.yml index 9712429cf..c9b1384ac 100644 --- a/.github/workflows/ci-drift.yml +++ b/.github/workflows/ci-drift.yml @@ -47,6 +47,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # THE FETCH, one step before the gate. It lives here rather than in a # `mise-tasks/` program because a credentialed read is the one thing the # engine may not do (house-style §5), and this is where the job token diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eecdf6ca..614e787bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -293,6 +293,20 @@ jobs: fi - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # PINNED TO `batten.toml`'s `[[provision]]` ROW, NOT LEFT TO RESOLVE + # (CLOUD-1672). The action digest above pins the ACTION; this pins the + # MISE it installs, which is a separate resolution the digest does not + # reach. Unset, it takes whatever is newest when the job starts — + # measured 2026-09-08, that was v2026.9.3, whose linux-x64 asset 404s, + # and every job in every workflow died at this step inside 11 seconds. + # v0.0.152 published with zero binaries as a result. + # + # The provision row's own comment already names this harm one layer + # down: a runner that updates itself under a container makes two + # sessions on one commit run different toolchains. The pin existed and + # this layer could not see it, so the tree declared one version and CI + # ran another. `ci-tools-check` now refuses any drift between the two. + version: 2026.9.1 # Install only what this job's gates actually invoke, not the whole # 18-tool dev set. Every job used to install everything, and `zig` # (17.3s) finished last, so zig alone set the install wall clock in @@ -640,24 +654,34 @@ jobs: # that was supposed to make this a finding rather than a surprise does not # reach it. That asymmetry is the row's to close, not this job's. # - # COLD, AND THE JUSTIFICATION FOR THAT IS NOW FALSE. `cache-warm-linux` - # writes `ci-` on arm64, so no warm x64 entry exists for this job and it pays - # a full build every run. + # WARM SINCE CLOUD-1477, AND THE TWO PRICINGS BEFORE IT WERE BOTH WRONG. + # + # The first priced this job's cold build as free: "a job running in parallel + # with a 1730s pole has ~1200s of slack." True when written, and falsified by + # this repository's own change — CLOUD-1410 removed the pole. Measured on run + # 33845451921, the first warm arm64 run of a real `crates/**` change: `ci` + # 364s, `bats` 271s, and THIS JOB 683s, 1.9x the next longest and the critical + # path of the whole workflow. # - # This comment used to price that as free: "a job running in parallel with a - # 1730s pole has ~1200s of slack." The premise was true when it was written - # and this repository's own change falsified it. Measured on run 33845451921, - # the first warm arm64 run of a real `crates/**` change: `ci` 364s, `bats` - # 271s, and THIS JOB 683s — 1.9x the next longest, and the critical path of - # the whole workflow. There is no 1730s pole to hide behind any more, because - # CLOUD-1410 removed it. The slack is gone and this job is the pole. + # The second declined the fix on a ceiling: buying a warm x64 entry needs a + # second warm writer, "doubling the cache families against a store measured at + # 10.72 GiB over a 10 GiB ceiling", so the answer was said to be CLOUD-1431 or + # a revert of the family to x64. Every term of that is now spent. + # `cache-sweep.yml` (CLOUD-1453, released v0.0.142) took the store to 3.16 GiB + # by evicting merged pull requests' entries, so the ceiling is not the binding + # constraint. CLOUD-1431 is Done and landed a GATE over the + # release-target/provision-platform pairing rather than an arm64 scanner, so + # this job still cannot move. And eviction bought 12% and not a hit: 683s to + # 600s across jobs 100903936005 and 101280975479, which is pressure lifting + # rather than a restore landing. # - # What has not changed is the trade that was declined: buying a warm x64 - # entry needs a second warm writer, doubling the cache families against a - # store measured at 10.72 GiB over a 10 GiB ceiling. So the answer is not a - # second writer — it is CLOUD-1431, which removes the reason this job is - # stranded here at all, or a revert of the family to x64 if `arch-probe.yml` - # shows arm64 buys nothing. + # So the second writer is what landed. `cache-warm-linux-x64` in + # `release-plz.yml` writes `ci-` on x64 for this job, exactly as + # `cache-warm-linux` writes it on arm64 for `ci`, `bats` and `commit-lint`, + # and `read-family-has-a-warm-writer` refuses the arrangement where a + # read-only consumer has no writer on its own architecture — so the orphaning + # that produced this comment's whole history is now a finding rather than a + # green run that took longer. runs-on: ubuntu-latest # DERIVED RATHER THAN OBSERVED, because this job has no history yet. The # terms: 189.6s for the step itself (job 100792703868), plus this job's own @@ -735,6 +759,20 @@ jobs: # is recorded there rather than papered over with a step that never fixed it. - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # PINNED TO `batten.toml`'s `[[provision]]` ROW, NOT LEFT TO RESOLVE + # (CLOUD-1672). The action digest above pins the ACTION; this pins the + # MISE it installs, which is a separate resolution the digest does not + # reach. Unset, it takes whatever is newest when the job starts — + # measured 2026-09-08, that was v2026.9.3, whose linux-x64 asset 404s, + # and every job in every workflow died at this step inside 11 seconds. + # v0.0.152 published with zero binaries as a result. + # + # The provision row's own comment already names this harm one layer + # down: a runner that updates itself under a container makes two + # sessions on one commit run different toolchains. The pin existed and + # this layer could not see it, so the tree declared one version and CI + # ran another. `ci-tools-check` now refuses any drift between the two. + version: 2026.9.1 # THE `ci` JOB'S LIST, NOT A NARROWER ONE, and the width is decided by # what `enforce` reaches rather than by what this job looks like it # needs. `batten.toml`'s `command` rules shell out to `hk`, to `regal` @@ -753,22 +791,30 @@ jobs: with: path: ~/.pkl/cache key: pkl-${{ runner.os }}-${{ hashFiles('hk.pkl') }} - # THE `ci-` FAMILY, AND THE ONLY STEP IN THIS WORKFLOW THAT STILL WRITES - # ONE. That is the rule the `ci`, `bats` and `windows` steps are stated - # over, applied rather than excepted: `save-if: false` belongs on a reader - # of a family a trunk-side job WARMS, and is wrong where no warm writer - # exists, because a pure consumer of an empty family has nothing at all. - # `cache-warm-linux` writes `ci-` on arm64 (see this job's `runs-on` for - # why this one cannot follow it), so on x64 that family has no writer. + # THE `ci-` FAMILY, READ-ONLY LIKE EVERY OTHER PULL-REQUEST CONSUMER OF IT + # (CLOUD-1477). This step was this workflow's last writer, and the reason it + # was one has been removed rather than merely overruled. + # + # The rule is the one the `ci`, `bats` and `windows` steps are stated over: + # `save-if: false` belongs on a reader of a family a trunk-side job WARMS, + # and is wrong where no warm writer exists, because a pure consumer of an + # empty family has nothing at all. That exception applied here for as long + # as `cache-warm-linux` wrote `ci-` on arm64 alone; `cache-warm-linux-x64` + # now writes it on x64, so this job is an ordinary consumer and the + # exception is spent. # - # It is not useless. A cache read is scoped to the run's own ref plus the - # base branch, so what this cannot do is serve ANOTHER pull request — but - # it does serve the next lap of THIS one, and `land` re-runs CI once per - # lap on a fresh SHA under the same `refs/pull/N/merge` ref. A landing loop - # is exactly where the repeat reads are. + # WHAT THE WRITE WAS BUYING, AND WHY IT IS NOT WORTH KEEPING BESIDE A WARM + # WRITER. A cache read is scoped to the run's own ref plus the base branch, + # so the entry could never serve another pull request — only the next lap of + # this one, under the same `refs/pull/N/merge` ref that `land` re-runs on. + # Against a warm x64 entry the first lap now restores instead of building, + # so the later laps have nothing left to inherit that they would not already + # get; what the write still costs is a multi-gigabyte upload per lap into + # the 10 GiB store that `cache-sweep.yml` then has to reclaim. - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: shared-key: ci- + save-if: false - run: mise run batten-check env: # `batten-check` reaches `doctor` through the same chain the `ci` job @@ -868,6 +914,20 @@ jobs: run: git fetch origin main:refs/remotes/origin/main - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # PINNED TO `batten.toml`'s `[[provision]]` ROW, NOT LEFT TO RESOLVE + # (CLOUD-1672). The action digest above pins the ACTION; this pins the + # MISE it installs, which is a separate resolution the digest does not + # reach. Unset, it takes whatever is newest when the job starts — + # measured 2026-09-08, that was v2026.9.3, whose linux-x64 asset 404s, + # and every job in every workflow died at this step inside 11 seconds. + # v0.0.152 published with zero binaries as a result. + # + # The provision row's own comment already names this harm one layer + # down: a runner that updates itself under a container makes two + # sessions on one commit run different toolchains. The pin existed and + # this layer could not see it, so the tree declared one version and CI + # ran another. `ci-tools-check` now refuses any drift between the two. + version: 2026.9.1 # One line, for the reason the `ci` job's list states: `ci-tools-check` # parses these as a scalar and several names begin with `aqua:`, which # a block scalar's continuation lines read as new keys. @@ -1033,6 +1093,20 @@ jobs: fetch-depth: 0 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # PINNED TO `batten.toml`'s `[[provision]]` ROW, NOT LEFT TO RESOLVE + # (CLOUD-1672). The action digest above pins the ACTION; this pins the + # MISE it installs, which is a separate resolution the digest does not + # reach. Unset, it takes whatever is newest when the job starts — + # measured 2026-09-08, that was v2026.9.3, whose linux-x64 asset 404s, + # and every job in every workflow died at this step inside 11 seconds. + # v0.0.152 published with zero binaries as a result. + # + # The provision row's own comment already names this harm one layer + # down: a runner that updates itself under a container makes two + # sessions on one commit run different toolchains. The pin existed and + # this layer could not see it, so the tree declared one version and CI + # ran another. `ci-tools-check` now refuses any drift between the two. + version: 2026.9.1 # rust builds the two binaries under measurement, hyperfine samples # them, jq reads the ratio out. See the ci job for why these lists are # narrow. diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index d30cb9c5a..c943439c4 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -144,6 +144,10 @@ jobs: fetch-depth: 0 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # `rust`, and nothing else. `commit-lint` depends on `commit-check` and # `commit-attribution`, both of which are `cargo run --quiet -p batten`, # and on `signing-posture`, which uses only git, gpg and ssh-keygen — diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 3178de190..6485c2ba3 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -57,6 +57,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # cargo-llvm-cov drives cargo; rust supplies the compiler and the # rustup that fetches llvm-tools-preview. Nothing else is invoked. # `mise run ci-tools-check` fails if a name here drifts from mise.toml. diff --git a/.github/workflows/fast-forward.yml b/.github/workflows/fast-forward.yml index ccafc4a74..1c80ec5bb 100644 --- a/.github/workflows/fast-forward.yml +++ b/.github/workflows/fast-forward.yml @@ -243,6 +243,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # The roster lives in `mise.toml`'s `[env]`, read from there by # `checks-green` and by `land`'s `graded_runs` alike (CLOUD-327). No # `[tools]` entry is needed to evaluate it, so none is installed. diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 9280d2f5b..a9ecd8002 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -67,6 +67,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # `rust` alone: it supplies cargo and the rustup that `mise run fuzz` # uses to fetch the nightly libFuzzer's `-Z sanitizer` requires, and # to build the pinned cargo-fuzz under it. cargo-fuzz is deliberately diff --git a/.github/workflows/hook-latency-drift.yml b/.github/workflows/hook-latency-drift.yml index d64da13a0..bda5d19f2 100644 --- a/.github/workflows/hook-latency-drift.yml +++ b/.github/workflows/hook-latency-drift.yml @@ -54,6 +54,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). This + # job measures the gate's own latency on a clock, so an unpinned + # toolchain changes what the series measures rather than failing. + version: 2026.9.1 - name: Report a fast tier that no longer matches its budget shell: bash env: diff --git a/.github/workflows/land-divergence.yml b/.github/workflows/land-divergence.yml index 9e636d81b..05a7089d8 100644 --- a/.github/workflows/land-divergence.yml +++ b/.github/workflows/land-divergence.yml @@ -56,6 +56,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # Measure to a FILE, then read it back — never `measure | assert`. A # pipeline hands the step's exit status to its last stage, so a measurement # that died would be judged as a clean empty window rather than reported diff --git a/.github/workflows/lock-currency.yml b/.github/workflows/lock-currency.yml index 5dbe60e8c..34761ad61 100644 --- a/.github/workflows/lock-currency.yml +++ b/.github/workflows/lock-currency.yml @@ -46,6 +46,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). This + # is the clock that bumps pins, so leaving it resolving latest means + # the job that MOVES a pin is the one a bad upstream release stops. + version: 2026.9.1 # The lockfile rewrite this performs is fine here and only here: the # checkout is disposable, and no commit is being judged by it. - run: mise run lock-check diff --git a/.github/workflows/mutant.yml b/.github/workflows/mutant.yml index 3e21fb134..448fe32d4 100644 --- a/.github/workflows/mutant.yml +++ b/.github/workflows/mutant.yml @@ -120,6 +120,11 @@ jobs: # it writes nothing, so no credential is persisted. persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # THE PAIR IS THE VERDICT, never either alone. `mutant` asks whether each # declared mutation is caught; `mutant-census` asks whether every gate in # the tree is declared or carries a filed exemption. A change that dropped a diff --git a/.github/workflows/nonverdict-rate.yml b/.github/workflows/nonverdict-rate.yml index 7eab4a100..d2b05bd55 100644 --- a/.github/workflows/nonverdict-rate.yml +++ b/.github/workflows/nonverdict-rate.yml @@ -61,6 +61,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). This + # job records a rate on a clock, so an unpinned toolchain changes what + # the series measures rather than failing. + version: 2026.9.1 # Measure to a FILE, then read it back — never `scan | assert`. A pipeline # hands the step's exit status to its last stage, so a scan that died would # be judged as an empty window rather than reported (perf.yml:84-92). diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index f8658c531..3b164b7ba 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -95,6 +95,10 @@ jobs: run: git fetch origin "refs/notes/perf:refs/notes/perf" || true - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # rust builds the binary under measurement, hyperfine samples it, jq # reads the sampler's JSON back out. Nothing else is invoked. # `mise run ci-tools-check` fails if a name here drifts from mise.toml. diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 9144b68b4..ed77a3726 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -137,6 +137,11 @@ jobs: # and the attestation would faithfully attest to the compromised build. - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution. Unset, every job here died at this step in + # 11s on v0.0.152 and the release published with zero binaries. + version: 2026.9.1 cache: false - name: Install cross if: matrix.build-tool == 'cross' @@ -275,6 +280,11 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution. Unset, every job here died at this step in + # 11s on v0.0.152 and the release published with zero binaries. + version: 2026.9.1 cache: false - name: Derive both schemas from the tagged config types run: mise run schema @@ -349,6 +359,11 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution. Unset, every job here died at this step in + # 11s on v0.0.152 and the release published with zero binaries. + version: 2026.9.1 cache: false # `checksums` emits a sums= KEY=VALUE line, so the asset path is decided in # that one file and never re-derived in YAML. diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 3b35b7c86..35d1c2f6e 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -52,6 +52,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # TAG goes through env, never interpolated into the run block: a `${{ }}` # expansion is textual substitution into the shell script, so a tag naming # shell metacharacters would execute. Same posture as every other run block diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index ad5a2c269..296b80403 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -162,6 +162,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # Exactly what the `ci` job needs to compile the workspace, minus the # tools only its assertions use: the cache being filled is a cargo # target dir. @@ -183,6 +187,76 @@ jobs: - run: mise exec -- cargo nextest run --no-run --workspace if: steps.rust-cache.outputs.cache-hit != 'true' + # THE X64 HALF OF THE `ci-` FAMILY (CLOUD-1477). + # + # WHY ONE `shared-key` NEEDS TWO WRITERS. `runnerOS-runnerArch` lands at + # rust-cache's `config.ts:93`, INSIDE the prefix assigned to `restoreKey` at + # `:133`, so `ci-` on arm64 and `ci-` on x64 are two entries that cannot + # contend, cannot overwrite one another, and cannot hand a reader the other's + # tree. `cache-warm-linux` above writes the arm64 one, read by `ci` and `bats` + # (ci.yml) and `commit-lint` (commit-lint.yml). THIS writes the x64 one, and + # `batten-check` is its only reader — the one job of the family that cannot + # move to arm64, because `batten enforce` carries `no-secrets` and no + # ripsecrets release has ever published a linux-aarch64 artifact. CLOUD-1431 + # closed that row by GATING the release-target/provision-platform pairing, not + # by finding an arm64 scanner, so the constraint stands. + # + # THE TRADE THIS RE-PRICES, because `ci.yml` declined it on a premise that has + # since been falsified. A second writer doubles this family's entries, and that + # was refused against a store measured at 10.72 GiB over a 10 GiB ceiling. + # `cache-sweep.yml` (CLOUD-1453, released v0.0.142) took the store to 3.16 GiB + # by evicting entries belonging to merged pull requests, so the ceiling the + # refusal rested on is no longer the binding constraint. What eviction did NOT + # buy was a hit: measured on jobs 100903936005 and 101280975479, + # `batten-check` went 683s to 600s under the swept store — 12%, which is + # eviction pressure lifting rather than a restore landing. A cold build is + # still a cold build, and this job is what removes it. + # + # `ubuntu-latest` MUST MATCH `batten-check`'s LABEL ARCHITECTURE rather than + # merely being "some x64 runner": `ci-cache-declared`'s + # `read-family-has-a-warm-writer` compares the two by the arch their labels + # resolve to, so moving either without the other is now a finding rather than a + # silent cold build. + cache-warm-linux-x64: + name: cache-warm-linux-x64 + runs-on: ubuntu-latest + # Grandfathered for `cache-warm-linux`'s reason: no measured p95 exists for a + # job that has never run, and a guessed number would read as measured. + # CLOUD-352 owns replacing it once the runs exist. + timeout-minutes: 30 # budget: grandfathered measured=2026-09-08 + # A warm job must never be able to red the release lane, exactly as above. + continue-on-error: true + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 + # The same list `cache-warm-linux` installs, and for its reason rather + # than by copying: what this fills is a cargo target dir, and + # `batten-check` reaches it through the same workspace build. + install_args: rust github:nextest-rs/nextest + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + # Read by the compile step below; `ci-local-parity` property 17 holds the + # `id` and the guard together. + id: rust-cache + with: + # Must match `ci.yml`'s `batten-check` job exactly or this writes an + # entry that job cannot read. + shared-key: ci- + # Compile only when there is nothing to restore. The evidence and the + # failure directions are recorded on the Windows job below; the same + # reasoning applies unchanged, and the same property refuses an unguarded + # version of this step. + - run: mise exec -- cargo nextest run --no-run --workspace + if: steps.rust-cache.outputs.cache-hit != 'true' + cache-warm-windows: name: cache-warm-windows runs-on: windows-latest @@ -196,6 +270,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # Exactly what the `windows` job installs, minus the tools only its # assertions need: the cache being filled is a cargo target dir. install_args: rust github:nextest-rs/nextest @@ -258,6 +336,12 @@ jobs: fetch-depth: 0 token: ${{ secrets.RELEASE_PLZ_TOKEN || secrets.GITHUB_TOKEN }} - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672), like + # the three cache-warm steps above. This is the step that cuts the + # release, so leaving it resolving latest is what published v0.0.152 + # with no binaries. + version: 2026.9.1 # release-plz clones the repo into a temp dir; `git fetch origin main` there # has no credentials (actions/checkout's auth lives only on the main clone), # so it cannot rebase the release branch onto main — the PR is then re-opened diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 80c2f9ebf..1c692750f 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -165,6 +165,10 @@ jobs: submodules: true - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # cross-check is `rustup target add` + `cargo check`; rust is the only # tool it touches. See the ci job for why these lists are narrow. install_args: rust @@ -288,6 +292,10 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # zig supplies the Darwin linker and cargo-zigbuild drives cargo # through it; rust is the compiler. Nothing else is invoked here — # and this is the ONLY job that needs zig, the 17.3s pole the other @@ -407,6 +415,10 @@ jobs: fetch-depth: 0 - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # cargo-semver-checks does the comparison; rust supplies the cargo and # the rustup that fetches the newer toolchain the tool requires — see # the note beside `[tools]` in mise.toml for why that is the tool's @@ -567,6 +579,10 @@ jobs: submodules: true - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # NOT rust alone, which is what this job shipped with and what the # sixth Windows run disproved. `cargo test` is the command, but the # suite SPAWNS: `tests/cli.rs` materializes fixtures carrying this diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 6880fac70..17e4f33c4 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -68,6 +68,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # scorecard reads the repository over the API and the checkout from # disk; nothing else is invoked. `mise run ci-tools-check` fails if a # name here drifts from mise.toml. diff --git a/.github/workflows/timeout-drift.yml b/.github/workflows/timeout-drift.yml index 11de3c529..3d050c60e 100644 --- a/.github/workflows/timeout-drift.yml +++ b/.github/workflows/timeout-drift.yml @@ -57,6 +57,11 @@ jobs: with: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) + with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # GH_TOKEN goes through env, never interpolated into the run block: a # `${{ }}` expansion is textual substitution into the shell script, and # zizmor gates that at high severity. All logic is in the task; nothing diff --git a/.github/workflows/zizmor-advisories.yml b/.github/workflows/zizmor-advisories.yml index 2be4b1778..be9dced32 100644 --- a/.github/workflows/zizmor-advisories.yml +++ b/.github/workflows/zizmor-advisories.yml @@ -56,5 +56,9 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 install_args: zizmor - run: mise run zizmor-advisories diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index e3a1e11fc..16d8a9130 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -159,6 +159,10 @@ jobs: persist-credentials: false - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 (CLOUD-404 retry fix, now a release) with: + # Pinned to `batten.toml`'s `[[provision]]` version (CLOUD-1672). The + # digest above pins the ACTION; this pins the MISE it installs, which + # is a separate resolution the digest does not reach. + version: 2026.9.1 # The analyzer, plus the `jq` its receipt check needs: the task body is # `step-receipt check zizmor` first, and `step-receipt` reads # `mise tasks info --json` through `jq` before it decides whether to run diff --git a/AGENTS.md b/AGENTS.md index adbfff59e..920138458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,9 +110,9 @@ run in the web sandbox — read `mem:github-access` before doubting.) branch is rebased on current `origin/main`. "Green but stale" is not green. 3. **`mise run linear-check`.** Don't ready by hand: `land` readies, after its push, and a ready spent before that buys only draft-era skips (CLOUD-247). -4. **`mise run land`, backgrounded.** It drives the whole loop — **no timeout, no - cap, never the PR webhook** — and stops for three things only: a rebase - conflict, a failed `verify`, or red CI, re-drafting the PR. `mem:workflow/landing-loop`. +4. **`mise run land`, backgrounded.** Drives the loop — no wall clock, only + counts, never the PR webhook. Stops on a conflict, a failed `verify`, red CI, + or a spent lap budget. **When a stop says run it again, run it again.** 5. **Never re-run CI on an already-tested SHA.** Fast-forward means `main` takes the PR's exact, already-passed commits. Don't add push-to-`main` triggers. diff --git a/batten.toml b/batten.toml index e3a5fee84..c959cca7c 100644 --- a/batten.toml +++ b/batten.toml @@ -777,6 +777,25 @@ it rather than holding a foreground poll open.""" # separates the race from the resolution — is what would remove the cost, and it # is a follow-up rather than this branch's work. # +# `--onto` IS THE SECOND EXEMPTION, AND IT IS A CORRECTED FALSE DENY RATHER THAN +# A SOFTENING. `git rebase --onto ` names +# `origin/main`, so `contains` selected it — and it is not a lap at all: it +# replays a RANGE somewhere else, dropping commits from the bottom, which the +# landing loop has no notion of and cannot perform. So this row refused an +# operation its own remedy does not offer, and a `shape` row declares no +# `[[verdict]]` class and this one carries no `bypass_env`, so there was no +# sanctioned route to it whatsoever. Measured: a branch that had to drop four +# commits another issue's PR carried could not, and the refusal named `land` — +# which would have replayed all twelve. +# +# THE DISCRIMINATOR HAS TO BE A FLAG, which is the one thing `pattern` cannot +# see: the operand matcher drops flags before comparing. `unless_contains` is +# `contains` in the other polarity for exactly that reason, matched against the +# same LINE so an `echo --onto` cannot switch this deny off for the line after +# it. A lap never spells itself with `--onto`, because a lap has no upstream to +# name — it is `git rebase `, and `--onto` requires the upstream to be +# written out, which is what makes it a range move rather than a catch-up. +# # `crates/batten/tests/it/land_hand_stepping.rs` carries the whole predicate at # DEFAULT strictness, including an arm asserting this row refuses without # `--fail-on-warning`, so both a deleted row and a silently lowered one redden. @@ -787,14 +806,18 @@ scope = "mediated_call" severity = "deny" pattern = "git rebase" contains = "origin/main" +unless_contains = "--onto" reason = """ Rebasing onto `main` by hand is a lap of the loop `mise run land` drives, and \ taking it off the task is the race the contract refuses rather than a repair. \ Background `mise run land`; it fetches, rebases, verifies, pushes and waits, \ -and stops only for a rebase conflict, a failed `verify`, or red CI. A CONFLICT \ -is the one step that is yours, and it is why this warns rather than denies: the \ -engine replays without moving anything, so a conflict leaves no rebase to \ -`--continue`, and re-creating it by hand is the only route to resolving it.""" +and stops only for a rebase conflict, a failed `verify`, or red CI. TWO \ +SPELLINGS ARE LEFT ALONE, and neither is a lap: the conflict exits \ +(`--continue`, `--abort`, `--skip`) name no target at all, and \ +`git rebase --onto ` is a range move this loop \ +cannot perform. A CONFLICT is the one step that is yours, and this row denies \ +rather than warns because a warn row on this surface reaches nobody — so the \ +conflict stop is a limit this row names rather than one it can soften.""" # The toolchain pin, given a mechanism (CLOUD-271). AGENTS.md has said "never a # bare `cargo`" since long before anything enforced it, which non-negotiable @@ -5440,6 +5463,44 @@ scope = "mediated_call" preset = "mise" severity = "warn" +# THE SAME PRESET AT THE OTHER SURFACE (CLOUD-1672), and two rows rather than one +# because a rule row carries ONE scope. The `mise` manifest declares a scope per +# module since that row, so this enables its tree half while the row above +# enables its mediated half; each compiles only the modules at its own scope. +# +# `severity = "deny"` where the mediated row warns, and the asymmetry is the +# point rather than an inconsistency. That row nudges an agent toward a task it +# could have run; this one is a disagreement between two committed statements +# about which toolchain this commit runs, and it has already cost a release: on +# 2026-09-08 the action resolved a mise whose asset 404ed, every job of every +# workflow died in the install step inside eleven seconds, and v0.0.152 published +# carrying its schema and none of its seven binaries. +# +# `sources` MUST CARRY BOTH SIDES OF THE COMPARISON, which is the one way this +# row can be wrong while looking right. The predicate reads a workflow's declared +# version AND the `[[provision]]` row it must equal; drop `batten.toml` from this +# glob and the pin document is simply absent, the module abstains by design, and +# the gate reports clean forever. The module finds both by SHAPE rather than by +# name — a document carrying `jobs`, a document carrying `provision` — because a +# preset ships to every consumer and rule 1 forbids naming their paths; this glob +# is where this consumer says which of its files those are. +# +# `line_sources` for the pointers only. Every clause decides over the parsed +# document; the lines are read to place a finding at a job, and a pointer that +# cannot be placed costs the line rather than the finding. +[[rule]] +id = "mise-preset-tree" +kind = "policy" +scope = "tree" +preset = "mise" +sources = [ + "batten.toml", + ".github/workflows/*.yml", + ".github/workflows/*.yaml", +] +line_sources = [".github/workflows/*.yml", ".github/workflows/*.yaml"] +severity = "deny" + [[rule]] id = "pinned-toolchain-preset" kind = "policy" @@ -13280,6 +13341,18 @@ id = "module read first" kind = "document" target = "policy/ci-cache-declared.rego" +[[verdict]] +id = "job read empty" +gloss = "a pull-request job is read-only against a cache family that is declared and holds nothing on its architecture" +class = """ +The complement of `job write unsafe`, and the arrangement that row's own text leaves open: a job already carrying `save-if: false` against a family no trunk-side job writes where that job can read it. The `shared-key` is declared and the entry behind it holds nothing, so the job restores nothing on every run, forever -- and unlike a job that writes its own entry it has no later lap to inherit one, so the cost is a full cold build every time with no term that ever amortises. The failure is silent in the way this whole module is stated over: not a red check, just a green run that took longer, with the symptom in a bill nobody reads. The architecture is half the fact and the half that gets missed: rust-cache composes `runnerOS-runnerArch` into the key INSIDE the restore prefix, so a family written on arm64 and read on x64 is two entries that can never touch, and a reader left behind by an architecture migration keeps a valid `shared-key` and a warm writer that exist on different machines. Measured here on `batten-check`, which could not follow its family to arm64 because its scanner publishes no linux-aarch64 artifact: 683s, 1.9x the next longest job and the critical path of the workflow. The remedy is a warm writer on the reader's own architecture, or moving the reader to the architecture that already has one. +""" + +[[verdict.route]] +id = "module read first" +kind = "document" +target = "policy/ci-cache-declared.rego" + [[verdict]] id = "task resolve missing" gloss = "the task graph this rule walks could not be resolved, so cargo reachability was not decided" @@ -13487,3 +13560,57 @@ id = "host-dependencies-present" gloss = "the released batten resolves by the bare name every hook registration invokes it under" check = ["env", "batten", "--version"] repair = ["mise", "run", "deps-install"] + +# THE COMMIT PATH IS A PRECONDITION, AND NOTHING DECLARED IT (CLOUD-1398). +# +# The five rows above ask whether the ENGINE can run. None of them asks whether +# the engine is on the path a COMMIT takes, and in the container this repository +# provisions for itself those came apart: `git commit` ran neither `pre-commit` +# nor `commit-msg`, so every commit bypassed the gate while `batten startup` +# reported every row green. The session-start advisory's "every declared repair +# has already run this session; what is listed is what it did not fix" was TRUE +# and useless — the hooks were never in the declared set at all. +# +# `mise-tasks/doctor.sh` did see it and emitted two `::error::` lines, which is +# the CLOUD-1454 shape one layer up: a reporter is not a gate. Worse, its remedy +# named `.claude/hooks/session-start.sh`, a program `7d188580` deleted, so the +# refusal was right and its instruction could not be followed. Declaring the +# precondition with a repair that RUNS is the half that closes it. +# +# NOT `hk install`, AND THAT IS MEASURED RATHER THAN PREFERRED (the row's §8). +# On this container `hk` resolves only through the pin — `mise exec -- hk +# --version` answers 1.56.1 while `doctor`'s bare-`PATH` probe reports +# `program-not-on-path hk` and the pin record is absent. `hk install` generates a +# hook whose body calls `hk` BARE, so the hook it installs makes every +# `git commit` fail with `hk: not found`: a repair that reads as installed and +# breaks the thing it installed. `mise.toml`'s `session:git-hooks` is the +# symlink-based form that works, and it is already the session-start step — so +# this row adds an ASSERTION rather than a second installer. +# +# WHY A `batten` VERB HERE, WHERE `toolchain-is-provisioned` REFUSED ONE. That +# row's subject is a third party's install state, and an engine-side reader of it +# would put a specific tool's output format in `crates/batten` — non-negotiable +# rule 1. This row's subject is `$GIT_DIR/hooks`, which is git's own vocabulary +# and every consumer's question, so the engine-side reader names nobody. +# +# `doctor gate` AND NOT `doctor commit-gate`: a man page is committed as the +# hyphen-joined command path and the surface suite maps that filename back by +# replacing every hyphen, so a sub-verb carrying an internal one is not +# round-trippable. `crates/batten/src/surface.rs` records the measurement. +# +# AND THE SUB-VERB RATHER THAN BARE `doctor`, which is `host-dependencies-present`'s +# trap approached from the other side. A row decides on an exit status and cannot +# select one line out of a report, so `check = ["batten", "doctor"]` would fail +# here whenever any unrelated declared program was unreachable — the state this +# very container is in, on the `hk` reading above — and would then fire a +# git-hook repair that cannot fix that, reporting `repair-failed` forever over a +# gate that is installed. +# +# The repair writes under `$GIT_DIR/hooks`, which is outside the worktree and so +# outside `protected`; this key in the committed authority is the authorisation +# to run it. +[[startup]] +id = "commit-gate-installed" +gloss = "this clone's commit path runs the gate, so a commit made here cannot bypass it" +check = ["batten", "doctor", "gate"] +repair = ["mise", "run", "session:git-hooks"] diff --git a/completions/batten.bash b/completions/batten.bash index 212a20740..e28538149 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -310,6 +310,9 @@ _batten() { batten__subcmd__doctor,egress) cmd="batten__subcmd__doctor__subcmd__egress" ;; + batten__subcmd__doctor,gate) + cmd="batten__subcmd__doctor__subcmd__gate" + ;; batten__subcmd__doctor,help) cmd="batten__subcmd__doctor__subcmd__help" ;; @@ -325,6 +328,9 @@ _batten() { batten__subcmd__doctor__subcmd__help,egress) cmd="batten__subcmd__doctor__subcmd__help__subcmd__egress" ;; + batten__subcmd__doctor__subcmd__help,gate) + cmd="batten__subcmd__doctor__subcmd__help__subcmd__gate" + ;; batten__subcmd__doctor__subcmd__help,help) cmd="batten__subcmd__doctor__subcmd__help__subcmd__help" ;; @@ -562,6 +568,9 @@ _batten() { batten__subcmd__help__subcmd__doctor,egress) cmd="batten__subcmd__help__subcmd__doctor__subcmd__egress" ;; + batten__subcmd__help__subcmd__doctor,gate) + cmd="batten__subcmd__help__subcmd__doctor__subcmd__gate" + ;; batten__subcmd__help__subcmd__doctor,hooks) cmd="batten__subcmd__help__subcmd__doctor__subcmd__hooks" ;; @@ -2911,7 +2920,7 @@ _batten() { return 0 ;; batten__subcmd__doctor) - opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help mediator egress hooks session help" + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help mediator egress gate hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2970,8 +2979,38 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__gate) + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__doctor__subcmd__help) - opts="mediator egress hooks session help" + opts="mediator egress gate hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2998,6 +3037,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__help__subcmd__gate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__doctor__subcmd__help__subcmd__help) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -3945,7 +3998,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__doctor) - opts="mediator egress hooks session" + opts="mediator egress gate hooks session" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3972,6 +4025,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__doctor__subcmd__gate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__doctor__subcmd__hooks) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 0c0d5d861..98c8f87b0 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -584,33 +584,34 @@ complete -c batten -n "__fish_batten_using_subcommand spec" -l no-color -d 'Neve complete -c batten -n "__fish_batten_using_subcommand spec" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand spec" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand spec" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "gate" -d 'Diagnose whether this checkout\'s commit path runs the gate' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -655,6 +656,28 @@ complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from egress" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from egress" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from egress" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from hooks" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -701,6 +724,7 @@ complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "gate" -d 'Diagnose whether this checkout\'s commit path runs the gate' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' @@ -3619,6 +3643,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lint" -f -a "brief" -d 'Check a delegation brief against the handoff schema (any missing section is a violation)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "gate" -d 'Diagnose whether this checkout\'s commit path runs the gate' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "completions" -d 'Emit the shell completion script for one shell' diff --git a/completions/batten.zsh b/completions/batten.zsh index 7427ceb21..77f9501e9 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -999,6 +999,37 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(gate) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'-J[Emit byte-stable JSON instead of pointer lines]' \ +'--json[Emit byte-stable JSON instead of pointer lines]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (hooks) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -1081,6 +1112,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(gate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (hooks) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -6164,6 +6199,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(gate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (hooks) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -7450,6 +7489,7 @@ _batten__subcmd__doctor_commands() { local commands; commands=( 'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'egress:Diagnose whether the agent proxy would carry this container'\''s requests' \ +'gate:Diagnose whether this checkout'\''s commit path runs the gate' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -7461,11 +7501,17 @@ _batten__subcmd__doctor__subcmd__egress_commands() { local commands; commands=() _describe -t commands 'batten doctor egress commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__gate_commands] )) || +_batten__subcmd__doctor__subcmd__gate_commands() { + local commands; commands=() + _describe -t commands 'batten doctor gate commands' commands "$@" +} (( $+functions[_batten__subcmd__doctor__subcmd__help_commands] )) || _batten__subcmd__doctor__subcmd__help_commands() { local commands; commands=( 'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'egress:Diagnose whether the agent proxy would carry this container'\''s requests' \ +'gate:Diagnose whether this checkout'\''s commit path runs the gate' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -7477,6 +7523,11 @@ _batten__subcmd__doctor__subcmd__help__subcmd__egress_commands() { local commands; commands=() _describe -t commands 'batten doctor help egress commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__help__subcmd__gate_commands] )) || +_batten__subcmd__doctor__subcmd__help__subcmd__gate_commands() { + local commands; commands=() + _describe -t commands 'batten doctor help gate commands' commands "$@" +} (( $+functions[_batten__subcmd__doctor__subcmd__help__subcmd__help_commands] )) || _batten__subcmd__doctor__subcmd__help__subcmd__help_commands() { local commands; commands=() @@ -7832,6 +7883,7 @@ _batten__subcmd__help__subcmd__doctor_commands() { local commands; commands=( 'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'egress:Diagnose whether the agent proxy would carry this container'\''s requests' \ +'gate:Diagnose whether this checkout'\''s commit path runs the gate' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ ) @@ -7842,6 +7894,11 @@ _batten__subcmd__help__subcmd__doctor__subcmd__egress_commands() { local commands; commands=() _describe -t commands 'batten help doctor egress commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__doctor__subcmd__gate_commands] )) || +_batten__subcmd__help__subcmd__doctor__subcmd__gate_commands() { + local commands; commands=() + _describe -t commands 'batten help doctor gate commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__doctor__subcmd__hooks_commands] )) || _batten__subcmd__help__subcmd__doctor__subcmd__hooks_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 7f9a0ef5d..2db82089a 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -1477,6 +1477,26 @@ pub enum DoctorCommand { /// Emit the classification as byte-stable JSON. json: bool, }, + /// Whether this checkout's commit path runs the gate (CLOUD-1398). + /// + /// APPENDED LAST, for the reason [`DoctorCommand::Egress`] above it records: + /// this enum carries no `repr`, so a variant placed beside its neighbours + /// shifts every later discriminant and `mise run semver` reads that as a + /// break the crate has to declare. + /// + /// **A sub-verb AND a row in the bare report, which its two neighbours are + /// not** — and the difference is what it is for. `mediator` and `egress` sit + /// outside the report because they answer properties of the WORLD; this + /// answers a property of the CHECKOUT, so it belongs in the report too. The + /// sub-verb exists so a `[[startup]]` row can ask this question ALONE: a + /// startup row decides on an exit status, and bare `doctor` would fail it for + /// an unrelated unreachable program and then run a repair that cannot fix + /// that — `repair-failed`, forever, over a gate that is installed. + /// [`crate::doctor::diagnose_commit_gate`] is the one predicate both reach. + CommitGate { + /// Emit the diagnosis as byte-stable JSON. + json: bool, + }, } /// Subcommands of `generate`. @@ -1816,6 +1836,9 @@ fn doctor_of(matches: &ArgMatches) -> DoctorCommand { Some(("egress", matches)) => DoctorCommand::Egress { json: flag(matches, "json"), }, + Some(("gate", matches)) => DoctorCommand::CommitGate { + json: flag(matches, "json"), + }, // The bare verb reads `-J` from its OWN matches, which is where clap put // it when no subcommand was given. _ => DoctorCommand::Diagnose { diff --git a/crates/batten/src/config.rs b/crates/batten/src/config.rs index 8583d3036..28eb1faab 100644 --- a/crates/batten/src/config.rs +++ b/crates/batten/src/config.rs @@ -3366,6 +3366,7 @@ fn default_rules() -> Vec { max_age: None, requires_field: None, contains: None, + unless_contains: None, require_via: None, requires_key: None, reason: None, diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 56a76184a..74c4700c9 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -185,6 +185,38 @@ const GIT_REPO: &str = "git-repo"; /// This harness's plan/todo surface has been SURVEYED — which is a different /// question from whether it has one (CLOUD-472). const PLAN_SURFACE: &str = "plan-surface"; +/// This checkout's commit path runs the gate (CLOUD-1398). +/// +/// **A SUB-VERB ONLY, and never a row in the bare report** — which is the +/// opposite of where this landed first, corrected by the suite rather than by +/// argument. The reasoning that put it in the report was that a clone's commit +/// path is a property of the CHECKOUT rather than of the world: byte-stable +/// across machines, the same class as [`GIT_REPO`], unlike [`Mediator`] and +/// [`Egress`] which answer about this container. That is right about the +/// PREDICATE and wrong about the REPORT. +/// +/// `container-health` renders [`diagnose`] at session start, so a row here makes +/// every checkout with no git hooks announce itself as unhealthy. Batten +/// requires git hooks of nobody — whether a consumer's commit path should run a +/// gate is that consumer's judgement, and minting it in `crates/batten` is +/// non-negotiable rule 1's violation. Measured: it reddened +/// `contract_drift::a_session_seeded_at_session_start_is_silent_and_stays_silent` +/// over a fixture that has no hooks and wants none. +/// +/// So the split is the same one the whole engine is built on. The PREDICATE is +/// repo-agnostic and lives here, reachable as `doctor commit-gate`. The +/// JUDGEMENT lives in the consumer's own `[[startup]]` row — this repository +/// declares `commit-gate-installed` against it, and every other adopter is +/// untouched. +/// +/// # What it is NOT +/// +/// Not a claim that the gate PASSES — only that a commit in this clone runs it. +/// `mise-tasks/doctor.sh` asks the stronger question (can the hook resolve its +/// runner) by executing the hook under a probe, which a `read` verb may not do +/// (§5, CLOUD-170). This stats, exactly as [`on_path`] does and for the same +/// reason. +const COMMIT_GATE: &str = "commit-gate"; /// Every `command`-kind rule names a program the spawn can reach — on `PATH`, or /// through the project's pin. /// @@ -643,6 +675,137 @@ fn on_path(program: &str) -> bool { crate::rules::on_path_verbatim(program).is_some() } +/// The two hooks a commit in this clone must run for the gate to be on its path. +/// +/// `pre-commit` runs the gate; `commit-msg` carries the Conventional Commits +/// check release-plz's semver bump depends on, so leaving it out would assert the +/// expensive half and not the deciding one. Both are git's own names, which is +/// what keeps this list generic: every consumer's commit path runs these two. +const COMMIT_HOOKS: [&str; 2] = ["pre-commit", "commit-msg"]; + +/// The stable reason id for a commit path that does not run the gate. +const COMMIT_HOOK_MISSING: &str = "commit-hook-missing"; + +/// Where git would look for this checkout's hooks. +/// +/// **The COMMON directory, never the per-worktree one**, which is the trap this +/// helper exists to hold. [`crate::git::git_dir`] is per-worktree and is right +/// for receipts and for `HEAD`; hooks are not per-worktree, and git resolves +/// `hooks/` against the common dir — so a linked worktree checked against +/// `git_dir()` would report the gate missing while every commit in it runs the +/// gate correctly. +/// +/// **And `core.hooksPath` outranks both.** A repository that redirects its hooks +/// has hooks; a probe that ignored the key would call it bare and send its owner +/// to install a second copy somewhere git will never read. Resolved through +/// [`crate::git::config_value`], which reads the value across every scope, +/// exactly as git does. A relative value resolves against the worktree root, +/// which is git's own reading of it. +/// +/// `None` is could-not-look and never an empty answer: a directory that is not a +/// repository has already been reported by [`GIT_REPO`], and manufacturing a +/// second failure from it would double-count one fault. +fn hooks_dir(dir: &Path) -> Option { + if let Ok(Some(configured)) = crate::git::config_value(dir, "core.hooksPath") + && !configured.trim().is_empty() + { + let at = Path::new(configured.trim()); + return Some(if at.is_absolute() { + at.to_path_buf() + } else { + crate::git::repo_root(dir).ok()?.join(at) + }); + } + Some(Path::new(&crate::git::common_dir(dir).ok()?).join("hooks")) +} + +/// Whether `at` is a file this checkout's git would actually run. +/// +/// **Stats and follows, never executes** — [`on_path`]'s rule, one subject over: +/// running the hook to see whether it works is what `mise-tasks/doctor.sh` does +/// behind a probe variable, and a `read` verb may not reach user-supplied code +/// (§5, CLOUD-170). `metadata` follows the symlink deliberately: this +/// repository's own installer makes the hooks symlinks into the tree precisely +/// so the checked-in body stays the one authority, and a check that refused to +/// follow one would fail the shape it is meant to certify. +/// +/// The executable bit is what git itself requires, so it is what is asked. On a +/// platform without one, existence is the whole of the question git asks too. +fn is_runnable_hook(at: &Path) -> bool { + let Ok(meta) = at.metadata() else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// Whether this checkout's commit path runs the gate (CLOUD-1398). +/// +/// **The predicate, asked only where a caller asks for it** — `doctor +/// commit-gate`, which this repository's `commit-gate-installed` `[[startup]]` +/// row names as its `check`. Deliberately NOT pushed into [`diagnose`]; see +/// [`COMMIT_GATE`] for why a row there would make the engine assert a consumer +/// judgement it has no standing to make. +/// +/// One implementation, because a second is the defect the row it repairs was +/// filed about one layer along: `mise-tasks/doctor.sh` and the committed +/// authority disagreeing about what an installed gate is. +/// +/// **Pointer-only, and here that costs something worth naming.** The subjects are +/// the HOOK NAMES — `pre-commit`, `commit-msg` — and never the directory they +/// were looked for in, because that path is absolute, differs per machine, and +/// would defeat §6 byte-stability while leaking the layout of someone's disk +/// (rule 4). The names are git's own vocabulary rather than anything read out of +/// a file, which is the same line [`Check::subjects`] already draws for a +/// declared program. +/// +/// Could-not-look PASSES, which is this module's posture and not a softening: a +/// directory whose hooks path cannot be resolved is one [`GIT_REPO`] has already +/// failed on, and reading "I cannot tell" as "the gate is bypassed" would redden +/// every checkout on a machine where the read failed for an unrelated reason. +#[must_use] +pub fn diagnose_commit_gate(dir: &Path) -> Check { + let Some(hooks) = hooks_dir(dir) else { + return Check::passed(COMMIT_GATE); + }; + let missing: Vec = COMMIT_HOOKS + .iter() + .filter(|name| !is_runnable_hook(&hooks.join(name))) + .map(|name| (*name).to_owned()) + .collect(); + if missing.is_empty() { + Check::passed(COMMIT_GATE) + } else { + Check::failed_naming(COMMIT_GATE, COMMIT_HOOK_MISSING, missing) + } +} + +// THE OBLIGATION THIS ROW OWES, BOUND TO THE LINE THAT DECIDES (CLOUD-1398). +// +// The mutation is the row's own defect expressed as a patch: a check that looks +// at the hooks and reports `ok` whatever it finds. That is precisely what this +// repository shipped — `mise-tasks/doctor.sh` SAW the missing hooks and decided +// nothing, and the container went on bypassing the gate — so a survivor here is +// the original defect back, not a hypothetical one. +// +// The suite is the `[[startup]]` one, and with the row out of the bare report it +// is the ONLY tier that can catch this: the arm that discriminates is the repair +// loop, where a check stuck on `ok` reports the row provisioned, `--repair` +// never runs its argv, and the fixture's hooks stay absent. That is also what +// makes the declared precondition load-bearing rather than decorative. +//MUTANT-SUITE crates/batten/tests/it/startup.rs +//MUTANT hooks-check-reports-without-deciding|s@^ Check::failed_naming(COMMIT_GATE, COMMIT_HOOK_MISSING, missing)$@ Check::passed(COMMIT_GATE)@|a_clone_with_no_commit_hooks_fails_the_row_and_repair_installs_them + /// The check the declared transcript earns, or `None` where none is declared /// (CLOUD-1035). /// @@ -2634,6 +2797,28 @@ mod tests { // resolved config too, and whether it can still answer decides whether // `COMMAND_PROGRAMS` above is reading the pin or guessing. Its own row // rather than a mode of that one, because the repairs differ. + // + // `COMMIT_GATE` IS DELIBERATELY ABSENT, and it is the addition this case + // REFUSED (CLOUD-1398). It was pushed here for one revision, on the + // argument that a clone's commit path is a property of the CHECKOUT + // rather than of the world — the same class as `GIT_REPO`, byte-stable + // across machines. That argument is sound about the PREDICATE and wrong + // about the REPORT, and the suite is what said so: `container-health` + // renders this report at session start, so the row made every checkout + // without git hooks announce itself as unhealthy — measured, it broke + // `contract_drift::a_session_seeded_at_session_start_is_silent_and_stays_silent` + // over a fixture that has no hooks and does not want any. + // + // The line it crosses is non-negotiable rule 1. WHETHER a consumer's + // commit path should run a gate is that consumer's judgement; batten + // requires no git hooks of anybody. So the predicate stays in the engine + // as `doctor commit-gate`, where a caller asks for it, and the JUDGEMENT + // lives in the consumer's own `[[startup]]` row — this repository + // declares `commit-gate-installed` and every other adopter is untouched. + // + // The list stays EXPLICIT rather than becoming a count, which is what + // keeps this case load-bearing: a sub-verb leaking in is a diff on this + // line, and has to be argued for here. let names: Vec<&str> = diagnose(&scratch("bare-unchanged")) .checks .iter() diff --git a/crates/batten/src/hook.rs b/crates/batten/src/hook.rs index a3144afe7..7fb458eb3 100644 --- a/crates/batten/src/hook.rs +++ b/crates/batten/src/hook.rs @@ -7244,6 +7244,22 @@ fn matching_shape_rows<'a>(policy: &'a Policy, envelope: &Envelope) -> Vec<&'a R { continue; } + // The other polarity, against THE SAME LINE for the same reason + // (CLOUD-1477's sibling). The operand matcher drops flags before + // comparing, so a flag is the one thing a `pattern` cannot see and + // this is how a row excludes one. `rebase-not-hand-stepped` is the + // measured case: `--onto` makes a rebase a range move rather than the + // landing lap the row bans, and the row had no way to say so. + // + // Line-scoped rather than segment-scoped is load-bearing in the + // permissive direction here, which is the direction to fear from an + // exemption: matched against the segment, an `echo --onto` on line one + // would switch this deny off for a genuine lap on line two. + if let Some(exemption) = rule.unless_contains.as_deref() + && line.raw.contains(exemption) + { + continue; + } matched.push(rule); } } @@ -10034,6 +10050,7 @@ mod tests { max_age: None, requires_field: None, contains: contains.map(ToOwned::to_owned), + unless_contains: None, require_via: None, requires_key: None, reason: Some(format!("use the sanctioned path for {id}")), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 5d14ac7cb..f6acd4969 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -18007,9 +18007,42 @@ fn run_doctor(command: &cli::DoctorCommand, out: &mut dyn Write) -> Result run_doctor_mediator(json, out), cli::DoctorCommand::Session { json } => run_doctor_session(json, out), cli::DoctorCommand::Egress { json } => run_doctor_egress(json, out), + cli::DoctorCommand::CommitGate { json } => run_doctor_commit_gate(json, out), } } +/// Does a commit in this clone run the gate (CLOUD-1398)? +/// +/// **The same [`doctor::Check`] the bare report pushes, asked alone.** It is not +/// a second reading and must never become one: a `[[startup]]` row decides on an +/// exit status, so it needs a command that answers THIS question and no other — +/// bare `doctor` would fail it for an unrelated unreachable program and then run +/// a git-hook repair that cannot fix that. Rationale on +/// [`doctor::diagnose_commit_gate`] and on [`cli::DoctorCommand::CommitGate`]. +/// +/// One pointer line — the row's name, and on a failure the HOOK NAMES it is +/// missing. Never the directory they were looked for in: that path is absolute +/// and per-machine, which would defeat §6 byte-stability and put the layout of +/// somebody's disk in a diagnostic that promises not to carry one (rule 4). +fn run_doctor_commit_gate(json: bool, out: &mut dyn Write) -> Result { + let check = doctor::diagnose_commit_gate(&std::env::current_dir()?); + if json { + // A data channel emits its document unconditionally, including on the + // healthy path: JSON that is sometimes absent is unparseable. + writeln!(out, "{}", serde_json::to_string_pretty(&check)?)?; + } else { + output::line(out, &check)?; + } + // The parent's promise, inherited rather than re-decided: `ExitCode::Violation` + // is unreachable here, because a mediating harness reads `2` as a deny and + // "this clone has no commit hooks" is not "policy says no". + Ok(if check.ok { + ExitCode::Success + } else { + ExitCode::Usage + }) +} + /// Was the engine the registrations reach built from this tree (CLOUD-1349)? /// /// `doctor hooks` answers whether the registrations reach an engine; this answers diff --git a/crates/batten/src/policy.rs b/crates/batten/src/policy.rs index 195cc28d6..a2532a761 100644 --- a/crates/batten/src/policy.rs +++ b/crates/batten/src/policy.rs @@ -706,7 +706,10 @@ pub fn load( let registry = registry_for(verdicts)?; let mut emitted: BTreeSet = BTreeSet::new(); let mut bundles = Vec::new(); - let mut seen: BTreeSet<&str> = BTreeSet::new(); + // Keyed on the scope's WORD rather than the enum, so this set does not oblige + // `RuleScope` to carry `Ord` for one local lookup — the derive would be a + // public-surface change made to satisfy a private detail. + let mut seen: BTreeSet<(&str, &'static str)> = BTreeSet::new(); // Every predicate id published so far, and the module that published it — // the value is what lets the collision refusal name BOTH sides, which is the // difference between a pointer and a complaint. @@ -727,13 +730,24 @@ pub fn load( rule.id )) })?; - // Two rows naming one source is dead config: the second enablement - // decides nothing the first did not, and "which one denied me" is not a - // question a reviewer should have to answer. - if !seen.insert(source_key) { + // Two rows naming one source AT ONE SCOPE is dead config: the second + // enablement decides nothing the first did not, and "which one denied + // me" is not a question a reviewer should have to answer. + // + // THE KEY IS THE PAIR SINCE CLOUD-1672, and the scope half is what makes + // a preset spanning two surfaces enableable at all. A rule row carries + // one scope, so a preset deciding both is enabled by two rows — and + // keyed on the name alone the second reads as the duplicate this refuses. + // It is not: each row compiles the modules at its own scope, so they + // share a name and decide disjoint surfaces. Narrower rather than looser + // for a module or a bundle, where the pair still collapses to the name + // in practice because those decide exactly one surface. + if !seen.insert((source_key, rule.scope.as_str())) { return Err(UsageError::raise(format!( - "rule `{}` registers `{source_key}`, which another rule already registers", - rule.id + "rule `{}` registers `{source_key}` at scope `{}`, which another rule already \ + registers at that scope", + rule.id, + rule.scope.as_str() ))); } @@ -800,21 +814,30 @@ pub fn load( // silent dead gate" would have been a claim about a channel nobody // measured, which is the defect `rules/policy-modules.md` // records against its own earlier revisions. - if manifest.scope != rule.scope { + // + // SINCE CLOUD-1672 THE MANIFEST DECLARES A SCOPE PER MODULE, so the + // question is no longer "does the preset's one scope equal this + // row's" but "does ANY module here decide it". A preset spanning + // both surfaces is enabled by two rows, one per scope, and each row + // compiles only the modules at its own — which is what keeps a tree + // module from being compiled onto the call surface, where it would + // read keys the engine never builds there. + if !manifest.decides(rule.scope) { + let decided: Vec<&str> = manifest + .scopes() + .into_iter() + .map(crate::rules::RuleScope::as_str) + .collect(); return Err(UsageError::raise(format!( "rule `{}` enables the preset `{name}` at scope `{}`, but its modules \ - decide `{}` — at the wrong scope they read keys the engine never builds, \ + decide {} — at the wrong scope they read keys the engine never builds, \ so the rule would evaluate and refuse nothing", rule.id, rule.scope.as_str(), - manifest.scope.as_str() + decided.join(" and ") ))); } - let modules = manifest.modules; - let sources: Vec<(String, String)> = modules - .iter() - .map(|(path, source)| ((*path).to_owned(), (*source).to_owned())) - .collect(); + let sources: Vec<(String, String)> = manifest.modules_at(rule.scope); let bundle = compile(&rule.id, &sources, &pattern_data)?; let declared = bundle.declared.clone(); check_predicate_severity(rule, &declared, source_key)?; diff --git a/crates/batten/src/policy/presets/mise/action-version-matches-the-pin.rego b/crates/batten/src/policy/presets/mise/action-version-matches-the-pin.rego new file mode 100644 index 000000000..55641b0cb --- /dev/null +++ b/crates/batten/src/policy/presets/mise/action-version-matches-the-pin.rego @@ -0,0 +1,318 @@ +#MUTANT-SUITE crates/batten/tests/it/mise_preset.rs +#MUTANT version-may-differ|s@declared != pinned@false@|a_step_declaring_another_version_is_refused +#MUTANT version-may-be-absent|s@not declares_a_version(step)@false@|a_step_declaring_no_version_is_refused + +# The toolchain a workflow INSTALLS must be the one the tree PINS (CLOUD-1672). +# +# THE DEFECT CLASS IS TWO AUTHORITIES, not a missing pin, and the difference is +# what makes this a gate rather than a lint. A consumer pins its toolchain in a +# `[[provision]]` row — a version plus per-platform urls and digests, which is +# the whole point of pinning. The GitHub Action that installs that toolchain in +# CI resolves its OWN version, independently, at the moment each job starts. +# Nothing compares the two. So the repository can declare one toolchain and run +# another, for as long as nobody looks. +# +# MEASURED, AND THIS MODULE EXISTS BECAUSE OF IT. On 2026-09-08 the action +# resolved a release whose linux-x64 asset 404ed. Every job of every workflow in +# the consumer that found this died in the install step inside 11 seconds, a +# release published carrying its schema and none of its seven binaries, and the +# landing loop stalled across the whole fleet. The pin that would have prevented +# it was committed and correct the entire time; it simply did not reach the layer +# where the install happened. The provision row's own comment had even named the +# harm — a runner that updates itself under a container makes two sessions on one +# commit run different toolchains — one layer below where it then occurred. +# +# THE COMPARISON IS AGAINST THE COMMITTED ROW, NEVER A LITERAL. A module carrying +# the version itself would be a THIRD authority, and would go stale the first +# time the pin moved — reporting drift against a number nobody had updated, which +# is worse than the silence it replaces. The version never appears here. +# +# NO DOCUMENT PINS THE TOOL ⇒ THIS ROW ABSTAINS. A consumer that pins nothing has +# nothing to disagree with, and refusing there would be a verdict about their +# configuration rather than about drift between two of their own statements. +# That is also what keeps the rule honest about its own subject: it compares, and +# where there is nothing to compare it says nothing. +# +# STRUCTURAL DISCOVERY, NOT A FILENAME. A preset ships to every consumer, so +# non-negotiable rule 1 forbids naming their paths. The pin is found by selecting +# whichever parsed document carries a `provision` array; the workflows by +# selecting whichever carry a `jobs` object. Neither is named. The action +# coordinate IS written inline, and that is not a consumer fact: it is a public +# action, the same literal for everyone this preset reaches. + +# METADATA +# description: | +# Bound to the TREE surface: reads `input.tree.documents` and +# `input.tree.lines`, never the mediated `{call, facts}` shape. The other +# module in this preset is the mediated half; they are one preset because +# they are one subject, and the manifest declares a scope per module. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +package batten.mise_action_version + +import rego.v1 + +rules contains "action-version-matches-the-pin" + +# --- the two documents, found by shape ---------------------------------------- + +# A workflow is a document declaring jobs. `ci-hygiene` selects the same way and +# for the same reason: the consumer's directory layout is theirs, not ours. +workflow[path] := doc if { + some path, doc in input.tree.documents + is_object(doc.jobs) +} + +# The pin table is a document carrying `provision` rows. There may be more than +# one such document in principle; every one is read, and a row is matched by the +# TOOL NAME rather than by which file it came from. +pinned[name] := version if { + some _, doc in input.tree.documents + some row in doc.provision + name := row.name + version := row.version +} + +# The guard, for the reason `hk-fix-selection` measured on another module: an +# unguarded module reported findings against a fixture carrying none of its +# subjects. A tree with no workflow declaring jobs is answering for nothing here. +governed if count(object.keys(workflow)) > 0 + +# --- the action, and the tool it installs ------------------------------------- + +# THE COORDINATE IS INLINE AND THE PRESET EXEMPTION IS NOT SPENT ON IT. A +# `[[pattern]]` row is consumer config that a preset cannot read — it resolves to +# undefined and the rule decides nothing while loading clean, which is the dead +# gate `rules/policy-modules.md` records. But this is not a regex either: it is a +# substring test over a step's `uses:` value, so there is no pattern to declare +# and `patterns: &[]` on the manifest stays honest. +# +# WRITTEN AS A WHOLE COORDINATE, and that is a gate's requirement rather than a +# flourish. A bare repository name is indistinguishable from naming a vendor as +# an authority, which this repository refuses tree-wide; the refusal's exemption +# recognises the forms a coordinate actually takes — a step's `uses:` key, or a +# forty-character digest. So the value is written the way a workflow writes it +# and the parts are derived, which also means the name and the pin cannot drift +# apart inside this file. The digest is illustrative: nothing here compares it, +# because pinning the ACTION is a separate question from pinning the mise it +# installs, and conflating them is what let this defect exist. +coordinate := "uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518" + +action := split(trim_prefix(coordinate, "uses: "), "@")[0] + +# The tool the action installs, which is the `[[provision]]` row name to compare +# against. Derived from the action's own repository rather than written twice, so +# the two cannot drift apart inside this file. +tool := trim_suffix(split(action, "/")[1], "-action") + +action_step(step) if contains(object.get(step, "uses", ""), action) + +declares_a_version(step) if object.get(step, ["with", "version"], "") != "" + +# --- the steps being judged --------------------------------------------------- + +job_step contains [path, name, step] if { + some path, _ in workflow + some name, job in workflow[path].jobs + some step in job.steps +} + +# --- pointers ----------------------------------------------------------------- +# +# Rule 4's shape: a finding resolves a `{path, line}` and nothing else. Neither +# the declared version nor the pinned one reaches a finding — a CI log is public, +# and a version is the consumer's business even when it is wrong. +# +# NOT A FUNCTION, for the reason the same construction carries elsewhere: a +# `pointer(path, line_of(path))` spelling makes the whole refusal undefined +# whenever the line index cannot place its subject, because Rego propagates +# undefined through arguments — so a `line_sources` glob that drifted would +# switch the gate off silently. As a set, an unplaceable pointer costs the LINE +# and never the finding. +job_line contains [path, name, number] if { + some path, _ in workflow + some name, _ in workflow[path].jobs + some index, line in input.tree.lines[path] + trim_space(line) == concat("", [name, ":"]) + number := index + 1 +} + +job_placed(path, name) if { + some placement in job_line + placement[0] == path + placement[1] == name +} + +# --- 1. a declared version that disagrees with the pin ------------------------ + +disagrees(path, name) if { + some entry in job_step + entry[0] == path + entry[1] == name + step := entry[2] + action_step(step) + declares_a_version(step) + pinned_version := pinned[tool] + declared := object.get(step, ["with", "version"], "") + declared != pinned_version +} + +violation contains { + "rule": "action-version-matches-the-pin", + "verdict": "job pin other", + "subjects": [{"path": path, "line": number}], +} if { + governed + some placement in job_line + path := placement[0] + name := placement[1] + number := placement[2] + disagrees(path, name) +} + +violation contains { + "rule": "action-version-matches-the-pin", + "verdict": "job pin other", + "subjects": [{"path": path}], +} if { + governed + some path, _ in workflow + some name, _ in workflow[path].jobs + disagrees(path, name) + not job_placed(path, name) +} + +# --- 2. no declared version at all -------------------------------------------- +# +# The direction that produced the incident. An absent `version:` is not a +# smaller version of a wrong one: it hands the choice to the action, which makes +# the answer a property of WHEN the job ran rather than of what the tree says. +# Conditioned on the tool being pinned, so a consumer who pins nothing is not +# told to match something that does not exist. + +unpinned_reader(path, name) if { + some entry in job_step + entry[0] == path + entry[1] == name + step := entry[2] + action_step(step) + not declares_a_version(step) + _ := pinned[tool] +} + +violation contains { + "rule": "action-version-matches-the-pin", + "verdict": "job pin missing", + "subjects": [{"path": path, "line": number}], +} if { + governed + some placement in job_line + path := placement[0] + name := placement[1] + number := placement[2] + unpinned_reader(path, name) +} + +violation contains { + "rule": "action-version-matches-the-pin", + "verdict": "job pin missing", + "subjects": [{"path": path}], +} if { + governed + some path, _ in workflow + some name, _ in workflow[path].jobs + unpinned_reader(path, name) + not job_placed(path, name) +} + +# --- could not look ----------------------------------------------------------- +# +# A declared source that would not parse is not an absent one. Absent is +# not-applicable — this tree runs no such workflow — and unparsed means the +# boundary tried and failed. Spelling those the same way is how a gate reports +# green over a file it never read. + +violation contains { + "rule": "action-version-matches-the-pin", + "verdict": "workflow parse unread", + "subjects": [{"path": path}], +} if { + some path, _ in input.tree.missing + endswith(path, ".yml") +} + +violation contains { + "rule": "action-version-matches-the-pin", + "verdict": "workflow parse unread", + "subjects": [{"path": path}], +} if { + some path, _ in input.tree.missing + endswith(path, ".yaml") +} + +# --- cases -------------------------------------------------------------------- +# +# The load-time tier. It pins the predicates; it cannot prove the ENGINE builds +# the documents these rules read, which is `crates/batten/tests/it/mise_preset.rs`'s +# whole reason to exist. + +test_a_matching_version_is_clean if { + count(violation) == 0 with input as tree(pin("2026.9.1"), reader_with("2026.9.1")) +} + +test_a_step_declaring_another_version_is_refused if { + some finding in violation with input as tree(pin("2026.9.1"), reader_with("2026.9.3")) + finding.verdict == "job pin other" +} + +test_a_step_declaring_no_version_is_refused if { + some finding in violation with input as tree(pin("2026.9.1"), reader_bare) + finding.verdict == "job pin missing" +} + +# ANTI-VACUITY, AND IT IS WHAT SEPARATES THIS ROW FROM A BLANKET DEMAND: with no +# `provision` row for the tool there is nothing to compare against, so the same +# unpinned reader that is refused above is clean here. Without this case the +# second clause would pass for the wrong reason, and a consumer who pins no +# toolchain would be refused for a disagreement they are not having. +test_an_unpinned_tool_is_not_this_rules_business if { + count(violation) == 0 with input as tree(no_pin, reader_bare) +} + +# A step that is not this action is not this rule's business either, which is the +# other way a predicate over `uses:` goes wrong — matching a lookalike. +test_another_action_is_not_judged if { + count(violation) == 0 with input as tree(pin("2026.9.1"), other_action) +} + +test_an_unparsed_workflow_is_could_not_look if { + some finding in violation with input as {"tree": { + "documents": {".github/workflows/w.yml": {"jobs": {}}}, + "lines": {}, + "missing": {".github/workflows/broken.yml": "Unparsed"}, + }} + finding.verdict == "workflow parse unread" +} + +# --- fixtures ----------------------------------------------------------------- + +pin(version) := {"provision": [{"name": "mise", "version": version}]} + +no_pin := {"provision": [{"name": "something-else", "version": "1.0.0"}]} + +reader_with(version) := {"jobs": {"reader": {"steps": [{ + "uses": "jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518", + "with": {"version": version}, +}]}}} + +reader_bare := {"jobs": {"reader": {"steps": [{"uses": "jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518"}]}}} + +other_action := {"jobs": {"reader": {"steps": [{"uses": "actions/checkout@3d3c42e5"}]}}} + +tree(pins, reader) := {"tree": { + "documents": { + "pins.toml": pins, + ".github/workflows/pr.yml": reader, + }, + "lines": {".github/workflows/pr.yml": ["jobs:", " reader:"]}, + "missing": {}, +}} diff --git a/crates/batten/src/preset.rs b/crates/batten/src/preset.rs index 4fb5c9b34..a349db9b7 100644 --- a/crates/batten/src/preset.rs +++ b/crates/batten/src/preset.rs @@ -1,5 +1,5 @@ -//! One manifest per vendored preset: its identity, the surface it decides, the -//! modules it ships, and the refusal vocabulary they raise (CLOUD-1181). +//! One manifest per vendored preset: its identity, the modules it ships with the +//! surface each decides, and the refusal vocabulary they raise (CLOUD-1181). //! //! # Why this module exists rather than three tables //! @@ -8,7 +8,9 @@ //! `verdict.rs`'s `VENDORED` under a comment, and a branch exempting it from the //! `[[pattern]]` refusal. Nothing declared a preset, so a preset carried no //! identity beyond its name, no version, and — the load-bearing omission — no -//! SCOPE. +//! SCOPE. (It carried one scope for the manifest from CLOUD-1181 until +//! CLOUD-1672 moved it onto each module; the paragraph below is why it had to +//! exist at all, and `Manifest::modules` is why it could not stay singular.) //! //! Scope is where the silence costs most. `rules/policy-modules.md` //! opens on the class: a module reading a key from the wrong surface evaluates, @@ -47,6 +49,23 @@ use crate::rules::RuleScope; use crate::verdict::{DeclaredVerdict, VendoredVerdict, admit, read, run}; +/// One module inside a preset, with the surface it decides. +/// +/// A struct rather than a `(RuleScope, &str, &str)` tuple because the two string +/// fields are not interchangeable and a tuple lets them be swapped silently: a +/// pointer in the source position compiles, loads, and produces a bundle whose +/// every rule body is a comment. Named fields make that unwritable. +#[derive(Debug)] +#[non_exhaustive] +pub struct PresetModule { + /// The surface this module reads — `input.tree.*` or `input.call`/`facts`. + pub scope: RuleScope, + /// `/….rego`, the pointer a refusal prints. + pub pointer: &'static str, + /// The module source, `include_str!`d at build time. + pub source: &'static str, +} + /// One vendored preset, declared once. #[derive(Debug)] #[non_exhaustive] @@ -60,16 +79,32 @@ pub struct Manifest { /// "the binary changed", which the release tag already says. This moves when /// what the preset DECIDES changes. pub version: u32, - /// The surface these modules decide. + /// The modules, each declaring the surface IT decides. /// - /// The field the absence of which was the silent dead gate. A rule enabling - /// this preset at the other scope is refused at LOAD rather than evaluating - /// to an empty violation set — see `policy::load`. - pub scope: RuleScope, - /// The modules, as `(pointer, source)`. The pointer is `/…` - /// rather than a filesystem path: a preset has no path in the consumer's - /// tree, and printing one sends a reader looking for a file that is not there. - pub modules: &'static [(&'static str, &'static str)], + /// **The scope is per module rather than per manifest, and that is the whole + /// of CLOUD-1672's engine half.** It used to be one `scope` on the manifest, + /// which said a preset decides exactly one surface — true of every preset + /// that existed when it was written, and false the moment a subject has + /// something to say about both. `mise` is that subject: whether an agent + /// reproduces a task's argv is a question about a CALL, and whether the + /// toolchain a workflow installs matches the version the tree pins is a + /// question about the TREE. They are one preset because they are one + /// concern, and a consumer enabling `mise` should not have to know that the + /// engine once could not express that. + /// + /// What the manifest-level field bought is preserved rather than dropped: + /// enabling a preset at a scope none of its modules decide is still refused + /// at LOAD, by `decides` below, and the refusal now names the scopes the + /// preset DOES decide instead of a single one. A row selects the modules at + /// its own scope, so a tree module never loads under a mediated row — + /// without that selection it would read `input.tree` on the call surface and + /// trip `check_tree_paths_are_emittable`, which is the guard working and not + /// a shape to rely on. + /// + /// The pointer is `/…` rather than a filesystem path: a preset + /// has no path in the consumer's tree, and printing one sends a reader + /// looking for a file that is not there. + pub modules: &'static [PresetModule], /// The refusal classes these modules raise, with their glosses and remedies. /// /// Declared HERE rather than in `verdict.rs`'s native table, which is the @@ -89,6 +124,51 @@ pub struct Manifest { pub patterns: &'static [&'static str], } +impl Manifest { + /// Whether any module here decides `scope`. + /// + /// The load-time question `policy::load` asks. It replaces an equality + /// against one manifest-level scope, and the difference is that a preset + /// spanning two surfaces answers yes to both rather than having to pick. + #[must_use] + pub fn decides(&self, scope: RuleScope) -> bool { + self.modules.iter().any(|module| module.scope == scope) + } + + /// The surfaces this preset decides, in manifest order, without repeats. + /// + /// Used to NAME the alternatives in a refusal. A consumer who enabled the + /// wrong scope is told which ones exist rather than being left to read a + /// table inside the binary they cannot open — the gap CLOUD-1181 recorded + /// and which a single scope could only half answer. + #[must_use] + pub fn scopes(&self) -> Vec { + let mut seen: Vec = Vec::new(); + for module in self.modules { + if !seen.contains(&module.scope) { + seen.push(module.scope); + } + } + seen + } + + /// The modules deciding `scope`, as `(pointer, source)` for the compiler. + /// + /// **Selection, not filtering for tidiness.** A module compiled at the wrong + /// surface reads keys the engine never builds there, and Rego reads undefined + /// as "this body does not hold" — a dead gate byte-identical to a clean tree. + /// `check_tree_paths_are_emittable` catches that today, and this keeps the + /// case from arising rather than relying on a downstream guard to notice. + #[must_use] + pub fn modules_at(&self, scope: RuleScope) -> Vec<(String, String)> { + self.modules + .iter() + .filter(|module| module.scope == scope) + .map(|module| (module.pointer.to_owned(), module.source.to_owned())) + .collect() + } +} + /// Every vendored preset, in a stable order. /// /// The ONE authority. `preset_names`, the modules a preset ships, and the @@ -99,16 +179,17 @@ pub const MANIFESTS: &[Manifest] = &[ Manifest { name: "ci-hygiene", version: 1, - scope: RuleScope::Tree, modules: &[ - ( - "/spend-is-authorised.rego", - include_str!("policy/presets/ci-hygiene/spend-is-authorised.rego"), - ), - ( - "/wiring-can-be-reached.rego", - include_str!("policy/presets/ci-hygiene/wiring-can-be-reached.rego"), - ), + PresetModule { + scope: RuleScope::Tree, + pointer: "/spend-is-authorised.rego", + source: include_str!("policy/presets/ci-hygiene/spend-is-authorised.rego"), + }, + PresetModule { + scope: RuleScope::Tree, + pointer: "/wiring-can-be-reached.rego", + source: include_str!("policy/presets/ci-hygiene/wiring-can-be-reached.rego"), + }, ], verdicts: &[ VendoredVerdict { @@ -252,11 +333,11 @@ value is what does the work, and it is a boolean rather than the string `true`." Manifest { name: "commit-hygiene", version: 1, - scope: RuleScope::MediatedCall, - modules: &[( - "/no-empty-commit.rego", - include_str!("policy/presets/commit-hygiene/no-empty-commit.rego"), - )], + modules: &[PresetModule { + scope: RuleScope::MediatedCall, + pointer: "/no-empty-commit.rego", + source: include_str!("policy/presets/commit-hygiene/no-empty-commit.rego"), + }], verdicts: &[VendoredVerdict { id: "commit ship empty", gloss: "an empty commit records that somebody wanted a new SHA", @@ -271,30 +352,40 @@ the pipeline.", Manifest { name: "landing-loop", version: 1, - scope: RuleScope::Tree, modules: &[ - ( - "/graded-head-is-not-regraded.rego", - include_str!("policy/presets/landing-loop/graded-head-is-not-regraded.rego"), - ), - ( - "/already-landed-work-is-not-relanded.rego", - include_str!( + PresetModule { + scope: RuleScope::Tree, + pointer: "/graded-head-is-not-regraded.rego", + source: include_str!( + "policy/presets/landing-loop/graded-head-is-not-regraded.rego" + ), + }, + PresetModule { + scope: RuleScope::Tree, + pointer: "/already-landed-work-is-not-relanded.rego", + source: include_str!( "policy/presets/landing-loop/already-landed-work-is-not-relanded.rego" ), - ), - ( - "/lease-authorises-the-branch.rego", - include_str!("policy/presets/landing-loop/lease-authorises-the-branch.rego"), - ), - ( - "/rebase-conflict-stops-the-lap.rego", - include_str!("policy/presets/landing-loop/rebase-conflict-stops-the-lap.rego"), - ), - ( - "/lap-waits-on-one-answer.rego", - include_str!("policy/presets/landing-loop/lap-waits-on-one-answer.rego"), - ), + }, + PresetModule { + scope: RuleScope::Tree, + pointer: "/lease-authorises-the-branch.rego", + source: include_str!( + "policy/presets/landing-loop/lease-authorises-the-branch.rego" + ), + }, + PresetModule { + scope: RuleScope::Tree, + pointer: "/rebase-conflict-stops-the-lap.rego", + source: include_str!( + "policy/presets/landing-loop/rebase-conflict-stops-the-lap.rego" + ), + }, + PresetModule { + scope: RuleScope::Tree, + pointer: "/lap-waits-on-one-answer.rego", + source: include_str!("policy/presets/landing-loop/lap-waits-on-one-answer.rego"), + }, ], verdicts: &[ VendoredVerdict { @@ -437,41 +528,101 @@ abandon the other unread.", // a regex. Manifest { name: "mise", - version: 1, - scope: RuleScope::MediatedCall, - modules: &[( - "/task-over-executable.rego", - include_str!("policy/presets/mise/task-over-executable.rego"), - )], - verdicts: &[VendoredVerdict { - id: "task reach loose", - gloss: "a task's own program was reached directly rather than through the task", - class: "A project that defines a task has already decided how that work is \ + version: 2, + // TWO SURFACES, ONE CONCERN (CLOUD-1672). This is the manifest that made + // the per-module scope necessary, and the reason is that "mise" is a + // subject rather than a surface. Whether an agent reproduces a task's + // argv instead of running the task is a question about a CALL. Whether + // the toolchain a workflow installs is the one the tree pins is a + // question about the TREE. Splitting them into two presets would make a + // consumer enable two things to get one concern, and would put the split + // where the engine's old limit was rather than where the subject's is. + modules: &[ + PresetModule { + scope: RuleScope::MediatedCall, + pointer: "/task-over-executable.rego", + source: include_str!("policy/presets/mise/task-over-executable.rego"), + }, + PresetModule { + scope: RuleScope::Tree, + pointer: "/action-version-matches-the-pin.rego", + source: include_str!("policy/presets/mise/action-version-matches-the-pin.rego"), + }, + ], + verdicts: &[ + VendoredVerdict { + id: "task reach loose", + gloss: "a task's own program was reached directly rather than through the task", + class: "A project that defines a task has already decided how that work is \ invoked — which program, which arguments, and which environment composes around it. \ Running the task's program directly reproduces the argv and drops the rest, and the \ failure that produces looks like the failure being investigated rather than like a wrong \ invocation. The refusal names the task because the mapping it reads is task to argv: the \ remedy is in the finding rather than a file the reader has to go and search.", - routes: &[run( - "task run first", - "run the task the refusal names, through the task runner", - )], - }], + routes: &[run( + "task run first", + "run the task the refusal names, through the task runner", + )], + }, + VendoredVerdict { + id: "job pin other", + gloss: "a job installs a toolchain version that disagrees with the one the tree pins", + class: "A pinned toolchain is pinned so that every machine running this commit runs the \ +same one. The action that installs it in CI resolves its own version independently, so a \ +repository can declare one toolchain and run another with nothing comparing them — two \ +authorities on one number, which is the disagreement pinning exists to remove. Measured: a \ +consumer pinned its toolchain by version and digest, the action resolved a newer release whose \ +asset could not be fetched, and every job of every workflow died in the install step inside \ +eleven seconds; the release that followed published its schema and none of its binaries. The \ +pin was committed and correct throughout and simply did not reach that layer. The remedy is to \ +declare the version at the step so the install is the pin rather than a resolution.", + routes: &[read("pin read first", "the row that pins the tool")], + }, + VendoredVerdict { + id: "job pin missing", + gloss: "a job installs a pinned toolchain without saying which version", + class: "The other direction of the same defect, and the one that actually fires, because \ +an absent version reads as a default rather than as a decision. It is not a milder form of a \ +wrong version: it hands the choice to the installer, which makes the toolchain a property of \ +WHEN the job ran instead of what the commit says — so two runs of one commit can differ, and \ +the run that breaks is the one that happened to start after an upstream release. Conditioned on \ +the tool being pinned somewhere in the tree: a project that pins nothing is not asked to match \ +something that does not exist.", + routes: &[read("pin read first", "the row that pins the tool")], + }, + VendoredVerdict { + id: "workflow parse unread", + gloss: "a declared workflow would not parse, so nothing could be compared over it", + class: "Could-not-look, and it is deliberately not spelled the same way as absent. Absent \ +is not-applicable — there is no such workflow — while unparsed means the boundary tried and \ +failed. A module that iterates only the documents it could read reports green over the file it \ +could not, which is a gate reporting on a surface it never saw.", + routes: &[read( + "source read first", + "the workflow that would not parse", + )], + }, + ], patterns: &[], }, Manifest { name: "pinned-toolchain", version: 1, - scope: RuleScope::MediatedCall, modules: &[ - ( - "/pinned-program-via-the-pin.rego", - include_str!("policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego"), - ), - ( - "/pinned-program-probed-bare.rego", - include_str!("policy/presets/pinned-toolchain/pinned-program-probed-bare.rego"), - ), + PresetModule { + scope: RuleScope::MediatedCall, + pointer: "/pinned-program-via-the-pin.rego", + source: include_str!( + "policy/presets/pinned-toolchain/pinned-program-via-the-pin.rego" + ), + }, + PresetModule { + scope: RuleScope::MediatedCall, + pointer: "/pinned-program-probed-bare.rego", + source: include_str!( + "policy/presets/pinned-toolchain/pinned-program-probed-bare.rego" + ), + }, ], verdicts: &[ VendoredVerdict { @@ -519,16 +670,19 @@ declares, and a probe inside the pin's environment is already correct", Manifest { name: "shell-hygiene", version: 1, - scope: RuleScope::Tree, modules: &[ - ( - "/shebang-names-its-language.rego", - include_str!("policy/presets/shell-hygiene/shebang-names-its-language.rego"), - ), - ( - "/sibling-resolves.rego", - include_str!("policy/presets/shell-hygiene/sibling-resolves.rego"), - ), + PresetModule { + scope: RuleScope::Tree, + pointer: "/shebang-names-its-language.rego", + source: include_str!( + "policy/presets/shell-hygiene/shebang-names-its-language.rego" + ), + }, + PresetModule { + scope: RuleScope::Tree, + pointer: "/sibling-resolves.rego", + source: include_str!("policy/presets/shell-hygiene/sibling-resolves.rego"), + }, ], verdicts: &[ VendoredVerdict { @@ -555,11 +709,11 @@ asserted rather than tested.", Manifest { name: "trunk-based", version: 1, - scope: RuleScope::MediatedCall, - modules: &[( - "/no-force-push.rego", - include_str!("policy/presets/trunk-based/no-force-push.rego"), - )], + modules: &[PresetModule { + scope: RuleScope::MediatedCall, + pointer: "/no-force-push.rego", + source: include_str!("policy/presets/trunk-based/no-force-push.rego"), + }], verdicts: &[VendoredVerdict { id: "trunk push forced", gloss: "a force push rewrites a shared branch under whoever already fetched it", @@ -620,13 +774,14 @@ mod tests { fn every_class_a_preset_raises_is_declared_by_its_own_manifest() { for manifest in MANIFESTS { let declared: BTreeSet<&str> = manifest.verdicts.iter().map(|entry| entry.id).collect(); - for (pointer, source) in manifest.modules { - for token in raised_in(source) { + for module in manifest.modules { + for token in raised_in(module.source) { assert!( declared.contains(token.as_str()), - "`{}` raises `{token}` in `{pointer}`, which its manifest does not \ + "`{}` raises `{token}` in `{}`, which its manifest does not \ declare — the refusal would carry no gloss and no route", - manifest.name + manifest.name, + module.pointer ); } } @@ -645,7 +800,7 @@ mod tests { let raised: BTreeSet = manifest .modules .iter() - .flat_map(|(_, source)| raised_in(source)) + .flat_map(|module| raised_in(module.source)) .collect(); for entry in manifest.verdicts { assert!( diff --git a/crates/batten/src/rules.rs b/crates/batten/src/rules.rs index c9cad8c9a..57b7ce098 100644 --- a/crates/batten/src/rules.rs +++ b/crates/batten/src/rules.rs @@ -161,6 +161,11 @@ const SHAPE_PERMITS: &[&str] = &[ "when_value", "reason", "contains", + // The other polarity of `contains` (CLOUD-1477's sibling). `shape` only: it + // switches a deny OFF, and a column that does that has no business on a + // precondition row, where the same bytes would silently stop demanding a + // receipt. + "unless_contains", "require_via", "requires_key", "base", @@ -1554,6 +1559,37 @@ pub struct Rule { /// against the raw text of the same segment, quotes included. #[serde(default, skip_serializing_if = "Option::is_none")] pub contains: Option, + /// A literal whose presence **exempts** the command from a + /// [`RuleKind::Shape`] row that would otherwise fire (CLOUD-1477's sibling). + /// + /// [`Rule::contains`]'s twin in the other polarity, and it exists for the + /// same structural reason rather than as a general escape hatch: the operand + /// matcher drops flags before comparing, so a row cannot distinguish two + /// commands that differ only by a flag. `contains` is how a row DEMANDS one; + /// this is how a row EXCLUDES one, and without it the only expressible + /// predicate over a flag is "must be present". + /// + /// **The measured case is `git rebase`.** `rebase-not-hand-stepped` bans a + /// hand-driven landing lap — `git rebase origin/main` — by asking whether the + /// line names `origin/main`. But `git rebase --onto origin/main + /// ` names it too, and is not a lap at all: `--onto` moves a range + /// somewhere else, which is a history edit the landing loop cannot perform, + /// so the row refused an operation its own remedy does not offer and declared + /// no route to it. A lap never spells itself with `--onto`, because a lap has + /// no upstream to name. + /// + /// Matched against the raw text of the same line as `contains`, quotes + /// included, and for the same reason: line one's text must not exempt line + /// two's command. + /// + /// **The permissive direction is the one to fear here**, which is why it is + /// `shape` only and a literal rather than a regex. A `contains` that is wrong + /// makes a row fire less often than it reads; so does this — but this is the + /// column whose whole job is to switch a deny off, so it stays the narrowest + /// thing that can do the job and is named in the row's `reason` wherever it + /// is used. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unless_contains: Option, /// Narrow a [`RuleKind::Shape`] deny to a call that reached its program /// **without** the named mediator (CLOUD-271). /// @@ -3251,6 +3287,12 @@ pub const COLUMN_CENSUS: &[ColumnCensus] = &[ field: "contains", declares: Declares::NotFactBearing("a condition over a fact another column declared"), }, + ColumnCensus { + field: "unless_contains", + declares: Declares::NotFactBearing( + "a condition over a fact another column declared, in the exempting polarity", + ), + }, ColumnCensus { field: "require_via", declares: Declares::NotFactBearing("narrows how a requirement may be met"), @@ -3895,9 +3937,10 @@ impl Rule { /// /// Shared by the `content` and `tool` arms of [`Rule::validate_shape_columns`] /// because the argument is identical one column over, and stating it twice is - /// how the two would drift when a fifth modifier lands. Each of the four - /// names a COMMAND: `contains` and `require_via` are substring and via-shape - /// tests over an argv, `requires_key` asks whether the work is keyed before a + /// how the two would drift when a sixth modifier lands. Each of the five + /// names a COMMAND: `contains` and `unless_contains` are substring tests over + /// an argv in the two polarities, `require_via` is a via-shape test over the + /// same argv, `requires_key` asks whether the work is keyed before a /// command may run, and `base` exists only to tell `requires_key` which /// commits to read. A write and a structured call carry no argv for any of /// them to read, so left to load they are accepted, ignored on every call, @@ -3909,6 +3952,7 @@ impl Rule { fn refuse_command_modifiers(&self, keyed_on: &str) -> anyhow::Result<()> { for (field, present) in [ ("contains", self.contains.is_some()), + ("unless_contains", self.unless_contains.is_some()), ("require_via", self.require_via.is_some()), ("requires_key", self.requires_key.is_some()), ("base", self.base.is_some()), @@ -4375,7 +4419,7 @@ impl Rule { /// about all of them makes that failure impossible, and /// [`tests::every_optional_rule_field_is_classified_by_every_kind`] fails if /// a column is added here without being placed. - fn columns(&self) -> [(&'static str, bool); 54] { + fn columns(&self) -> [(&'static str, bool); 55] { [ // In the census because it is now per-kind, which is what makes // "required by every kind but the judge" a fact the existing @@ -4405,6 +4449,7 @@ impl Rule { ("check", self.check.is_some()), ("fix", self.fix.is_some()), ("contains", self.contains.is_some()), + ("unless_contains", self.unless_contains.is_some()), ("require_via", self.require_via.is_some()), ("requires_key", self.requires_key.is_some()), ("reason", self.reason.is_some()), @@ -13605,6 +13650,7 @@ mod tests { max_age: None, requires_field: None, contains: None, + unless_contains: None, require_via: None, requires_key: None, reason: None, diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index a6e603dc4..1571fca5e 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -462,6 +462,15 @@ mod tests { // task runner grades a value that runner's env block has already // corrected. "doctor egress".to_owned(), + // Whether this checkout's commit path runs the gate (CLOUD-1398). + // `read`, and structurally so: it resolves a directory through + // git's own config and stats two files. Nothing is EXECUTED, and + // the distinction is the whole reason this is admissible — the + // stronger question, whether each hook RUNS, needs executing one, + // which reaches user-supplied code and is exactly what a row on + // this allowlist may not do (CLOUD-170). Following the symlink is + // a read of the link's target, not a spawn of it. + "doctor gate".to_owned(), // The one row that is both a parent and a `read` verb of its own // (CLOUD-777). House style §2 spells the verb `doctor ` and // §8 promises what bare `doctor` does, so both are on the list — @@ -768,6 +777,7 @@ mod tests { "design audit".to_owned(), "doctor".to_owned(), "doctor egress".to_owned(), + "doctor gate".to_owned(), "doctor hooks".to_owned(), "doctor mediator".to_owned(), "doctor session".to_owned(), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index f68bcbd97..f2c62b275 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -2787,6 +2787,44 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON], }, + // THE ONE SUB-VERB THAT IS ALSO A ROW IN THE BARE REPORT (CLOUD-1398), and + // the asymmetry with the two rows above it is the decision rather than an + // oversight. `doctor mediator` and `doctor egress` are outside the report + // because they answer properties of the WORLD; whether this clone's commit + // path runs the gate is a property of the CHECKOUT, the same class as + // `git-repo`, so it is reported where a reader is already looking. + // + // It exists as a verb ANYWAY because a `[[startup]]` row decides on an exit + // status and has no way to select one row out of a report. `check = ["batten", + // "doctor"]` would fail the commit-gate row whenever an unrelated declared + // program was unreachable — measured in this container, where `hk` resolves + // only under `mise exec` — and then run a repair that installs git hooks, + // which cannot fix that, reporting `repair-failed` over a gate that is + // installed. One predicate answers both callers; this row is what lets the + // narrow caller ask it. + // + // `read`, and structurally so: it resolves a directory and stats two files. + // Nothing is executed — running a hook to see whether it works is what a + // shell task can do behind a probe variable, and reaching user-supplied code + // from the `filter(effect == read)` allowlist is CLOUD-170's actual invariant. + // + // `gate` RATHER THAN `commit-gate`, AND THE HYPHEN IS THE WHOLE REASON. A man + // page is committed as the hyphen-joined command path — `batten-doctor-gate.1` + // — and `surface.rs`'s own suite maps that filename back by replacing EVERY + // hyphen with a space. So a sub-verb whose name contains one is not + // round-trippable: `batten-doctor-commit-gate.1` reads back as the command + // `doctor commit gate`, which renders nothing, and three cases in + // `crates/batten/tests/it/surface.rs` go red at once. Measured here rather + // than reasoned — no verb on this surface has ever carried an internal + // hyphen, and this is why. Do not reintroduce one. + CommandDecl { + path: "doctor gate", + id: "doctor.gate", + about: "Diagnose whether this checkout's commit path runs the gate", + data_channel: true, + effect: Effect::Read, + flags: &[JSON], + }, CommandDecl { path: "doctor hooks", id: "doctor.hooks", diff --git a/crates/batten/tests/it/ci_cache_declared.rs b/crates/batten/tests/it/ci_cache_declared.rs index 430404815..40d18af22 100644 --- a/crates/batten/tests/it/ci_cache_declared.rs +++ b/crates/batten/tests/it/ci_cache_declared.rs @@ -449,6 +449,51 @@ fn the_same_key_on_the_same_architecture_is_still_refused() { ); } +#[test] +fn a_read_only_consumer_of_an_unwarmed_family_is_refused() { + // RULE 4, OVER THE ENGINE, and the architecture term is exactly why it has to + // be: the discriminator is a `runs-on` label the boundary projects out of the + // parsed job, which the module's own case supplies for itself. + // + // `WARM` writes `ci-` on `ubuntu-24.04-arm`; this reader is READ-ONLY on x64, + // so rust-cache composes a different key at `config.ts:93` and the entry it + // restores from does not exist. Unlike the writer in + // `the_same_key_on_another_architecture_is_not_the_same_family`, it has no + // later lap to inherit its own entry — it gets nothing, forever. That is the + // shape `batten-check` was in before `cache-warm-linux-x64` existed + // (CLOUD-1477), and the shape `bats` was in against `bats-` before + // CLOUD-1410. + let reader = READER.replace("runs-on: ubuntu-24.04-arm", "runs-on: ubuntu-latest"); + let root = tree( + "orphaned-reader", + MANIFEST, + &[("warm.yml", WARM), ("pr.yml", &reader)], + ); + let found = findings(&root); + assert!( + !found.is_empty(), + "a read-only consumer whose family is warmed on another architecture is refused" + ); +} + +#[test] +fn a_read_only_consumer_of_a_family_nothing_writes_is_refused() { + // The other direction on the KEY rather than the architecture, so the rule + // cannot pass by only ever noticing an architecture split. Same arm64 runner + // on both sides; the reader simply names a family `WARM` does not write. + let reader = READER.replace("shared-key: ci-", "shared-key: nobody-"); + let root = tree( + "unwritten-family", + MANIFEST, + &[("warm.yml", WARM), ("pr.yml", &reader)], + ); + let found = findings(&root); + assert!( + !found.is_empty(), + "a read-only consumer of a family nothing writes at all is refused" + ); +} + #[test] fn the_engine_reads_a_quoted_save_if_as_read_only() { // YAML SPELLS `false` TWO WAYS AND THE BOUNDARY DECIDES WHICH ARRIVES. A diff --git a/crates/batten/tests/it/land_hand_stepping.rs b/crates/batten/tests/it/land_hand_stepping.rs index 6524e4351..dfb3c36d5 100644 --- a/crates/batten/tests/it/land_hand_stepping.rs +++ b/crates/batten/tests/it/land_hand_stepping.rs @@ -27,8 +27,11 @@ //! conflict, "resolve and `git rebase --continue`, never a fresh `git rebase //! origin/main`". A `shape` row compares operand words with flags already //! DROPPED, so `pattern = "git rebase"` alone cannot tell the two apart and -//! would refuse the one step the contract requires by hand. `contains` matches -//! the raw text of the same segment, which is what separates them. +//! would refuse the one step the contract requires by hand. `contains` and +//! `unless_contains` match the raw text of the same LINE, which is what +//! separates them: the conflict exits fall out of the first column (they name no +//! target), and `--onto` needs the second, because a range move DOES name +//! `origin/main` while being an edit the loop cannot perform. //! //! So the deny case alone proves nothing: a row that refused every `git rebase` //! passes it while breaking conflict resolution, which is this defect pointed @@ -278,6 +281,41 @@ fn a_rebase_that_is_not_the_landing_lap_is_left_alone() { not_refused_by_the_row("git rebase -i HEAD~3"); } +/// A RANGE MOVE IS NOT A LAP, and this arm is why `unless_contains` exists. +/// +/// `--onto ` replays a range somewhere else. It +/// NAMES `origin/main` — so the `contains` half selected it — while being an +/// operation `land` cannot perform at all: the loop rebases a branch onto its +/// target and has no notion of dropping commits from the bottom of a range. So +/// the row refused a history edit its own remedy does not offer, with no +/// `[[verdict]]` class to override and no `bypass_env` to spend, which is a +/// wrongly refusing gate rather than a cost. +/// +/// The discriminator is the flag, and a flag is precisely what a `pattern` +/// cannot see: the operand matcher drops flags before comparing. A lap never +/// spells itself with `--onto`, because a lap has no upstream to name. +/// +/// MEASURED: the command below was refused while dropping four commits that a +/// PR's `closing-key-check` required be dropped, and there was no sanctioned +/// route to it. +#[test] +fn a_range_move_onto_main_is_not_this_rows_business() { + not_refused_by_the_row("git rebase --onto origin/main 321c72dd my-branch"); +} + +/// THE EXEMPTION IS LINE-SCOPED, and this is the arm that pins it. +/// +/// An exemption is the one column whose bug is permissive by construction, so it +/// gets the treatment `a_needle_on_another_line_does_not_qualify_this_one` gives +/// the demanding half. Matched against the SEGMENT rather than the line, an +/// `echo --onto` on line one would switch this deny off for a genuine lap on line +/// two — and the `&&` spelling is the same evasion written across one line. +#[test] +fn the_exemption_does_not_reach_another_lines_lap() { + denied_by_the_row("echo --onto\ngit rebase origin/main"); + denied_by_the_row("git log --onto-nothing\ngit rebase origin/main"); +} + /* The obligation CLOUD-1461's Ready block declares, in the shape `crates/batten/tests/it/mcp_dispatch.rs` already uses. diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index f843971a8..49005b240 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -174,6 +174,7 @@ mod memories; mod memory_injection; mod minted_facts; mod mise_pin_agreement; +mod mise_preset; mod mutate; mod mutation_declared_case; mod narrow_adoption; diff --git a/crates/batten/tests/it/mise_preset.rs b/crates/batten/tests/it/mise_preset.rs new file mode 100644 index 000000000..a16eeac63 --- /dev/null +++ b/crates/batten/tests/it/mise_preset.rs @@ -0,0 +1,254 @@ +//! The `mise` preset's TREE half decides over the compiled engine (CLOUD-1672). +//! +//! # Why this tier +//! +//! The module's own `test_` cases hand themselves a `documents` object, so they +//! are green over a shape the engine may never build — the hazard +//! `rules/policy-modules.md` names, and the reason both of its measured +//! instances were found by adding a tier like this rather than by reading. +//! +//! Here the hazard is sharper than usual, because this predicate reads **two +//! documents of different formats** and compares them. The workflow is YAML and +//! the pin table is TOML, and the module joins them by shape rather than by +//! filename — so what a fabricated `documents` object cannot show is whether the +//! engine actually delivers both. If it delivers only the workflows, the pin is +//! absent, the module abstains BY DESIGN, and the gate reports clean over every +//! tree forever. That failure is invisible to the load-time suite, invisible in +//! the output, and is precisely the drift the row exists to catch. +//! +//! # The case that carries the most +//! +//! `this_repository_is_clean_today` runs the preset over this checkout, where +//! all 24 committed workflows declare the version the `[[provision]]` row pins. +//! `a_drifted_version_in_this_repository_is_refused` is its discriminating twin: +//! it takes the same real tree, moves one workflow's version, and requires a +//! finding. Together they say the rule passes the tree it should and fails the +//! tree it should — which a fixture pair alone cannot, because a fixture +//! supplies its own pin document and so proves nothing about the glob. + +// Panicking on setup failure is the idiomatic way for a test to fail loudly. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule}; + +/// The row as `batten.toml` declares it, deserialized rather than +/// struct-literalled: `Rule` carries `deny_unknown_fields`, so this goes through +/// the same column census a consumer's config does. +/// +/// `sources` carries `batten.toml` BESIDE the workflow globs, and that is the +/// column this whole file exists to hold. Drop it and every case below still +/// compiles, the suite still runs, and the deny cases silently stop denying. +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "mise-preset-tree", + "kind": "policy", + "scope": "tree", + "preset": "mise", + "sources": [ + "batten.toml", + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + ], + "line_sources": [".github/workflows/*.yml", ".github/workflows/*.yaml"], + "severity": "deny", + })) + .expect("the row batten.toml declares") +} + +/// A scratch tree carrying one workflow and one pin table. +/// +/// No module is installed: a preset ships INSIDE the binary, so unlike an +/// in-repo module there is nothing to copy into the fixture. That is also what +/// makes this tier the only place the vendored bytes are exercised at all. +fn tree(name: &str, pins: &str, workflow: &str) -> PathBuf { + let root = common::scratch(&format!("mise-preset-{name}")); + common::write(&root, "batten.toml", pins); + common::write(&root, ".github/workflows/ci.yml", workflow); + root +} + +fn findings(root: &Path) -> Vec<(String, Option)> { + // The empty vocabulary is what a consumer hands a preset, and it is the + // point rather than a shortcut. A preset's verdicts are the binary's own + // vendored table; its `[[pattern]]` lookups would resolve to undefined for + // every real consumer, so a harness that declared any id would supply input + // no consumer supplies and the deny cases below would pass for the wrong + // reason — how `ci-hygiene` once shipped two dead predicates under a green + // `batten policy test` reporting 330 passed. + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &[], + recorders: &[], + }, + root, + ) + .expect("the read surface runs a policy row") + .findings + .into_iter() + .map(|finding| (finding.path, finding.line)) + .collect() +} + +/// The pin, reduced to the one row the predicate reads. +const PINS: &str = r#" +[[provision]] +name = "mise" +version = "2026.9.1" +"#; + +/// A pin table naming some other tool, so the mise row is genuinely absent. +const OTHER_PIN: &str = r#" +[[provision]] +name = "ripsecrets" +version = "0.1.11" +"#; + +fn workflow(step: &str) -> String { + format!( + r" +name: CI +on: + pull_request: +jobs: + build: + name: build + runs-on: ubuntu-latest + steps: +{step} +" + ) +} + +/// The action declaring a version. +fn declaring(version: &str) -> String { + workflow(&format!( + " - uses: jdx/mise-action@3c2e0cf8\n with:\n version: {version}" + )) +} + +/// The action declaring none — the shape that produced the incident. +fn bare() -> String { + workflow(" - uses: jdx/mise-action@3c2e0cf8") +} + +// --------------------------------------------------------------------------- +// The tree this preset actually defends. +// --------------------------------------------------------------------------- + +#[test] +fn this_repository_is_clean_today() { + let root = common::at_root(".") + .canonicalize() + .expect("this checkout is where the manifest says it is"); + let found = findings(&root); + assert!( + found.is_empty(), + "every committed workflow should install the version batten.toml pins: {found:?}" + ); +} + +#[test] +fn a_drifted_version_in_this_repository_is_refused() { + // THE DISCRIMINATING TWIN OF THE CASE ABOVE, and the only case in this file + // that can fail if the `sources` glob stops delivering `batten.toml`. Every + // other deny case writes its own pin table into a scratch tree, so it would + // keep passing over a row that never reads the consumer's. This one copies + // the real checkout's workflows and real pin table, moves ONE version, and + // requires the finding. + let real = common::at_root(".") + .canonicalize() + .expect("this checkout is where the manifest says it is"); + let root = common::scratch("mise-preset-real-drift"); + let pins = std::fs::read_to_string(real.join("batten.toml")).expect("the committed authority"); + common::write(&root, "batten.toml", &pins); + + let from = real.join(".github/workflows/ci.yml"); + let workflow = std::fs::read_to_string(&from).expect("the committed workflow"); + // The pin the sweep landed, moved to a version the provision row does not + // name. Asserted rather than assumed: if the committed spelling ever + // changes, this case must fail loudly rather than silently replacing nothing + // and then passing because the tree is clean. + assert!( + workflow.contains("version: 2026.9.1"), + "the committed workflow should carry the pinned version" + ); + common::write( + &root, + ".github/workflows/ci.yml", + &workflow.replacen("version: 2026.9.1", "version: 2026.9.3", 1), + ); + + let found = findings(&root); + assert!( + !found.is_empty(), + "a workflow installing a version the pin table does not name is refused" + ); +} + +// --------------------------------------------------------------------------- +// The two directions, over the engine. +// --------------------------------------------------------------------------- + +#[test] +fn a_step_declaring_another_version_is_refused() { + let root = tree("other", PINS, &declaring("2026.9.3")); + assert!( + !findings(&root).is_empty(), + "a declared version that disagrees with the pin is refused" + ); +} + +#[test] +fn a_step_declaring_no_version_is_refused() { + let root = tree("bare", PINS, &bare()); + assert!( + !findings(&root).is_empty(), + "an absent version hands the choice to the installer and is refused" + ); +} + +#[test] +fn a_matching_version_is_clean() { + // Without this the two cases above could both be produced by a rule that + // refuses every workflow carrying this action, which looks identical to one + // that compares. + let root = tree("match", PINS, &declaring("2026.9.1")); + let found = findings(&root); + assert!(found.is_empty(), "the pinned version is clean: {found:?}"); +} + +#[test] +fn an_unpinned_tool_is_not_this_rules_business() { + // ANTI-VACUITY. With no `[[provision]]` row for the tool there is nothing to + // disagree with, so the same bare step that is refused above is clean here. + // A consumer who pins no toolchain is not told to match a version that does + // not exist. + let root = tree("unpinned", OTHER_PIN, &bare()); + let found = findings(&root); + assert!( + found.is_empty(), + "a tool the tree does not pin is outside this rule: {found:?}" + ); +} + +#[test] +fn the_engine_reads_the_version_as_written() { + // A SHAPE QUESTION THE FIXTURES CANNOT ASK, and the class `ci_hygiene.rs` + // records for `cancel-in-progress`. A version like `2026.9.1` is a STRING to + // the YAML parser, but an unquoted `2026.9` would resolve to a float and a + // comparison against a TOML string would then never hold — refusing nothing, + // green suite. Driving the real parser is the only way to see which arrives. + let root = tree("quoted", PINS, &declaring("\"2026.9.1\"")); + let found = findings(&root); + assert!( + found.is_empty(), + "a quoted version is the same version: {found:?}" + ); +} diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 4d1f6926f..43ba583b0 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -1175,6 +1175,24 @@ const CENSUS: &[Verb] = &[ // since the value differs per machine. The remedy is a change to the // container's Environment variables field, and the verdict is what says // whether to make it. + // POINTER-ONLY OVER A SUBJECT THAT IS ENTIRELY PATHS (CLOUD-1398). This verb + // resolves a hooks directory — through `core.hooksPath` when set, the common + // git dir otherwise — and stats two files in it. Every one of those is an + // ABSOLUTE path that differs per machine, so emitting one would defeat §6's + // byte-stability and put the layout of somebody's disk in a diagnostic that + // promises not to carry one. + // + // What it emits instead is git's own vocabulary: the hook NAMES, `pre-commit` + // and `commit-msg`, as the failure's subjects. They are the actionable half — + // a reader knows which hook to install — and they are the same class as a + // declared program name in `command-programs`, which is a token the consumer + // already has rather than a byte read out of a file. + Verb { + path: "doctor gate", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { path: "doctor egress", args: &[], diff --git a/crates/batten/tests/it/preset_manifest.rs b/crates/batten/tests/it/preset_manifest.rs index 51b3d1738..bd7d3c9bf 100644 --- a/crates/batten/tests/it/preset_manifest.rs +++ b/crates/batten/tests/it/preset_manifest.rs @@ -87,8 +87,18 @@ fn the_same_preset_at_its_declared_scope_loads() { /// that fails at load. #[test] fn every_declared_preset_can_be_enabled_at_its_own_scope() { + // EVERY SCOPE, NOT THE MANIFEST'S ONE SCOPE (CLOUD-1672). A manifest declares + // a scope per module now, so a preset spanning two surfaces has two ways to be + // enabled and both must resolve. Looping the pair rather than a single value + // is what keeps the reachability claim honest for such a preset: enabling the + // half a consumer happens not to want must not be the half that fails. for manifest in batten::preset::MANIFESTS { - let root = Fixture::new(&format!("preset-enable-{}", manifest.name)) + for scope in manifest.scopes() { + let root = Fixture::new(&format!( + "preset-enable-{}-{}", + manifest.name, + scope.as_str() + )) .config(&format!( "version = 1\n\n\ [[rule]]\n\ @@ -98,8 +108,8 @@ fn every_declared_preset_can_be_enabled_at_its_own_scope() { {}\ preset = \"{}\"\n\ severity = \"deny\"\n", - manifest.scope.as_str(), - if manifest.scope == batten::rules::RuleScope::Tree { + scope.as_str(), + if scope == batten::rules::RuleScope::Tree { "sources = [\"**/*.md\"]\n" } else { "" @@ -107,13 +117,15 @@ fn every_declared_preset_can_be_enabled_at_its_own_scope() { manifest.name, )) .build(); - let output = common::run(&root, &["check"]); - assert_ne!( - output.status.code(), - Some(batten::exit::ExitCode::Usage.code()), - "`{}` is declared but cannot be enabled: {}", - manifest.name, - String::from_utf8_lossy(&output.stderr) - ); + let output = common::run(&root, &["check"]); + assert_ne!( + output.status.code(), + Some(batten::exit::ExitCode::Usage.code()), + "`{}` is declared but cannot be enabled at `{}`: {}", + manifest.name, + scope.as_str(), + String::from_utf8_lossy(&output.stderr) + ); + } } } diff --git a/crates/batten/tests/it/session_provisioning.rs b/crates/batten/tests/it/session_provisioning.rs index b048af935..d98618c36 100644 --- a/crates/batten/tests/it/session_provisioning.rs +++ b/crates/batten/tests/it/session_provisioning.rs @@ -257,6 +257,9 @@ struct Row { id: String, on: String, bounded: bool, + /// The `run` argv exactly as the row spells it, so a case can ask WHAT a + /// handler dispatches and not only that it is declared (CLOUD-1620). + run: String, } /// The committed `[[hook.handler]]` rows, in declaration order, read as TEXT. @@ -290,6 +293,7 @@ fn handler_rows() -> Vec { id: field("id").unwrap_or_default(), on: field("on").unwrap_or_default(), bounded: field("timeout_ms").is_some(), + run: field("run").unwrap_or_default(), }); } rows @@ -482,6 +486,197 @@ fn the_committed_provisioning_declares_every_step_in_order() { ); } +/// The two entry points that compile the engine, named once. +/// +/// `install:local` declares `depends = ["build:release"]`, so either spelling +/// reaching a session-start task is the same four-minute compile. +const COMPILE_ENTRY_POINTS: [&str; 2] = ["install:local", "build:release"]; + +/// The task each `session-start` row dispatches, plus every task those bodies +/// reach through `mise run`. +/// +/// Bounded rather than fully transitive: the delegation in this manifest is one +/// hop (`session:doctor` → `doctor`, `session:identity` → `attribution-identity`) +/// and a fixed depth cannot loop on a manifest that gains a cycle. Depth is +/// asserted to have found something by `the_reachable_set_is_not_empty`. +fn reachable_session_task_bodies() -> Vec<(String, String)> { + let manifest = + std::fs::read_to_string(at_root("mise.toml")).expect("the task manifest is readable"); + + // THE EXECUTABLE SURFACE, NEVER THE WHOLE BLOCK, and the distinction is not + // pedantry — it is what makes this a gate rather than a prose scanner. The + // first draft returned everything under the header, and both cases below went + // red against a manifest that was already correct: `session:batten`'s comment + // NAMES `install:local` while explaining why it no longer runs it, and + // `session:install`'s names `cargo-zigbuild`. A comment is the one place a + // retired mechanism is supposed to still be written down. + // + // So this reads `run` and `depends` — what mise will actually execute — + // discarding the commentary around them. `depends` is here because + // `install:local` carries `depends = ["build:release"]`: a task can compile + // without its own body naming a compiler. + // + // `[tasks."name"]` and `[tasks.name]` are both spelled in this file. + let body_of = |name: &str| -> Option { + let headers = [ + format!("\n[tasks.\"{name}\"]\n"), + format!("\n[tasks.{name}]\n"), + ]; + let block = headers + .iter() + .find_map(|header| manifest.split(header.as_str()).nth(1))?; + let block = block.split("\n[").next().unwrap_or(block); + + let value = |key: &str| -> String { + let Some(rest) = block.split(&format!("\n{key} = ")).nth(1) else { + return String::new(); + }; + rest.strip_prefix("\"\"\"").map_or_else( + || rest.lines().next().unwrap_or_default().to_owned(), + |triple| triple.split("\"\"\"").next().unwrap_or(triple).to_owned(), + ) + }; + Some(format!("{}\n{}", value("run"), value("depends"))) + }; + + let mut queue: Vec = session_rows() + .iter() + .filter_map(|row| { + // `run = ["mise", "run", ""]` — the task is the word after the + // `run` verb. A row dispatching something that is not `mise run` has + // no task body to read and drops out here. + let argv: Vec<&str> = row + .run + .split(['[', ']', ',', '"']) + .map(str::trim) + .filter(|word| !word.is_empty()) + .collect(); + match argv.as_slice() { + ["mise", "run", task, ..] => Some((*task).to_owned()), + _ => None, + } + }) + .collect(); + + let mut seen = Vec::new(); + let mut found = Vec::new(); + for _ in 0..4 { + let mut next = Vec::new(); + for name in std::mem::take(&mut queue) { + if seen.contains(&name) { + continue; + } + seen.push(name.clone()); + let Some(body) = body_of(&name) else { + continue; + }; + for word in body.split_whitespace().collect::>().windows(3) { + if word[0].ends_with("mise") && word[1] == "run" { + next.push(word[2].trim_matches(['"', '\'']).to_owned()); + } + } + found.push((name, body)); + } + queue = next; + } + found +} + +#[test] +fn the_reachable_set_is_not_empty() { + // THE ANTI-VACUITY CASE FOR THE SCAN BELOW, and it is the same class as + // `every_handler_row_is_read` one case down: a header this manifest + // re-spells, or a `run` argv shape the parser stops recognising, returns an + // empty set and the compiler scan then passes over nothing at all. + let bodies = reachable_session_task_bodies(); + assert!( + bodies.len() >= session_rows().len(), + "every session-start row's task body resolves, found {} for {} rows", + bodies.len(), + session_rows().len() + ); + assert!( + bodies.iter().any(|(name, _)| name == "session:batten"), + "the row that used to carry the compile is in the scanned set" + ); + assert!( + bodies + .iter() + .any(|(name, _)| name == "attribution-identity"), + "delegation is followed: `session:identity` reaches `attribution-identity`" + ); +} + +/// No session-start handler compiles the engine (CLOUD-1620). +/// +/// # Why this is a case and not a comment +/// +/// The premise that put a compile here was that consumer #1 must judge the +/// engine it ships, and it is a premise a reader can re-derive at any time — +/// which is how it arrived. What it cost is not re-derivable from reading the +/// manifest: measured 2026-09-08, `session:batten` at 224.87s and +/// `session:identity` at ~215s, ~440s of a ~450s session start, to produce a +/// binary byte-identical to the release already on PATH. +/// +/// So the property is asserted rather than explained. `install.sh` cannot +/// compile — `install-does-one-thing` in `batten.toml` bans `cargo` from it +/// outright — and this is that same ban one layer up, over the tasks a session +/// start actually dispatches. +/// +/// # Skew is not what this weakens +/// +/// A release binary older than the tree's config is a real failure (CLOUD-1326, +/// measured at ~15 hours of silently permitting gates). It is `batten doctor +/// mediator`'s to detect (CLOUD-1630) and this case takes no position on it: a +/// branch that needs the tree's engine runs `mise run install:local`, which stays +/// reachable and is what `verify` and `batten-check` already do. +// THE MUTATION TARGETS VACUITY, NOT THE ASSERTION, and the first draft got this +// backwards. It emptied `COMPILE_ENTRY_POINTS`, which makes the scan below loop +// over nothing and PASS — a survivor, so the mutation would have reported the +// suite as non-discriminating while the real defect it names went unmodelled. +// +// The way this gate dies quietly is the reachable set going empty: a `run` argv +// spelled differently, a task header re-spelled, and every case here passes over +// zero bodies. So the mutation makes the extraction yield nothing, and the case +// it must redden is the anti-vacuity one — which is the only case that can tell +// "nothing compiles" from "nothing was read". +//MUTANT-SUITE crates/batten/tests/it/session_provisioning.rs +//MUTANT session-start-compiles|s@^ \["mise", "run", task, \.\.\] => Some((\*task).to_owned()),$@ ["mise", "run", _task, ..] => None,@|the_reachable_set_is_not_empty +#[test] +fn no_session_start_step_compiles_the_engine() { + for (name, body) in reachable_session_task_bodies() { + for entry in COMPILE_ENTRY_POINTS { + assert!( + !body.contains(entry), + "the session-start task `{name}` reaches `{entry}`, which compiles \ + the engine on the one path that may not assume a toolchain — \ + install the release and let `doctor mediator` decide staleness" + ); + } + } +} + +/// A `cargo` fallback is allowed, and only behind a resolution guard. +/// +/// `attribution-identity` keeps `cargo run` for the clone whose provisioning has +/// not happened yet — it has to set an identity before it writes a commit. That +/// is a fallback, not a path: the guard is what keeps it off the session-start +/// path, and without this case the guard could be dropped and the case above +/// would still pass, since `cargo run -p batten` names neither entry point. +#[test] +fn a_cargo_fallback_in_a_session_task_is_guarded() { + for (name, body) in reachable_session_task_bodies() { + if !body.contains("cargo") { + continue; + } + assert!( + body.contains("command -v batten"), + "the session-start task `{name}` spawns `cargo` with nothing \ + establishing that the installed binary is absent first" + ); + } +} + #[test] fn every_handler_row_is_read() { // THE ANTI-VACUITY CASE FOR THE SCAN ABOVE, and it is the reason the scan is diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index c0a7d560b..afc2dce06 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -888,6 +888,24 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "doctor gate", + "id": "doctor.gate", + "about": "Diagnose whether this checkout's commit path runs the gate", + "effect": "read", + "data_channel": true, + "flags": [ + { + "name": "json", + "short": "J", + "long": "json", + "takes_value": false, + "positional": false, + "help": "Emit byte-stable JSON instead of pointer lines" + } + ], + "subcommands": [] + }, { "path": "doctor hooks", "id": "doctor.hooks", @@ -3057,6 +3075,10 @@ expression: stdout_of(&output) "id": "doctor.egress", "path": "doctor egress" }, + { + "id": "doctor.gate", + "path": "doctor gate" + }, { "id": "doctor.hooks", "path": "doctor hooks" diff --git a/crates/batten/tests/it/startup.rs b/crates/batten/tests/it/startup.rs index 4f261b8ae..7b2970d27 100644 --- a/crates/batten/tests/it/startup.rs +++ b/crates/batten/tests/it/startup.rs @@ -120,6 +120,259 @@ fn repair_fixes_and_says_so_while_the_bare_verb_changes_nothing() { assert_eq!(stdout(&again), "makes-it ok\nstartup: 1 row(s), 0 failed\n"); } +/// The commit gate as a declared precondition, end to end (CLOUD-1398). +/// +/// **The one case in this file whose check is a REAL precondition**, which the +/// module header says the others deliberately avoid — and the exception is the +/// point rather than a lapse. Every other fixture uses `true`/`false`/`test -f` +/// because a real check would be testing the container; this row exists because +/// nothing was testing the container, and a fixture that stubbed the predicate +/// out would reproduce exactly the defect it is here to catch. `doctor +/// commit-gate` is batten's own verb over a scratch repository, so what is under +/// test is still this tree and not the host. +/// +/// The repair sets `core.hooksPath`, which is argv-only — no shell between the +/// declaration and what runs (`batten.toml`'s own bound on `check`/`repair`) — +/// and it exercises the branch of [`doctor::hooks_dir`] that a repository +/// redirecting its hooks takes. A `git init` leaves `.git/hooks` carrying only +/// `*.sample` files, so the fixture starts genuinely bypassed rather than being +/// made so. +/// +/// The third run is what the row's §7 asks for and is not decoration: `ok` +/// WITHOUT `repaired` is the only thing separating a repair that worked from one +/// that exits zero having fixed nothing. +#[test] +fn a_clone_with_no_commit_hooks_fails_the_row_and_repair_installs_them() { + let dir = scratch("startup-commit-gate"); + write(&dir, "batten.toml", "version = 1\n\n"); + common::init_repo(&dir); + + // The hooks the repair will point git at. Written before the row, so the + // repair is a redirection and never a creation — it is `session:git-hooks`'s + // shape (an existing body, linked into place) rather than a second author of + // the hook. + let hooks = dir.join("committed-hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + for name in ["pre-commit", "commit-msg"] { + let at = hooks.join(name); + std::fs::write(&at, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&at, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + // THE BUILT BINARY BY ABSOLUTE PATH, never bare `batten` — and this is the + // one thing this case got wrong first. `startup` spawns a row's `check` by + // resolving it the way any spawn does, so a bare name reaches whatever + // `batten` is INSTALLED on PATH. That made this case grade the container's + // install currency rather than this tree: it passed while the installed copy + // happened to carry the verb, and went red the moment the verb was renamed + // here. Which is CLOUD-1650's own subject, arriving inside CLOUD-1398's suite. + let bin = json(env!("CARGO_BIN_EXE_batten")); + let rows = row( + "commit-gate-installed", + &format!("[{bin}, \"doctor\", \"gate\"]"), + Some(&format!( + "[\"git\", \"config\", \"core.hooksPath\", {}]", + json(hooks.to_str().unwrap()) + )), + ); + write(&dir, "batten.toml", &format!("version = 1\n\n{rows}")); + + // A fresh clone: `.git/hooks` holds samples and nothing git will run, so a + // commit here bypasses the gate. This is the state the row was filed over. + let bare = startup(&dir, &[]); + assert_eq!(out_code(&bare), 1); + assert_eq!( + stdout(&bare), + "commit-gate-installed failed not-provisioned\nstartup: 1 row(s), 1 failed\n", + "a clone whose commit path does not run the gate must say so" + ); + + let repaired = startup(&dir, &["--repair"]); + assert_eq!(out_code(&repaired), 0); + assert_eq!( + stdout(&repaired), + "commit-gate-installed ok repaired\nstartup: 1 row(s), 0 failed\n" + ); + + let again = startup(&dir, &["--repair"]); + assert_eq!( + stdout(&again), + "commit-gate-installed ok\nstartup: 1 row(s), 0 failed\n", + "a repair that runs every time is a repair whose check is wrong" + ); +} + +/// The sub-verb answers alone, and answers the same thing the row does. +/// +/// **This is the property the `[[startup]]` row depends on and nothing else +/// asserts.** A row decides on an exit status, so `doctor commit-gate` has to +/// carry the commit-gate verdict and NO other check's — if it ever started +/// folding in a sibling row, the committed row would fail for an unrelated +/// unreachable program and fire a git-hook repair that cannot fix it. +#[test] +fn the_commit_gate_sub_verb_answers_only_its_own_question() { + let dir = scratch("startup-commit-gate-verb"); + write(&dir, "batten.toml", "version = 1\n\n"); + common::init_repo(&dir); + + let bare = batten() + .current_dir(&dir) + .args(["doctor", "gate"]) + .output() + .expect("the binary runs"); + assert_eq!(out_code(&bare), 1, "doctor never renders a policy verdict"); + assert_eq!( + stdout(&bare), + "commit-gate failed commit-hook-missing commit-msg pre-commit\n", + "the hook NAMES are the actionable subjects; the directory is a path and never emitted" + ); + + let hooks = dir.join(".git").join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + for name in ["pre-commit", "commit-msg"] { + let at = hooks.join(name); + std::fs::write(&at, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&at, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + let healthy = batten() + .current_dir(&dir) + .args(["doctor", "gate"]) + .output() + .expect("the binary runs"); + assert_eq!(out_code(&healthy), 0); + assert_eq!(stdout(&healthy), "commit-gate ok\n"); +} + +/// A non-executable hook is not a hook — which is git's reading on unix, and +/// deliberately not its reading everywhere. +/// +/// The arm that separates this check from a file-existence one: "present but git +/// will not run it" is indistinguishable from healthy to a probe that only stats. +/// +/// **`cfg!` IN THE BODY RATHER THAN `#[cfg(unix)]` ON THE CASE**, which is +/// `platform-gated-test-added`'s own remedy and the reason it exists. Narrowing +/// the case to unix would turn a red leg green while leaving the Windows +/// contract unstated and one arm never compiled on the host that authors it. +/// Both arms compile on every target here, and the Windows expectation is +/// written down rather than skipped. +/// +/// The two readings are both correct, which is the substance: git on Windows has +/// no executable bit to consult and runs any hook file it finds, so a present +/// file IS a live hook there. `is_runnable_hook` tracks what git will actually do +/// on each platform. Measured on the `windows` job at 9891539c, where asserting +/// the unix reading everywhere gave `left: "commit-gate ok"` against +/// `right: "… failed commit-hook-missing pre-commit"`. +/// +/// The sibling cases need no such split: a `git init` leaves no `pre-commit` at +/// all, so they turn on existence, which reads the same on both platforms. +#[test] +fn a_present_but_unrunnable_hook_reads_as_missing() { + let dir = scratch("startup-commit-gate-mode"); + write(&dir, "batten.toml", "version = 1\n\n"); + common::init_repo(&dir); + let hooks = dir.join(".git").join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + std::fs::write(hooks.join("commit-msg"), "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions( + hooks.join("commit-msg"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + // Present, and mode 0644: git skips it silently. + std::fs::write(hooks.join("pre-commit"), "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions( + hooks.join("pre-commit"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + } + + let out = batten() + .current_dir(&dir) + .args(["doctor", "gate"]) + .output() + .expect("the binary runs"); + let expected = if cfg!(unix) { + // The mode bit is real here, so a 0644 hook is one git will skip. + "commit-gate failed commit-hook-missing pre-commit\n" + } else { + // No executable bit exists for git to consult, so a present file is a + // live hook and the row is honestly satisfied. + "commit-gate ok\n" + }; + assert_eq!( + stdout(&out), + expected, + "only the unrunnable one is named — a subject list is what a reader acts on" + ); +} + +/// The row's own remedy names a path that exists. +/// +/// **The assertion that stops CLOUD-1398 recurring, and it is the whole reason +/// the row exists at all.** `doctor.sh` told an agent to run +/// `.claude/hooks/session-start.sh` for its entire life after `7d188580` deleted +/// that program: the refusal was correct and its instruction could not be +/// followed. Prose cannot hold that; a case over the tracked file can. +/// +/// It asserts the SUCCESSOR resolves rather than that the old name is absent, +/// because those are different claims — a remedy could name a second dead path +/// and pass the weaker one. +#[test] +fn every_remedy_the_hook_check_prints_names_something_that_resolves() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("the workspace root is two levels above the crate"); + // THE COMMITTED AUTHORITY IS THE SUBJECT, not `mise-tasks/doctor.sh`, and + // that narrowing is measured rather than chosen. The shell task's two + // `::error::` remedies still name `.claude/hooks/session-start.sh`, which + // `7d188580` deleted — the defect this row was filed on. It is NOT repaired + // here because `policy/shell-retirement.rego` refuses the edit: its one + // admitted arm, `only_drops_a_retired_reference`, requires every removed line + // to name a path THIS SAME DELTA deleted, and this delta deletes nothing. So + // the task has two landable shapes — retire it whole, or leave it alone — and + // this change leaves it alone. + // + // What the row CAN hold is its own remedy, and that is what this asserts: the + // `[[startup]]` row's `repair` argv must name a task the manifest declares and + // a hook body present in the tree. A remedy naming a retired file is what + // CLOUD-1398 is about; this makes the successor's remedy unable to become one. + let config = + std::fs::read_to_string(root.join("batten.toml")).expect("the authority is tracked"); + assert!( + config.contains(r#"repair = ["mise", "run", "session:git-hooks"]"#), + "the commit-gate row must repair through the installer that exists" + ); + let manifest = + std::fs::read_to_string(root.join("mise.toml")).expect("the manifest is tracked"); + assert!( + manifest.contains("[tasks.\"session:git-hooks\"]"), + "the remedy names a task the manifest must declare" + ); + assert!( + root.join(".claude") + .join("hooks") + .join("git-hook.sh") + .is_file(), + "the hook body the row's repair links must be present in the tree" + ); +} + /// A repository declaring nothing says so, rather than saying nothing. /// /// The count line is what makes silence legible: without it, "no rows" and diff --git a/install.sh b/install.sh index 4ca08e7b0..9a7c44ff0 100755 --- a/install.sh +++ b/install.sh @@ -633,8 +633,35 @@ main() { mkdir -p "$dest" || die 1 "cannot create $dest. Set BATTEN_INSTALL_DIR to a writable directory." chmod +x "$tmp/$BIN" - cp "$tmp/$BIN" "$dest/$BIN" || - die 1 "cannot write $dest/$BIN. Set BATTEN_INSTALL_DIR to a writable directory." + + # ATOMIC REPLACE, BECAUSE THE TARGET MAY BE THE RUNNING BINARY (CLOUD-1620). + # + # `cp` opens the destination inode O_WRONLY|O_TRUNC. When that inode is a + # RUNNING executable the kernel refuses with ETXTBSY, and this installer's + # `die` then blamed the directory — "Set BATTEN_INSTALL_DIR to a writable + # directory" — for a fault that has nothing to do with permissions. + # + # Measured 2026-09-08: `session:batten` installs the release at session start, + # and at that moment `batten` IS executing — it is the SessionStart dispatcher + # running this very handler. So the one caller that most needs a current binary + # was the one caller guaranteed to fail. It went unseen because the other + # caller, `deps-install`, runs at provisioning time when nothing is running + # yet, and because `install:local` — the mechanism `session:batten` used before + # — spells `install -m 0755`, and GNU coreutils `install` unlinks the + # destination first, so it silently had the property this lacked. + # + # `rename(2)` gives it honestly: the running process keeps its old inode until + # it exits, and the name flips in one step, so no reader ever sees a partial + # binary. THE TEMPORARY MUST LIVE IN `$dest`, not in `$tmp` — `mv` across + # filesystems degrades to a copy onto the destination and re-hits the very + # ETXTBSY this avoids, and `$TMPDIR` is routinely a different filesystem. + staged="$dest/.$BIN.new.$$" + cp "$tmp/$BIN" "$staged" || + die 1 "cannot write $staged. Set BATTEN_INSTALL_DIR to a writable directory." + mv -f "$staged" "$dest/$BIN" || { + rm -f "$staged" + die 1 "cannot replace $dest/$BIN." + } # KEY=VALUE, the same shape `mise-tasks/dist.sh` emits, so a caller can consume # this without parsing prose. diff --git a/man/batten-doctor-gate.1 b/man/batten-doctor-gate.1 new file mode 100644 index 000000000..f9c95c068 --- /dev/null +++ b/man/batten-doctor-gate.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-doctor-gate 1 batten +.SH NAME +batten\-doctor\-gate \- Diagnose whether this checkout\*(Aqs commit path runs the gate +.SH SYNOPSIS +\fBbatten doctor gate\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Diagnose whether this checkout\*(Aqs commit path runs the gate +.SH OPTIONS +.TP +\fB\-J\fR, \fB\-\-json\fR +Emit byte\-stable JSON instead of pointer lines +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-doctor.1 b/man/batten-doctor.1 index 78fcb2970..165872795 100644 --- a/man/batten-doctor.1 +++ b/man/batten-doctor.1 @@ -22,6 +22,9 @@ Diagnose whether the engine the registrations reach was built from this tree batten\-doctor\-egress(1) Diagnose whether the agent proxy would carry this container\*(Aqs requests .TP +batten\-doctor\-gate(1) +Diagnose whether this checkout\*(Aqs commit path runs the gate +.TP batten\-doctor\-hooks(1) Diagnose whether batten is wired on every hook surface of every harness .TP diff --git a/mise.toml b/mise.toml index 68d2883a6..06bee6956 100644 --- a/mise.toml +++ b/mise.toml @@ -2435,19 +2435,59 @@ description = "Session start: provision what mise does not own — the rustup cr run = "mise run doctor >/tmp/session-start-doctor.log 2>&1 || { echo '::error:: session-start: doctor failed — see /tmp/session-start-doctor.log' >&2; tail -5 /tmp/session-start-doctor.log >&2; exit 1; }" [tasks."session:batten"] -description = "Session start: build and install this checkout's own engine over the released one `deps-install` provisioned" +description = "Session start: install the latest RELEASE over whatever `deps-install` provisioned — a verified download, never a compile" # THE RELEASED BINARY IS ALREADY ON PATH BY THE TIME THIS RUNS, and that is what # makes this row possible at all. `deps-install` puts it there at provisioning # (CLOUD-1085, #798) — before Claude launches, so before any hook fires — which -# is what dispatches the handler this task is registered as. This step then -# REPLACES it with the working tree's build, because consumer #1 must judge the -# engine it ships rather than the last release. -# -# The ordering consequence, stated because it inverts the old script's: a session -# whose `deps-install` did not run has no binary to dispatch handlers with, so -# nothing here runs and the advisory `contract::unmediated_session` reports it. -# `mise run install:local` stays reachable by hand, which is the manual repair. -run = "mise run install:local >/tmp/session-start-batten.log 2>&1 || { echo '::error:: session-start: install:local failed — see /tmp/session-start-batten.log' >&2; tail -5 /tmp/session-start-batten.log >&2; exit 1; }" +# is what dispatches the handler this task is registered as. +# +# THIS STEP USED TO SPELL `install:local`, AND THAT WAS A COMPILE ON THE ONE PATH +# THAT MAY NOT HAVE ONE (CLOUD-1620). The premise was that consumer #1 must judge +# the engine it ships rather than the last release, which is true and is not a +# reason to build HERE. Measured 2026-09-08 on a container already warm for +# tools: this step took 224.87s, and the binary it produced was 0.0.151 — byte +# for byte the release `deps-install` had already fetched. A session paid a +# four-minute compile to arrive where it started. +# +# THE INVERSION IS THE ROW'S OWN, and this file already argued it two thousand +# lines up. `deps-install`'s comment reads "`install.sh`, NEVER `install:local` … +# A provisioning path cannot assume a Rust toolchain, a 141-second compile, or +# that the checkout builds at all" — and `install-does-one-thing` in batten.toml +# ENFORCES it, banning `cargo` from `install.sh` outright. This task was the one +# caller routing around a rule the repository had already committed to. +# +# SKEW IS NOW DETECTED, NOT PRE-EMPTED. The risk the compile hedged against is +# real — CLOUD-1326 measured an installed batten older than a config key +# disarming every mediated gate in silence — but an unconditional build is the +# wrong instrument for it, because it spends four minutes on every session to +# insure against a skew most sessions do not have. `batten doctor mediator` +# (CLOUD-1630) is the detector, and it answers `mediator-build-behind-source` on +# exactly the branches that need a build. +# +# SO COMPILING IS OPT-IN, AND THE BAR IS STATED: you are changing +# `crates/batten/**` and need the tree's engine to mediate your own calls. +# `mise run install:local` is that route, and `verify` and `batten-check` already +# build on that path anyway — so the case needs no session-start step to serve it. +# +# `./install.sh` AND NOT `mise run deps-install`, WHICH IS THE OBVIOUS SPELLING +# AND IS WRONG HERE. That task is `./install.sh && batten wiring reclaim -y`, and +# the reclaim half is ALREADY a session-start handler: `session-wiring`, row 9, +# placed after this one precisely so it observes repaired wiring rather than the +# launcher's. Routing through it would fire the reclaim twice in one batch, and +# `batten hook` expires the at-load record on this same event — so a second +# reclaim here is the unordered write-and-clear that row warns about, for work +# row 9 has already done. +# +# The proxy seam survives the narrowing, which is the thing worth checking before +# calling the installer by path: `deps-install`'s own header records that a bare +# `./install.sh` is unreachable FROM THE SETUP SCRIPT, because the release API +# needs `NO_PROXY` and `MISE_GITHUB_TOKEN` in mise's own process — and that "a +# task body inherits that environment". This is a task body. +# +# The ordering consequence, unchanged: a session whose `deps-install` did not run +# has no binary to dispatch handlers with, so nothing here runs and the advisory +# `contract::unmediated_session` reports it. +run = "./install.sh >/tmp/session-start-batten.log 2>&1 || { echo '::error:: session-start: release install failed — see /tmp/session-start-batten.log' >&2; tail -5 /tmp/session-start-batten.log >&2; exit 1; }" [tasks."session:git-hooks"] description = "Session start: install the repo-owned git hooks, the per-clone step nothing performed for 24 commits (CLOUD-476)" @@ -3951,7 +3991,55 @@ run = 'cargo run --quiet -p batten -- attribution check --message "{{arg(name="f [tasks.attribution-identity] description = "Write: set this clone's repo-local git identity when it is unset or carries a denied vendor identity" -run = "cargo run --quiet -p batten -- attribution identity" +# THE BINARY ON PATH FIRST, AND `cargo run` ONLY WHERE THERE IS NONE (CLOUD-1620). +# +# This task ran as `session:identity`, the seventh session-start handler, and it +# spelled `cargo run` — the DEBUG profile, which shares no artifacts with the +# release build the fifth handler had just finished. Measured 2026-09-08: ~215s, +# a second full compile of the same crate, to perform a `git config` write that +# the binary already installed at `/root/.local/bin/batten` answers in +# milliseconds. The two compiles together were ~440s of a ~450s session start. +# +# The shape is `target-prune`'s, above: try the installed binary and fall back to +# a build when it cannot do the job. That fallback is not decoration — a clone +# whose provisioning has not run still has to be able to set its identity before +# it writes a commit, which is the whole reason this task exists in the +# provisioning window at all. +# +# THE GUARD IS TRY-AND-SUCCEED, NEVER MERELY `command -v`, and the difference is +# the whole correctness of the change. A presence test asks whether a binary is +# on PATH; the failure that matters here is a binary that IS on PATH and CANNOT +# READ THIS TREE — the release predating a `batten.toml` key, which is CLOUD-1326 +# and which this repository reaches constantly, because config keys land between +# releases. Measured 2026-09-08 on this very lap: the installed 0.0.151 refused +# `[lease]` with "the config declares a key this build predates". A presence-only +# guard sends the task to that binary and fails; `&&` sends it to the build, which +# is what `target-prune` has always done and what the unconditional `cargo run` +# here used to do for free. +# +# It also narrows `task-substitution`, whose subject is whichever declared task +# leads with `cargo`: a reader running `cargo test` was being told to run +# `attribution-identity`, a task with nothing to do with running tests. +# +# ONE LINE, NOT A TRIPLE-QUOTED BLOCK, for the reason `deps-install` states above +# its own `&&`: `inline-task-bodies-not-growing-basic` is a `non_increasing` +# ratchet over block bodies in this file, with no `admits_with`. A block body here +# takes it 2->3 and its only routes are extraction or a waiver — and extraction +# means a new `mise-tasks/*.sh`, which `shell add refused` denies. Neither is +# worth spending on one guarded fallback, so the guard is spelled inline. +# +# AND THIS COMMENT MAY NOT SPELL THE PATTERN IT DESCRIBES. The ratchet is a +# literal count, so quoting its pattern here counted as a third body and reddened +# the gate over prose — measured, on the first draft of this comment. The mirror +# of the defect one file over, where a gate added in this same change scanned task +# COMMENTS for `install:local` and refused a manifest that was already correct. +# A gate that counts literals cannot tell a mechanism from a mention of one, so +# the mention has to give way. +# +# `if`/`else` rather than `&&`/`||`: with `a && b || c`, a FAILING `attribution +# identity` falls through to `c` and spends the four-minute build this row exists +# to remove — the error path would cost more than the thing being avoided. +run = "if command -v batten >/dev/null 2>&1 && batten attribution identity; then :; else cargo run --quiet -p batten -- attribution identity; fi" [tasks.commit-check] description = "Gate: every commit subject in BASE_SHA..HEAD_SHA follows the convention (policy: [commit] in batten.toml)" diff --git a/policy/ci-cache-declared.rego b/policy/ci-cache-declared.rego index 544bb8eec..86c77b599 100644 --- a/policy/ci-cache-declared.rego +++ b/policy/ci-cache-declared.rego @@ -43,6 +43,7 @@ #MUTANT key-may-carry-a-hash|s@contains(key, "hashFiles")@false@|a_shared_key_carrying_a_content_hash_is_refused #MUTANT cargo-reach-may-go-uncached|s@not declares_a_cache(path, name)@false@|a_cargo_job_with_no_cache_step_is_refused #MUTANT warmed-family-may-be-written|s@not reads_only(step)@false@|a_pull_request_writer_of_a_warmed_family_is_refused +#MUTANT orphaned-reader-may-pass|s@not [shared_key(step), arch(runner(path, name))] in warmed@false@|a_read_only_consumer_of_an_unwarmed_family_is_refused # METADATA # description: | @@ -63,6 +64,8 @@ rules contains "cargo-reach-declares-a-cache" rules contains "warmed-family-is-read-only" +rules contains "read-family-has-a-warm-writer" + # --- what is being judged, and whether there is anything to judge ------------- # THE MANIFEST IS READ INLINE, NEVER BOUND TO A TOP-LEVEL RULE, AND THAT IS A @@ -477,6 +480,78 @@ violation contains { not job_placed(path, name) } +# --- 4. a pure consumer has a warm writer on its own architecture ------------- +# +# THE COMPLEMENT OF RULE 3, AND THE HALF THAT ROW EXPLICITLY LEAVES OPEN. Rule 3 +# refuses a pull-request job that WRITES a family the trunk already warms; its +# own verdict row closes by saying "a family no trunk-side job writes is NOT +# warmed and is deliberately outside this row: making those read-only would leave +# them with nothing at all." That sentence names an arrangement nothing then +# checks — a job ALREADY read-only against a family nothing warms — and this is +# the predicate for it. A pure consumer of an empty family restores nothing on +# every run, forever, by construction. +# +# THE MEASURED INSTANCE IS AN ARCHITECTURE SPLIT, NOT A TYPO (CLOUD-1477). +# `batten-check` reads `ci-` on x64 while `cache-warm-linux` writes `ci-` on +# arm64, and rust-cache composes `runnerOS-runnerArch` at `config.ts:93` INSIDE +# the restore prefix — so the reader cannot read that writer's entry at all. Not +# a partial hit: no hit, ever. The `bats` job had the same shape against a `bats-` +# family nothing on the trunk wrote, and this module's own header records it as +# one of the three failures that motivated the file. Both were found by reading a +# job log by hand, which is what this predicate replaces. +# +# WHY IT IS SCOPED TO `reads_only` AND NOT TO EVERY READER. Every rust-cache step +# restores, so "reads a family" would reach `cross-`, `semver-`, +# `${{ matrix.target }}`, `coverage-`, `fuzz-` and `perf-` — families this +# repository writes from the pull request on purpose, because they have no trunk +# writer and `ci.yml` records that as a deferred follow-up rather than an +# oversight. Those jobs still get their own entry on a later lap; a `save-if: +# false` job gets nothing. Refusing both would relitigate a decision made +# elsewhere, which is the same bound rule 3 draws when it excludes the scheduled +# writers from `warmed`. +# +# THE FAMILY IS THE (KEY, ARCHITECTURE) PAIR, read exactly as rule 3 reads it — +# same `warmed` set, same `arch`, same `runner`. One resolution, so the two +# predicates cannot disagree about what a family is. +orphaned(path, name) if { + on_pull_request(path) + some entry in job_step + entry[0] == path + entry[1] == name + step := entry[2] + reads_only(step) + not [shared_key(step), arch(runner(path, name))] in warmed +} + +violation contains { + "rule": "read-family-has-a-warm-writer", + "verdict": "job read empty", + "subjects": [{"path": path, "line": number}], +} if { + governed + some job in job_of + path := job[0] + name := job[1] + orphaned(path, name) + some placement in job_line + placement[0] == path + placement[1] == name + number := placement[2] +} + +violation contains { + "rule": "read-family-has-a-warm-writer", + "verdict": "job read empty", + "subjects": [{"path": path}], +} if { + governed + some job in job_of + path := job[0] + name := job[1] + orphaned(path, name) + not job_placed(path, name) +} + # --- could not look ---------------------------------------------------------- # # A DECLARED SOURCE THAT WOULD NOT PARSE IS NOT AN ABSENT ONE. Absent is @@ -562,6 +637,37 @@ test_the_same_key_on_the_same_architecture_is_still_refused if { finding.rule == "warmed-family-is-read-only" } +# RULE 4, AND THE FIXTURE IS THE ORPHANING THAT MOTIVATED IT: a read-only +# consumer on x64 against a writer on arm64. `test_a_readable_key_with_a_cache_is +# _clean` is the matched-architecture direction, so between them the pair +# discriminates the architecture term rather than merely the key. +test_a_read_only_consumer_of_an_unwarmed_family_is_refused if { + some finding in violation with input as tree( + warm_writer_on("ubuntu-24.04-arm"), + pr_reader_on("ci-", false, "ubuntu-latest"), + ) + finding.rule == "read-family-has-a-warm-writer" +} + +# The other direction on the KEY rather than the architecture: a read-only +# consumer of a family nothing writes at all. Without this the rule could pass by +# only ever noticing the architecture split. +test_a_read_only_consumer_of_a_family_nothing_writes_is_refused if { + some finding in violation with input as tree(no_writer, pr_reader("ci-", false)) + finding.rule == "read-family-has-a-warm-writer" +} + +# ANTI-VACUITY FOR RULE 4, and it is the bound the predicate's header argues for: +# a job that WRITES an unwarmed family is rule 3's business and not this one's, +# because it still gets its own entry on a later lap. Shares a fixture with +# `test_an_unwarmed_family_may_still_be_written` deliberately — that case asserts +# rule 3 stays silent over it, this one asserts rule 4 does too, and a single +# `count(violation) == 0` covering both is what keeps the two bounds from drifting +# apart. +test_a_writer_of_an_unwarmed_family_is_not_this_rules_business if { + count(violation) == 0 with input as tree(no_writer, pr_reader("cross-", true)) +} + test_a_job_reaching_no_cargo_needs_no_cache if { count(violation) == 0 with input as tree(warm_writer, inert_reader) } diff --git a/schema/batten.local.schema.json b/schema/batten.local.schema.json index 412bbd236..5d8937409 100644 --- a/schema/batten.local.schema.json +++ b/schema/batten.local.schema.json @@ -1413,6 +1413,13 @@ } ] }, + "unless_contains": { + "description": "A literal whose presence **exempts** the command from a\n[`RuleKind::Shape`] row that would otherwise fire (CLOUD-1477's sibling).\n\n[`Rule::contains`]'s twin in the other polarity, and it exists for the\nsame structural reason rather than as a general escape hatch: the operand\nmatcher drops flags before comparing, so a row cannot distinguish two\ncommands that differ only by a flag. `contains` is how a row DEMANDS one;\nthis is how a row EXCLUDES one, and without it the only expressible\npredicate over a flag is \"must be present\".\n\n**The measured case is `git rebase`.** `rebase-not-hand-stepped` bans a\nhand-driven landing lap — `git rebase origin/main` — by asking whether the\nline names `origin/main`. But `git rebase --onto origin/main \n` names it too, and is not a lap at all: `--onto` moves a range\nsomewhere else, which is a history edit the landing loop cannot perform,\nso the row refused an operation its own remedy does not offer and declared\nno route to it. A lap never spells itself with `--onto`, because a lap has\nno upstream to name.\n\nMatched against the raw text of the same line as `contains`, quotes\nincluded, and for the same reason: line one's text must not exempt line\ntwo's command.\n\n**The permissive direction is the one to fear here**, which is why it is\n`shape` only and a literal rather than a regex. A `contains` that is wrong\nmakes a row fire less often than it reads; so does this — but this is the\ncolumn whose whole job is to switch a deny off, so it stays the narrowest\nthing that can do the job and is named in the row's `reason` wherever it\nis used.", + "type": [ + "string", + "null" + ] + }, "use_sources": { "description": "The same, glob-selected (CLOUD-762).\n\nA layering rule decides over every module by construction, so the glob is\nthe spelling that matters and it ships with its literal column.", "type": "array", diff --git a/schema/batten.schema.json b/schema/batten.schema.json index 00c8e49cc..53daf8f37 100644 --- a/schema/batten.schema.json +++ b/schema/batten.schema.json @@ -3858,6 +3858,13 @@ } ] }, + "unless_contains": { + "description": "A literal whose presence **exempts** the command from a\n[`RuleKind::Shape`] row that would otherwise fire (CLOUD-1477's sibling).\n\n[`Rule::contains`]'s twin in the other polarity, and it exists for the\nsame structural reason rather than as a general escape hatch: the operand\nmatcher drops flags before comparing, so a row cannot distinguish two\ncommands that differ only by a flag. `contains` is how a row DEMANDS one;\nthis is how a row EXCLUDES one, and without it the only expressible\npredicate over a flag is \"must be present\".\n\n**The measured case is `git rebase`.** `rebase-not-hand-stepped` bans a\nhand-driven landing lap — `git rebase origin/main` — by asking whether the\nline names `origin/main`. But `git rebase --onto origin/main \n` names it too, and is not a lap at all: `--onto` moves a range\nsomewhere else, which is a history edit the landing loop cannot perform,\nso the row refused an operation its own remedy does not offer and declared\nno route to it. A lap never spells itself with `--onto`, because a lap has\nno upstream to name.\n\nMatched against the raw text of the same line as `contains`, quotes\nincluded, and for the same reason: line one's text must not exempt line\ntwo's command.\n\n**The permissive direction is the one to fear here**, which is why it is\n`shape` only and a literal rather than a regex. A `contains` that is wrong\nmakes a row fire less often than it reads; so does this — but this is the\ncolumn whose whole job is to switch a deny off, so it stays the narrowest\nthing that can do the job and is named in the row's `reason` wherever it\nis used.", + "type": [ + "string", + "null" + ] + }, "use_sources": { "description": "The same, glob-selected (CLOUD-762).\n\nA layering rule decides over every module by construction, so the glob is\nthe spelling that matters and it ships with its literal column.", "type": "array",