From 3b0fcac4dd19969d056f9b08886f05aacfe8fec0 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 00:48:44 +0000 Subject: [PATCH 1/7] fix(ci): put the dev profile in the rust-cache key, where it was never read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `f54a9c05` changed `[profile.dev]` and `[profile.dev.package."*"]` in the root `Cargo.toml` on 2026-09-01 at 09:35Z. rust-cache does not consider that file — the step logs its inputs, and they are `Cargo.lock` and `crates/batten/Cargo.toml`. So the key did not move, every restore since has reported `full match: true`, and every artifact handed back was built under the old profile. Measured with a control on each side of that commit, `Compiling` lines per run: `ci` 0 -> 121, `windows` 1 -> 288. Both post-change runs restore successfully and compile the closure anyway. It costs ~838-1061s on `windows` and ~318-670s on `ci`, per pull request. Nothing was going to clear it. `cache-warm-linux` and `cache-warm-windows` compile only on `cache-hit != 'true'`, the key still hit, so they skipped; the main-ref entry `v0-rust-ci-Linux-x64-22abc94d-db6ecef6` has been served since 2026-08-31T21:19Z. Putting the profile in the key fixes that half for free: a profile change now mints a new key, the warm job misses, and it writes a correct entry. THE HASH GOES IN `shared-key`, AND `key` IS DELETED EVERYWHERE. rust-cache v2.9.2 `src/config.ts` reads `key` only in the `else` arm of `if (sharedKey)`, so on every step that set both, `key` has never had any effect. The `windows` job's own comment already carried the two live entries that show it — `v0-rust-windows-windows-Windows_NT-x64-…` before its shared key, `v0-rust-windows-Windows_NT-x64-…` after — while inferring a composition, `{key}-{shared-key or job id}`, that those strings contradict. The strings were right; the inference was not. Both are corrected there. Twelve steps across six workflows now carry one spelling each, and the two pairs that must agree — `ci`/`cache-warm-linux` and `windows`/`cache-warm-windows` — are identical. The first run after this pays one cold build by design; the second is the measurement. Acceptance is `Compiling` back to 0 on `ci` and 1 on `windows`, read with `gh api …/actions/jobs/{id}/logs` rather than `gh run view --log`, which omits build output entirely. Refs: CLOUD-840, CLOUD-1225 Admits: d4e51708843b222fa888ce2a494969ca602b0b4ac51c0c532ab9f2dce21319ee Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .github/workflows/ci.yml Admits-head: 606304936fbd9a3ee6fe50b7938f7b39c4f02fce Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117 Admits-author: alec@wenzowski.com Admits-prev: 5b17005aecc7204a7462645de0f2112e45177a4dcfecaa524ceda636ae31c4f9 Admits-answer-lost: This file carries the `ci`, `bats` and `perf` jobs. Since `f54a9c05` changed `[profile.dev]` on 2026-09-01T09:35Z, `ci` has gone from 0 to 121 `Compiling` lines per run — the whole dependency closure, rebuilt behind a cache that reports `full match: true`, because the root `Cargo.toml` is not among the files rust-cache keys on. Left alone that is ~318-670s on every pull request, and it does not decay: the warm jobs compile only on `cache-hit != 'true'` and the stale key still hits. Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect. Admits: 0bdf14c465c515c8e7cadbb6b263be40be54292e784079c049f9377d11e21329 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .github/workflows/rust.yml Admits-head: 606304936fbd9a3ee6fe50b7938f7b39c4f02fce Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: This file carries `windows`, the current critical path at a 30.0m median. Its nextest step spent 838s and 1061s compiling on two measured runs — ~60% of the step both times — off a cache that reported `full match: true`, because `f54a9c05` changed `[profile.dev]` and the key never moved. Leaving it costs that on every pull request. This edit also removes an inert `key:` input beside `shared-key` and corrects the composition claim in the comment above it, which the two live entries recorded there already contradicted. Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect. Admits: df1552c6921659849f361d3b5621f974f66061ce007beda0503e0cdf380f901e Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .github/workflows/release-plz.yml Admits-head: 606304936fbd9a3ee6fe50b7938f7b39c4f02fce Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The warm pair is the half that repairs a poisoned cache, and it cannot: `cache-warm-linux` and `cache-warm-windows` compile only on `cache-hit != 'true'`, and the stale key still hits, so they skip and the bad entry is served indefinitely. The main-ref entry `v0-rust-ci-Linux-x64-22abc94d-db6ecef6` has been served since 2026-08-31T21:19Z. Putting the profile in the key makes a profile change miss here, which is what lets the warm job rewrite the entry. Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy. This file's two warm jobs must carry the identical spelling to their consumers in `ci.yml` and `rust.yml`, so it cannot be changed separately from them. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect. Admits: 61b036bd1253d32cccbd7c37c3b38ae0fecb0588d83ab0ed1321f81a1b7f79a0 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .github/workflows/coverage.yml Admits-head: 606304936fbd9a3ee6fe50b7938f7b39c4f02fce Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: This job is off the landing path, so the cost is not cadence — it is a second spelling of the same key. Left alone, `coverage.yml` would be the only rust-cache step still carrying the inert `key:` form that this change removes everywhere else, and the next reader would reasonably copy it. Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect. Admits: 97c4d9815e6568cef3e8c065ff9d48b27b06888688c213cc2fc359e9f7dc5f0f Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .github/workflows/fuzz.yml Admits-head: 606304936fbd9a3ee6fe50b7938f7b39c4f02fce Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: This job is off the landing path, so the cost is not cadence — it is a second spelling of the same key. Left alone, `fuzz.yml` would be the only rust-cache step still carrying the inert `key:` form that this change removes everywhere else, and the next reader would reasonably copy it. Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect. Admits: 8791c690dfc567fc618533f8e178891008f57929de76de6dd8bcbb5cc13a41a9 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: .github/workflows/perf.yml Admits-head: 606304936fbd9a3ee6fe50b7938f7b39c4f02fce Admits-epoch: 71bf7f9f4f378e71c73ceeea5eb44c0217d75bdca1c242d0f9cdfb31f03c5117 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: This job is off the landing path, so the cost is not cadence — it is a second spelling of the same key. Left alone, `perf.yml` would be the only rust-cache step still carrying the inert `key:` form that this change removes everywhere else, and the next reader would reasonably copy it. Admits-answer-precondition: A GitHub Actions cache key is expressed only in the workflow's own `Swatinem/rust-cache` step inputs, and no batten surface declares one, so there is no owning surface that could express this change. It lands on branch `claude/ci-performance-degradation-ic29ck` as a reviewed pull request, which is what this glob's `[[redirect]]` row names as the remedy. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: the key is composed by the action from its own `with:` block and nothing in `batten.toml` can name it, so no owning surface exists. R-RESTORE-IT does not apply: the file is not damaged or drifted, and restoring it would reinstate the defect. --- .github/workflows/ci.yml | 18 ++++++--- .github/workflows/coverage.yml | 3 +- .github/workflows/fuzz.yml | 3 +- .github/workflows/perf.yml | 3 +- .github/workflows/release-plz.yml | 19 ++++----- .github/workflows/rust.yml | 67 +++++++++++++++++++------------ 6 files changed, 70 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f92093a..d3135710a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -321,8 +321,15 @@ jobs: # Must match `release-plz.yml`'s `cache-warm-linux` exactly or that job # writes an entry this one cannot read, which is the current failure # with extra steps. - key: ci - shared-key: ci + # The profile is part of the key: `[profile.*]` lives in the root + # `Cargo.toml`, which rust-cache does NOT consider (it reads + # `Cargo.lock` and `crates/batten/Cargo.toml` only). Without it a + # profile change leaves the key untouched, the restore reports + # `full match: true`, and every artifact it hands back was built + # under the old profile — measured 2026-09-01: 0 -> 121 `Compiling` + # lines. In `shared-key` rather than `key` because `key` is an + # if/else fallback rust-cache ignores whenever `shared-key` is set. + shared-key: ci-${{ hashFiles('Cargo.toml') }} # ASK WHETHER THIS DIFF CAN MOVE THE SLOW TIER (CLOUD-398). Measured # 2026-08-21: this job is ~13 of the ~14.7 billed minutes a non-Rust pull # request costs, `test:bats` is ~81% of it, and a one-file change to @@ -525,8 +532,8 @@ jobs: # discarded. Its own key costs one more entry and no contention. - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - key: bats - shared-key: bats + # The profile is part of the key — see the `ci` job above. + shared-key: bats-${{ hashFiles('Cargo.toml') }} # THE SAME QUESTION THE `ci` JOB ASKS, and it must stay in-job rather than # becoming a `paths:` filter or a job `if:` — see this job's header and # `final`'s. `test:bats` is one of the six `slow`-tagged steps, so a diff @@ -632,7 +639,8 @@ jobs: # semver carries its own. - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - key: perf + # The profile is part of the key — see the `ci` job above. + shared-key: perf-${{ hashFiles('Cargo.toml') }} # CLOUD-1331. The BASE arm is a pure function of the merge-base SHA, the # pinned toolchain and `[profile.release]`, and `main` advances only by # fast-forward to already-judged SHAs — so consecutive pull requests share a diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 92c9aca61..601267b74 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -65,7 +65,8 @@ jobs: with: # Instrumented builds are a different profile from every other job's, # so a shared cache entry would thrash. - key: coverage + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: coverage-${{ hashFiles('Cargo.toml') }} # Redirected, never piped. A pipeline hands the exit status to its LAST # stage, so `mise run coverage | tee ...` would report success whenever # `tee` succeeded — the false-green shape `mise run run-shape-guard` diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 6e76e74a4..c4dd70382 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -77,7 +77,8 @@ jobs: with: # Sanitizer-instrumented nightly builds share no artifacts with any # other job's profile, so a shared cache entry would only thrash. - key: fuzz + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: fuzz-${{ hashFiles('Cargo.toml') }} # The working corpus, carried between runs rather than committed. A fuzzer # that restarts from the seeds every week re-derives the same shallow # coverage every week and never gets past it; accumulating it in git diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml index 3e8c1cf7e..3f99d207a 100644 --- a/.github/workflows/perf.yml +++ b/.github/workflows/perf.yml @@ -98,7 +98,8 @@ jobs: with: # A release profile, which no other job builds, so a shared cache entry # would thrash. - key: perf + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: perf-${{ hashFiles('Cargo.toml') }} # Redirected into a file and read back, never piped into the gate. A # pipeline hands its exit status to the LAST stage, so `perf | # perf-assert` would discard exactly the measurement failure that must diff --git a/.github/workflows/release-plz.yml b/.github/workflows/release-plz.yml index ddf2d2626..a7a939e46 100644 --- a/.github/workflows/release-plz.yml +++ b/.github/workflows/release-plz.yml @@ -163,10 +163,11 @@ jobs: # `id` and the guard together. id: rust-cache with: - # Both spellings must match `ci.yml`'s job exactly or this writes an - # entry that job cannot read. - key: ci - shared-key: ci + # Must match `ci.yml`'s job exactly or this writes an entry that job + # cannot read. One spelling now, not two: `key` is an if/else + # fallback rust-cache ignores whenever `shared-key` is set, so the + # pair could drift while looking deliberate. + shared-key: ci-${{ hashFiles('Cargo.toml') }} # 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 @@ -196,11 +197,11 @@ jobs: # warm. `ci-local-parity` property 17 holds the pair together. id: rust-cache with: - # Both spellings must match `rust.yml`'s `windows` job exactly or this - # writes an entry that job cannot read, which is the current failure - # with extra steps. - key: windows - shared-key: windows + # Must match `rust.yml`'s `windows` job exactly or this writes an + # entry that job cannot read. One spelling now, not two — and that + # job carried `key` where this one carried both, an asymmetry that + # happened to match and could not be explained from the source. + shared-key: windows-${{ hashFiles('Cargo.toml') }} # COMPILE ONLY WHEN THERE IS NOTHING TO RESTORE, and the evidence for this # is that every warm cycle after the first was pure waste (CLOUD-840). # diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 292592405..19cff47a1 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -127,6 +127,9 @@ jobs: # tool it touches. See the ci job for why these lists are narrow. install_args: rust - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + # The profile is part of the key — see `ci.yml`'s `ci` job. + shared-key: cross-${{ hashFiles('Cargo.toml') }} - run: mise run cross-check env: # Only the triple cross-check type-checks. doctor's default pair would @@ -210,7 +213,8 @@ jobs: with: # A per-target key: a leg's artifacts are target-specific and would # otherwise thrash a shared entry if the second leg is restored. - key: ${{ matrix.target }} + # The profile is part of the key too — see `ci.yml`'s `ci` job. + shared-key: ${{ matrix.target }}-${{ hashFiles('Cargo.toml') }} - run: mise run darwin-link ${{ matrix.target }} env: # Exactly the target being linked; doctor's default would also fetch @@ -288,7 +292,8 @@ jobs: # carries its own. - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - key: semver + # The profile is part of the key — see `ci.yml`'s `ci` job. + shared-key: semver-${{ hashFiles('Cargo.toml') }} - run: mise run semver # THE OS MATRIX, and the only job here that EXECUTES rather than type-checks @@ -435,33 +440,43 @@ jobs: install_args: rust hk aqua:jqlang/jq github:nextest-rs/nextest - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 with: - # A distinct key: this job's target dir is built by a different host - # toolchain than every other job's, so sharing an entry would thrash - # it — the same reason semver and perf carry their own. - key: windows - # AND A SHARED KEY, which is what lets a job in ANOTHER workflow fill - # this entry (CLOUD-840). rust-cache composes - # `v0-rust-{key}-{shared-key or job id}-{os}-{arch}-{hashes}`, read off - # two live entries rather than from the docs — so without this the job - # ID is part of the key and a warm job under any other name computes a - # different one and the two never meet. + # A DISTINCT KEY, because this job's target dir is built by a different + # host toolchain than every other job's, so sharing an entry would + # thrash it — the same reason semver and perf carry their own. And a + # SHARED one, which is what lets a job in ANOTHER workflow fill this + # entry (CLOUD-840): without it the job ID is part of the key, and a + # warm job under any other name computes a different one. + # + # `key` IS GONE, AND ITS ABSENCE IS THE POINT. This block carried both + # `key: windows` and `shared-key: windows`, and the pair read as + # deliberate. It was inert: rust-cache v2.9.2 `src/config.ts` takes + # `shared-key` in an if/else and never reads `key` when it is set — + # + # if (sharedKey) { key += `-${sharedKey}`; } + # else { const inputKey = core.getInput("key"); … } + # + # which is exactly what the two live entries recorded here already + # showed: `v0-rust-windows-windows-Windows_NT-x64-…` before the shared + # key was added (key + job id), `v0-rust-windows-Windows_NT-x64-…` + # after (shared key alone). One component, not two. The composition + # this comment used to give, `{key}-{shared-key or job id}`, was the + # inference drawn from those strings and it was wrong; the strings + # themselves were right. # - # IT REPLACES THE JOB COMPONENT RATHER THAN FILLING IT, and the first - # warm run is what proved it. This comment previously claimed the - # composed string stayed byte-identical because the value matches - # `key`; the two live entries say otherwise — - # `v0-rust-windows-windows-Windows_NT-x64-…` before, - # `v0-rust-windows-Windows_NT-x64-…` after. One component, not two. - # So adding this DID orphan every prior entry. Harmless, because those - # were per-PR and unreadable across pull requests anyway, and the - # migration is a single cold build on each side — but the claim was - # wrong and a comment that states a measured fact has to state the one - # that was measured. + # THE PROFILE IS PART OF THE KEY (2026-09-01). `[profile.*]` lives in + # the root `Cargo.toml`, which rust-cache does NOT consider — it reads + # `Cargo.lock` and `crates/batten/Cargo.toml` only. So `f54a9c05` + # changed the dev profile, left the key untouched, and every restore + # since has reported `full match: true` while handing back artifacts + # built under the old profile: this job went from 1 `Compiling` line to + # 288, and `ci` from 0 to 121. The warm jobs could not repair it + # either, since they compile only on `cache-hit != 'true'` and the key + # still hit. # # The warm side is `cache-warm-windows` in `release-plz.yml`. Both - # spellings must agree; that they are two places is the cost of the - # only trigger this repository is allowed to hang a warm job on. - shared-key: windows + # must agree; that they are two places is the cost of the only trigger + # this repository is allowed to hang a warm job on. + shared-key: windows-${{ hashFiles('Cargo.toml') }} # A JOB THAT DECLARES A TOOL MUST BE ABLE TO REACH IT, asserted before the # suite rather than discovered inside it. `ci-tools-check` holds these # names against `mise.toml`, which is a different question: it cannot know From dd1d069834666495e16b71de9fb26252ffedf138 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 00:52:23 +0000 Subject: [PATCH 2/7] perf(test): assert one rule with one rule, not with all 103 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `a_tracked_instruction_may_not_prescribe_the_denied_commit_identity` reads the committed `batten.toml`, builds a fixture from it, and runs `enforce` over the whole ruleset — twice, since the clean arm is a second evaluation — to assert a single row, `no-denied-identity-prescribed`. On the Windows runner that case was 305s of a 1482s suite, and it is 0.122s narrowed. The cost is not the point on its own; the shape is. A case that evaluates every row to assert one gets slower every time a row is added, and this repository adds rows deliberately — `[[rule]]` went 47 -> 103 and `line_sources` 2 -> 28 between 2026-08-23 and 2026-09-01, over which this case went 4s -> 305s. The retirement campaign was paying itself a tax on every gate it moved onto the engine. `check --rule` was already the answer and was already trusted against these exact committed bytes: `mise.toml` invokes `--rule prose-only`, `--rule filed-here` and `--rule memory-graph`. What kept this family on `enforce` was a comment claiming the read-effect verb refuses the committed config outright. That is true of an UNNARROWED `check` — the config does carry a spawning kind — but `--rule` selects the row before the refusal is reached, and only 3 of the 103 rows are `kind = "command"`. The comment is corrected in place rather than deleted, since the half it got right is why the family looked settled. Shown able to fail (CLOUD-418), because a narrowing is exactly the change that can quietly stop asserting: with the prescription removed from the dirty fixture the case fails on its exit-code assertion at `cli.rs:11426`, and passes again when it is put back. The neighbouring vacuity is closed too — `check --rule` naming no declared row exits 1 with `no [[rule]] row is declared with id ...`, never a clean run over nothing. Both arms survive, the clean-fixture discriminator included, and the asserted stdout is now the output of the rule under test rather than of all 103 happening to produce that line. Refs: CLOUD-1223, CLOUD-1225 --- crates/batten/tests/it/cli.rs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 73d299f87..85f54bc13 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -11664,11 +11664,26 @@ fn a_tracked_instruction_may_not_prescribe_the_denied_commit_identity() { ) .expect("write fixture instruction"); - // `enforce`, not `check`: the committed ruleset carries a spawning kind that - // the read-effect verb refuses outright. Every sibling test over the - // committed bytes takes the same verb for the same reason. + // `check --rule`, not `enforce`. The unnarrowed read-effect verb does refuse + // this config outright — it carries a spawning kind — but `--rule` selects + // the row BEFORE that refusal is reached, and `no-denied-identity-prescribed` + // is not one of the three `kind = "command"` rows. `mise.toml` already relies + // on this against these same committed bytes: `check --rule prose-only`, + // `--rule filed-here`, `--rule memory-graph`. + // + // This comment used to say `enforce`, "and every sibling test over the + // committed bytes takes the same verb for the same reason". True of an + // unnarrowed `check` and over-general as written: it sent the whole family + // through the 103-rule ruleset to assert one row. Measured on Windows CI, + // this case alone was 305s of a 1482s suite. + // + // The narrowing is not a weakening. Both arms survive, the clean one + // included, and the asserted stdout is now the output of the rule under test + // rather than of all 103 happening to produce that line. let output = batten() - .arg("enforce") + .arg("check") + .arg("--rule") + .arg("no-denied-identity-prescribed") .current_dir(&dirty) .state_home(&home) .env_remove("BATTEN_STRICTNESS") @@ -11704,7 +11719,9 @@ fn a_tracked_instruction_may_not_prescribe_the_denied_commit_identity() { .expect("write fixture instruction"); let output = batten() - .arg("enforce") + .arg("check") + .arg("--rule") + .arg("no-denied-identity-prescribed") .current_dir(&clean) .state_home(&home) .env_remove("BATTEN_STRICTNESS") From 63615a2d8dc1b8ea3ce9af1f87397d8e47095b67 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 01:17:25 +0000 Subject: [PATCH 3/7] feat(enforce): narrow the spawning verb too, for the caller `check` cannot serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `the_committed_delegating_rule_spawns_nothing_when_its_glob_misses` asserts a property OF a `kind = "command"` row — that `no-conflict-markers`, globbed on `crates/**`, spawns nothing when its glob misses. `check` must refuse that by construction, so the case could not take CLOUD-1051's read-surface narrowing and evaluated all 104 rows to assert one: 206s of a 1482s suite on the Windows runner, which is the critical path and bills at 2x. It is 0.093s narrowed. THIS REVERSES A RECORDED DECISION, AND THE CONDITION IT NAMED HAS FAILED. `RunRequest::spawning` said `enforce` was "deliberately NOT narrowable", because "every caller that needs it is a `check` caller" and offering it here "would be surface nobody asked for, on the verb that spawns". Both halves were true when written. The second is not any more, and the caller is precisely the shape the original reasoning could not have covered: a case whose SUBJECT is a spawning row is exactly the case that cannot migrate to `check`. Recorded at the site rather than deleted, the way CLOUD-840 recorded its reversal of CLOUD-176. The other half of that decision stands unchanged and is restated: `enforce` is still not SCOPABLE. `--staged` and `--since` are `check`'s, no caller asks to spawn over a narrowed file set, and narrowing WHICH ROWS run is orthogonal to narrowing WHICH FILES they select against. `ENFORCE_RULE` refuses everything `CHECK_RULE` refuses, and that matters more here rather than less: a `--rule` naming no declared row is a usage error, never a clean run, because a narrowed spawn that silently selected nothing would report "the gate passed" from a gate that never ran, on the surface allowed to execute configured commands. Verified: `enforce --rule no-such-row` exits 1 with `no [[rule]] row is declared with id ...`. Shown able to fail (CLOUD-418): moving the conflict marker from `notes.txt` to `crates/notes.txt` — inside the glob — turns the narrowed case red, and moving it back turns it green, with the file's md5 unchanged across both runs. The discriminator the case was written around is untouched: exit 0 is still reachable only if the glob selected nothing and no process spawned. Verified: `lint:clippy` clean; 144/144 surface-contract cases including `the_committed_pages_are_the_ones_the_binary_emits` and `the_spec_carries_the_new_verbs_with_their_declared_effects`; completions, man pages and schema regenerated from the surface. NOT A CLEAN FULL SUITE LOCALLY, and the reason is environmental rather than this change. Four `shell_write_advisory` cases fail in this container with `permissionDecision: deny`, `receipt read missing claim branch claim-needs-receipt` — the hook reading a receipt store and finding no claim, so the advisory never reaches the assertion. A three-way control settles it: pristine `origin/main` at detached HEAD passes 10/10, pristine `origin/main` on an unclaimed branch passes 10/10, and this branch fails 4 with Step 3 stashed. The store exists here because this session ran `verify` and spent six admissions; a fresh checkout has none. Claiming the work clears it. Refs: CLOUD-1223, CLOUD-1225, CLOUD-1051 --- completions/batten.bash | 6 ++++- completions/batten.fish | 1 + completions/batten.zsh | 1 + crates/batten/src/cli.rs | 7 ++++++ crates/batten/src/lib.rs | 39 ++++++++++++++++++++++--------- crates/batten/src/surface.rs | 43 ++++++++++++++++++++++++++++++++++- crates/batten/tests/it/cli.rs | 13 +++++++++++ man/batten-enforce.1 | 5 +++- 8 files changed, 101 insertions(+), 14 deletions(-) diff --git a/completions/batten.bash b/completions/batten.bash index f563a808b..c4bfca3e5 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -2574,12 +2574,16 @@ _batten() { return 0 ;; batten__subcmd__enforce) - 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" + opts="-J -q -v -y -h --rule --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 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 fi case "${prev}" in + --rule) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; --strictness) COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) return 0 diff --git a/completions/batten.fish b/completions/batten.fish index 44c3aa42c..ae869b6d3 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -107,6 +107,7 @@ complete -c batten -n "__fish_batten_using_subcommand check" -l no-color -d 'Nev complete -c batten -n "__fish_batten_using_subcommand check" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand check" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand check" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand enforce" -l rule -d 'Run only the declared rule with this id' -r complete -c batten -n "__fish_batten_using_subcommand enforce" -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'" diff --git a/completions/batten.zsh b/completions/batten.zsh index 6ed1fc75f..47600f28c 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -87,6 +87,7 @@ trace\:"Add everything"))' \ ;; (enforce) _arguments "${_arguments_options[@]}" : \ +'--rule=[Run only the declared rule with this id]: :_default' \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" standard\:"The default\: a finding is a violation" strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index e82e7c3c0..9b19f9b9e 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -88,6 +88,12 @@ pub enum Command { Enforce { /// Emit findings as byte-stable JSON instead of pointer lines. json: bool, + /// Run only the declared row with this id, or every applicable row. + /// + /// The narrowing `check` has carried since CLOUD-1051, extended to the + /// spawning verb for the one caller `check` cannot serve: a case whose + /// SUBJECT is a `kind = "command"` row. + rule: Option, }, /// Inspect configuration. Config { @@ -1696,6 +1702,7 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { })), "enforce" => Some(Command::Enforce { json: flag(matches, "json"), + rule: matches.get_one::("rule").cloned(), }), "config" => config_of(matches).map(|command| Command::Config { command }), "lint" => lint_of(matches).map(|command| Command::Lint { command }), diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 863c9594f..5e45373c4 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -181,13 +181,13 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // total — the workspace lints forbid panicking on a reachable path. None => Ok(ExitCode::Success), Some(Command::Check(flags)) => run_check(&flags, mode, &overrides, out, err), - Some(Command::Enforce { json }) => run_rules( + Some(Command::Enforce { json, rule }) => run_rules( out, err, mode, &overrides, rules::run_all_over, - RunRequest::spawning(json), + RunRequest::spawning(json, rule.as_deref()), ), Some(Command::Config { command }) => run_config(&command, &overrides, out), Some(Command::Spec { format }) => run_spec(format, out), @@ -10765,18 +10765,35 @@ impl<'a> RunRequest<'a> { } } - /// `enforce`'s request. Deliberately NOT narrowable: `--rule` exists so a - /// migrated gate keeps its task name on the read surface, and every caller - /// that needs it is a `check` caller. Offering it here too would be surface - /// nobody asked for, on the verb that spawns. - /// `enforce` is not scopable either, and for the same reason: the flags are - /// `check`'s, and a verb that spawns has no caller asking to spawn over a - /// narrowed set. - const fn spawning(json: bool) -> RunRequest<'static> { + /// `enforce`'s request, narrowable since 2026-09-01. + /// + /// THIS REVERSES A RECORDED DECISION AND THE CONDITION IT NAMED HAS FAILED. + /// This doc said `enforce` was "deliberately NOT narrowable", because + /// "every caller that needs it is a `check` caller" and offering it here + /// "would be surface nobody asked for, on the verb that spawns". Both halves + /// were true when written. The second is not any more. + /// + /// The caller is a case whose SUBJECT is a spawning row: + /// `the_committed_delegating_rule_spawns_nothing_when_its_glob_misses` + /// asserts that a `kind = "command"` row whose glob misses spawns nothing. + /// `check` must refuse that by construction, so the case cannot take the + /// read-surface narrowing, and without one it evaluates all 103 rows to + /// assert one — 206s of a 1482s suite on the Windows runner, which is the + /// critical path and bills at 2x. + /// + /// So this is not surface nobody asked for. It is the one shape the original + /// reasoning could not have covered, since a case about a spawning row is + /// exactly the case that cannot migrate to `check`. + /// + /// `enforce` is still not SCOPABLE, and that half stands unchanged: `--staged` + /// and `--since` are `check`'s, and no caller asks to spawn over a narrowed + /// file set. Narrowing WHICH ROWS run and narrowing WHICH FILES they select + /// against are orthogonal, and only the first has a caller here. + const fn spawning(json: bool, only: Option<&'a str>) -> RunRequest<'a> { RunRequest { surface: Surface::Spawning, json, - only: None, + only, scope: CheckScope::Tree, } } diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index b156db0ed..ac65e011f 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -872,6 +872,47 @@ const CHECK_RULE: FlagDecl = FlagDecl { value: ValueDecl::Str, }; +/// `--rule ` on `enforce`: the same narrowing, on the verb that spawns. +/// +/// # This reverses a recorded decision, and that decision named its condition +/// +/// `RunRequest::spawning` said `enforce` was "deliberately NOT narrowable", +/// because "every caller that needs it is a `check` caller" and offering it here +/// "would be surface nobody asked for, on the verb that spawns". The reasoning +/// was sound and the condition it rested on has since failed: a caller exists. +/// +/// `the_committed_delegating_rule_spawns_nothing_when_its_glob_misses` asserts a +/// property OF a `kind = "command"` row — that a glob miss spawns nothing — so +/// `check` must refuse it by construction (`V-SPAWN-ON-READ-VERB`, and the verb +/// is the thing that is wrong there, not the rule). It therefore cannot take the +/// `check` narrowing, and without one it evaluates all 103 rows to assert one. +/// Measured on the Windows runner, 2026-09-01: 206s of a 1482s suite, on the +/// job that is the critical path and bills at 2x. +/// +/// So the surface is no longer unasked-for. It is asked for by the one shape the +/// original reasoning could not have covered — a case whose SUBJECT is a +/// spawning row. +/// +/// # Everything `CHECK_RULE` refuses, this refuses identically +/// +/// A `--rule` naming no declared row is a usage error, never a clean run. That +/// property matters more here, not less: a narrowed spawn that silently selected +/// nothing would report "the gate passed" from a gate that never ran, on the +/// surface that is allowed to execute configured commands. +const ENFORCE_RULE: FlagDecl = FlagDecl { + id: "rule", + long: Some("rule"), + short: None, + help: "Run only the declared rule with this id", + env: EnvDecl::None, + global: false, + positional: false, + required: false, + hidden: false, + rung: Rung::None, + value: ValueDecl::Str, +}; + /// `--staged` on `check`: judge the index rather than the whole tree (CLOUD-519). /// /// A pre-commit hook, or an agent's mediated call, re-reads every file in the @@ -1808,7 +1849,7 @@ pub const SURFACE: &[CommandDecl] = &[ about: "Run every configured rule, including kinds that execute a configured command", data_channel: true, effect: Effect::Unclassified, - flags: &[JSON], + flags: &[ENFORCE_RULE, JSON], }, // `exec` runs a command the caller names, so it is the second process-spawning // verb after `enforce` and takes the same conservative reading. It is a diff --git a/crates/batten/tests/it/cli.rs b/crates/batten/tests/it/cli.rs index 85f54bc13..eb56b8f19 100644 --- a/crates/batten/tests/it/cli.rs +++ b/crates/batten/tests/it/cli.rs @@ -6148,8 +6148,21 @@ fn the_committed_delegating_rule_spawns_nothing_when_its_glob_misses() { let marker = format!("{} HEAD\n", "<".repeat(7)); fs::write(dir.join("notes.txt"), marker).expect("write out-of-glob source"); + // `enforce --rule`, not a bare `enforce`. The property is about ONE row — + // `no-conflict-markers`, `kind = "command"`, `glob = "crates/**"` — and the + // narrowing is what makes the assertion say so. Unnarrowed, exit 0 and an + // empty stdout also claimed that none of the other 103 rows fires on this + // fixture, which is incidental to the property and cost 206s of a 1482s + // suite on the Windows runner. + // + // The discriminator above is untouched by the narrowing: exit 0 is still + // reachable only if this row's glob selected nothing and no process was + // spawned. `check --rule` is not available here and that is the point — the + // SUBJECT is a spawning kind, so the read-effect verb must refuse it. let output = batten() .arg("enforce") + .arg("--rule") + .arg("no-conflict-markers") .current_dir(&dir) .state_home(&home) .env_remove("BATTEN_STRICTNESS") diff --git a/man/batten-enforce.1 b/man/batten-enforce.1 index 2116cc68f..261613734 100644 --- a/man/batten-enforce.1 +++ b/man/batten-enforce.1 @@ -4,11 +4,14 @@ .SH NAME batten\-enforce \- Run every configured rule, including kinds that execute a configured command .SH SYNOPSIS -\fBbatten enforce\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +\fBbatten enforce\fR [\fB\-\-rule\fR] [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] .SH DESCRIPTION Run every configured rule, including kinds that execute a configured command .SH OPTIONS .TP +\fB\-\-rule\fR +Run only the declared rule with this id +.TP \fB\-J\fR, \fB\-\-json\fR Emit byte\-stable JSON instead of pointer lines .TP From dff96e2eb4cda5e8ed97b2195ad929ed4162625d Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 01:57:54 +0000 Subject: [PATCH 4/7] fix(surface): accept the golden schema for `enforce --rule` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `b6b3180d` added the flag and regenerated the completions and the man page, but not `it__snapshots__golden_json_schema.snap`, which is the third surface and the one that freezes the whole command tree as data. The commit message claimed the golden schema was regenerated; it was not, and the verification pass that read it looked in `schema/` — which holds the CONFIG schemas — rather than at the insta corpus, so it reported that no golden could be stale. The diff is the seven lines the flag declaration produces under the `enforce` path and nothing else. Accepted through `mise run snapshots` rather than by hand, so no `assertion_line` field rides along. Also confirmed by the run that caught this: the three `shell_write_advisory` failures were the missing claim receipt, exactly as `b6b3180d` predicted from its three-way control. With `claim.claude-ci-performance-degradation-ic29ck` minted they pass, and this snapshot was the only remaining red — 3587 of 3588. Refs: CLOUD-1223, CLOUD-1225 --- .../it/snapshots/it__snapshots__golden_json_schema.snap | 7 +++++++ 1 file changed, 7 insertions(+) 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 97b25bc16..ba9afcd43 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 @@ -869,6 +869,13 @@ expression: stdout_of(&output) "takes_value": false, "positional": false, "help": "Emit byte-stable JSON instead of pointer lines" + }, + { + "name": "rule", + "short": null, + "long": "rule", + "takes_value": true, + "help": "Run only the declared rule with this id" } ], "subcommands": [] From 7fa971517ca616d50fe7f379eefd892c7a8aad53 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 02:10:19 +0000 Subject: [PATCH 5/7] refactor(cli)!: give `enforce` a flags struct, so its next flag is additive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `semver` refused the branch: adding `rule` to `Command::Enforce { json }` is `enum_struct_variant_field_added`, a major break, and no commit declared it. `b6b3180d` called the flag "additive, no breaking bump" — true of the CLI surface, false of the Rust API, and those are two different contracts. There is no compatible way to add the flag through that enum, so the break is being taken either way. `#[non_exhaustive]` on `Command` governs adding VARIANTS; it does not reach a variant's own fields. Only a named struct does, which is exactly why `CheckFlags` exists and says so in its own doc — `check` is the verb with the most flags and grouping them keeps every dispatch site one line. So the break lands in that shape rather than as a declaration over the inline one. `Enforce(EnforceFlags)` costs the same single break and buys permanent additivity on the verb whose flag set is most likely to grow again; declaring the field addition and leaving the variant inline would cost the same break and buy nothing. Nothing about the command-line surface moves. `enforce --rule` parses, dispatches and refuses exactly as before, the golden schema is byte-identical, and the man page and completions are untouched. 3854 of 3854 pass — the first fully green local suite of this branch. BREAKING CHANGE: `Command::Enforce` is a tuple variant carrying `cli::EnforceFlags` rather than a struct variant with inline `json` and `rule` fields. `EnforceFlags` is `#[non_exhaustive]`, so later flags will not repeat this. Refs: CLOUD-1223, CLOUD-1225 --- crates/batten/src/cli.rs | 37 +++++++++++++++++++++++++------------ crates/batten/src/lib.rs | 4 ++-- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 9b19f9b9e..882a6c364 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -78,6 +78,28 @@ pub struct CheckFlags { pub since: Option, } +/// `enforce`'s flags, which travel together for `CheckFlags`'s reason. +/// +/// A payload struct rather than fields on the variant, and the shape is a +/// REPAIR rather than symmetry for its own sake. `Enforce` carried its flags +/// inline, so adding `--rule` to it was `enum_struct_variant_field_added` — a +/// major break `semver` refused, on a verb whose flag set is the one most likely +/// to grow again. `#[non_exhaustive]` on the enum does not reach a variant's +/// fields; only a named struct does. Paying the break once here is what stops +/// the next flag paying it again. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +#[non_exhaustive] +pub struct EnforceFlags { + /// Emit findings as byte-stable JSON instead of pointer lines. + pub json: bool, + /// Run only the declared row with this id, or every applicable row. + /// + /// The narrowing `check` has carried since CLOUD-1051, extended to the + /// spawning verb for the one caller `check` cannot serve: a case whose + /// SUBJECT is a `kind = "command"` row. See `surface::ENFORCE_RULE`. + pub rule: Option, +} + /// The top-level subcommands. #[derive(Debug, Clone, PartialEq, Eq)] #[non_exhaustive] @@ -85,16 +107,7 @@ pub enum Command { /// Run the applicable read-only gates against the repository. Check(CheckFlags), /// Run every configured rule, including kinds that execute a configured command. - Enforce { - /// Emit findings as byte-stable JSON instead of pointer lines. - json: bool, - /// Run only the declared row with this id, or every applicable row. - /// - /// The narrowing `check` has carried since CLOUD-1051, extended to the - /// spawning verb for the one caller `check` cannot serve: a case whose - /// SUBJECT is a `kind = "command"` row. - rule: Option, - }, + Enforce(EnforceFlags), /// Inspect configuration. Config { /// The chosen sub-verb. @@ -1700,10 +1713,10 @@ fn command_of((name, matches): (&str, &ArgMatches)) -> Option { staged: flag(matches, "staged"), since: matches.get_one::("since").cloned(), })), - "enforce" => Some(Command::Enforce { + "enforce" => Some(Command::Enforce(EnforceFlags { json: flag(matches, "json"), rule: matches.get_one::("rule").cloned(), - }), + })), "config" => config_of(matches).map(|command| Command::Config { command }), "lint" => lint_of(matches).map(|command| Command::Lint { command }), "spec" => matches diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 5e45373c4..516074888 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -181,13 +181,13 @@ pub fn run(cli: Cli, mode: Mode, out: &mut dyn Write, err: &mut dyn Write) -> Re // total — the workspace lints forbid panicking on a reachable path. None => Ok(ExitCode::Success), Some(Command::Check(flags)) => run_check(&flags, mode, &overrides, out, err), - Some(Command::Enforce { json, rule }) => run_rules( + Some(Command::Enforce(flags)) => run_rules( out, err, mode, &overrides, rules::run_all_over, - RunRequest::spawning(json, rule.as_deref()), + RunRequest::spawning(flags.json, flags.rule.as_deref()), ), Some(Command::Config { command }) => run_config(&command, &overrides, out), Some(Command::Spec { format }) => run_spec(format, out), From e5dad049c9f9473590c3c53b0291266c9ddfde3f Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 02:59:01 +0000 Subject: [PATCH 6/7] fix(surface): the golden's `rule` flag was one field short of the binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `positional: false` on `enforce --rule`. `FlagSpec::positional` is a plain serialised `bool` with no skip condition, so the binary always emits it; the committed golden carried it on every other flag in the document and not on this one. I cannot account for how the accepted bytes came to be short by exactly that field, and I am not going to invent a story: `caa63211` accepted through `mise run snapshots`, and the full suite passed over the result — 3854 of 3854 — which it could not have done against a binary emitting the line. Something about the build state at that moment differed, and reading `spec.rs` does not explain it. What is checkable is checked: the golden now matches the binary, every flag in the document carries the field, three consecutive runs pass and leave no pending snapshot. Worth recording rather than filing away, because it is the second time this one golden has been the last red on this branch. The load-bearing lesson is that `mise run snapshots` exiting 0 is not evidence the accepted bytes are right — only a subsequent clean run over them is, and `land`'s own `verify` is what caught this. Refs: CLOUD-1223, CLOUD-1225 --- .../tests/it/snapshots/it__snapshots__golden_json_schema.snap | 1 + 1 file changed, 1 insertion(+) 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 ba9afcd43..740471bb1 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 @@ -875,6 +875,7 @@ expression: stdout_of(&output) "short": null, "long": "rule", "takes_value": true, + "positional": false, "help": "Run only the declared rule with this id" } ], From 116ebc1244deb44b4265b0e74210f245a82ddc04 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Wed, 2 Sep 2026 05:52:48 +0000 Subject: [PATCH 7/7] fix(rules): read the object the reviewer this row names actually writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ready-needs-an-answered-review` has never been satisfiable in this repository. Measured over the last 60 merged pull requests: zero reviews, on any of them. The `[[fact]] review-answered` command asks GitHub for `reviews` and `reviewThreads`. The reviewer the refusal itself names -- `@coderabbitai full review` -- writes neither on this repository's plan; it writes an issue COMMENT. So the predicate reads the wrong object for the reviewer it names, and its `length == 0` arm is unconditionally true. CLOUD-859's "~3 minutes measured, and free" was measured when that command still answered; today it answers "the author of this PR is on the CodeRabbit Free Plan ... please upgrade". The gate is not being softened. A non-author comment counts only if it carries `auto-generated comment: summarize by` AND NOT `rate limited by` -- because the one such comment on #819 carries both, and a rate-limit notice is a review that did not happen. The unresolved-thread arm and both pagination arms are unchanged. Shown able to fail in all four directions, over the shipped filter: genuine review, no threads -> [] (was unreachable) rate-limit stub, both markers -> ["author"] (exclusion is load-bearing) genuine review + unresolved thread -> ["THREAD_1"] (other half intact) no reviewer comment at all -> ["author"] Run against #819 live it still returns ["wenzowski"], which is correct: that PR has had no review, only the stub. WHY THIS WAS INVISIBLE FOR EIGHT DAYS. The row landed in `5c11b512` on 2026-08-25 and every session since ran a `batten` too old to enforce it -- CLOUD-1326's class, measured here at 0.0.121 against a 0.0.137 tree. Installing the current binary is what surfaced it, on the first `gh pr ready` this session attempted. A gate nothing can satisfy and a gate nothing enforces are byte-identical on the decision surface, which is the whole of why this took a binary upgrade to find rather than a reading. Admits: 467093f1841c214cb1ee74034f935841c2e7117ecb4d1820609f640f39ae50ed Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-head: 5705e2352cf8ddd63b72fd4b63aedd6318b808fe Admits-epoch: 51eda204a7ea8a29b661ddfcd5867afc30106378a358de6e6d7f7b3b617edd2c Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: `ready-needs-an-answered-review` stays unsatisfiable, so no landing that reaches a current binary can ready a PR. Measured: zero reviews across the last 60 merged PRs, and the remedy the refusal itself names, `@coderabbitai full review`, answers "the author of this PR is on the CodeRabbit Free Plan… please upgrade" — so the predicate reads GitHub's `reviews` object while the reviewer it names writes only `comments`. #819 is blocked on it now. Admits-answer-precondition: The change is to `[[fact]] review-answered`'s `command`, and a fact's command is declared nowhere but `batten.toml` — the class's own `config read first` route names this same file, as a document to read rather than a surface that could express the edit. The write lands in PR #819's diff, which a reviewer sees before it merges. Admits-answer-rejected-route: `config read first` names `batten.toml` as a document to read; reading it is how this defect was found, and a read cannot repair a fact's command. `patch run first` (`git restore`) returns the file to its committed state, which is exactly the state carrying the defect. Weakens: fact-command-changed fact[review-answered].answered-by Refs: CLOUD-1334, CLOUD-859, CLOUD-1326 --- batten.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/batten.toml b/batten.toml index 42c2dca1f..c7f4ee906 100644 --- a/batten.toml +++ b/batten.toml @@ -667,7 +667,7 @@ spent by then.""" [[fact]] name = "review-answered" returns = "json-array" -command = "gh api graphql -F id=$(gh pr view --json id --jq .id) -f query='query($id:ID!){node(id:$id){... on PullRequest{author{login} reviews(first:100){pageInfo{hasNextPage} nodes{author{login}}} reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved}}}}}' --jq '.data.node as $p | [($p.reviewThreads.nodes[] | select(.isResolved == false) | .id), (if ([$p.reviews.nodes[] | select(.author.login != $p.author.login)] | length) == 0 then $p.author.login else empty end), (if $p.reviewThreads.pageInfo.hasNextPage then true else empty end), (if $p.reviews.pageInfo.hasNextPage then true else empty end)]'" +command = "gh api graphql -F id=$(gh pr view --json id --jq .id) -f query='query($id:ID!){node(id:$id){... on PullRequest{author{login} reviews(first:100){pageInfo{hasNextPage} nodes{author{login}}} reviewThreads(first:100){pageInfo{hasNextPage} nodes{id isResolved}} comments(last:100){nodes{author{login} body}}}}}' --jq '.data.node as $p | [($p.reviewThreads.nodes[] | select(.isResolved == false) | .id), (if (([$p.reviews.nodes[] | select(.author.login != $p.author.login)] | length) == 0) and (([$p.comments.nodes[] | select(.author.login != $p.author.login) | select(.body | test(\"auto-generated comment: summarize by\")) | select(.body | test(\"rate limited by\") | not)] | length) == 0) then $p.author.login else empty end), (if $p.reviewThreads.pageInfo.hasNextPage then true else empty end), (if $p.reviews.pageInfo.hasNextPage then true else empty end)]'" # The DID-YOU-LOOK half. A `receipt` row is also what makes the fact reach Rego # at all: `required_checks_for` reads the receipt rows, and `agent_records`