From ab94025b17ccd90a79715d2b7898703d70b33ecf Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 7 Sep 2026 05:39:18 +0000 Subject: [PATCH 1/4] fix(landing): consult the required-check roster before moving main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `protect-main` requires exactly one context, `final`, and `final` declares `needs: [ci, batten-check, bats, perf]` — four of the twenty names in `CI_REQUIRED_CHECKS`. Branch protection passing is therefore not the roster answering, and required status checks apply to direct pushes, which is what `/fast-forward` performs. Measured on PR #880 head 6eb08e14: `final` concluded 02:57:30 and `windows` 03:01:50. For that 4m20s the one required context was green while a required check was still running, and a `/fast-forward` posted in it would have moved `main` to an ungraded head. That is how the row was found. Three shapes are refuted by the tree rather than by argument. A `workflow_call` from ci.yml — or moving the four jobs back — makes a path-filtered check SKIP where today it is ABSENT, and `skipped` is excluded from `CI_ANSWERED_CONCLUSIONS` on purpose, so it wedges `land` at exit 3; that is the defect rust.yml's split exists to avoid. And `final` reading the siblings by name is CLOUD-897, already walked and refused. A path-filtered check must be absent to avoid wedging the loop and a ruleset cannot require an absent context, so the host cannot enforce this roster by construction. So the enforcement point is the landing path, where fast-forward.yml already decides two things itself rather than delegating to the ruleset — the draft head and the unreviewed fork head — both on the reasoning that the ruleset is the thing measured not to hold. The step reuses `mise run checks-green` whole: re-deriving "green" here would be the second authority CLOUD-363 measured into a branch wedge that took a hand-minted SHA to escape. Building is not affordable at 683s against a 5-minute budget, so the released binary is installed through install.sh. policy/landing-roster-guarded.rego is the ratchet. It does not assert that the fan-in covers the roster — that is false by design and would refuse the tree forever — but that the landing path asks the question at all. Verified: the adapter exits 0 over all twenty answered and exits 3 with `windows` in flight and nineteen graded; the channel was confirmed with an unconditional arm before the clean reading was trusted. Refs: CLOUD-1570 Refs: CLOUD-1579 Admits: b1267e1e1dead053e5ff2f7044b1de9731a881b85cf4ddaae5f5ee3e7fc0f8f1 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: .github/workflows/fast-forward.yml Admits-anchor: call:6eb08e1468c244ea3a4a3791d482d15c83e6c6ab Admits-epoch: d15646da656c93efe11540f87e16e245fd48630292cfe2d15ec53eb3ea9b49f6 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The window CLOUD-1570 measured stays open: `final` fans in over 4 of the 20 names in `CI_REQUIRED_CHECKS`, so on PR #880's head `6eb08e14` the one context branch protection requires went green at 02:57:30 while `windows` ran until 03:01:50. In that 4m20s any OWNER/MEMBER/COLLABORATOR typing `/fast-forward` moves `main` to a head whose roster has not answered, and the host refuses nothing. Measured here twice through the adapter: all 20 answered exits 0, `windows` in flight with 19 graded exits 3. Admits-answer-precondition: The class declares two other routes and both point at surfaces that cannot carry this change: `[[redirect]]` and `batten.toml` declare WHICH paths are protected and what to write instead, and neither can express a refusal STEP inside a workflow job. CLOUD-1570's fix is a step in `fast-forward.yml` running `mise run checks-green` before the fast-forward action, and there is no config spelling for it. The write is the entire content of the PR that closes CLOUD-1570, so a reviewer sees it in the diff it lands in. Admits-answer-rejected-route: `config read first` points at `batten.toml`, which declares the protected set and the per-class redirects; it cannot hold a workflow job step, and widening the protected set is not the change. `patch run first` is `git restore`, which discards the edit and is the opposite of the change. Neither reaches a step inside `.github/workflows/fast-forward.yml`, which is where the enforcement point has to be: a path-filtered check must be ABSENT rather than skipped to avoid wedging `land`, and a ruleset cannot require an absent context, so the host structurally cannot enforce this roster. Admits: c4b8b2d2f41d01067e23766049a33c17eb10e86ff7be36c8e53ade0024f2733f Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:6eb08e1468c244ea3a4a3791d482d15c83e6c6ab Admits-epoch: d15646da656c93efe11540f87e16e245fd48630292cfe2d15ec53eb3ea9b49f6 Admits-author: alec@wenzowski.com Admits-prev: 6130ede1c401d25e38e7c4a26a71c41bb83b88ca194b88815c896f5fff4e68f9 Admits-answer-lost: Non-negotiable rule 2: a rule without a runnable gate is half a change. Without these rows `policy/landing-roster-guarded.rego` is an unregistered file the engine never loads, so the roster guard added to `fast-forward.yml` could be deleted silently and nothing would refuse it — the regression the module exists to prevent. The two rows cannot be split: a module raising a token no `[[verdict]]` row declares fails the load, so registering one without the other takes the whole bundle down. Admits-answer-precondition: A `[[rule]]` row and a `[[verdict]]` row can only be declared in `batten.toml` — it IS the policy authority and the verdict registry, so registering a gate has no other surface by construction. `[[redirect]]` names a narrower remedy per path class and cannot add rows to itself. This write registers `landing-roster-guarded` and its `check read never` class in ONE edit, which is the shape the previous attempt should have taken: split across two admissions it left the config in a half-registered state that would not load. It lands in the PR closing CLOUD-1570, visible in the diff. Admits-answer-rejected-route: `config read first` points at `batten.toml`, the file being written; it is a read route and cannot register a rule or declare a class. `patch run first` is `git restore` — it was just SPENT correctly, to undo a class name the vocabulary refused (`roster` is not a declared subject), and running it again would discard this registration too. Neither can add a `[[rule]]` or a `[[verdict]]`, because that surface is `batten.toml` and nothing else. --- .github/workflows/fast-forward.yml | 104 +++++++++++++ batten.toml | 52 +++++++ crates/batten/tests/it/landing_roster.rs | 172 +++++++++++++++++++++ crates/batten/tests/it/main.rs | 1 + mise.toml | 2 +- policy/landing-roster-guarded.rego | 183 +++++++++++++++++++++++ 6 files changed, 513 insertions(+), 1 deletion(-) create mode 100644 crates/batten/tests/it/landing_roster.rs create mode 100644 policy/landing-roster-guarded.rego diff --git a/.github/workflows/fast-forward.yml b/.github/workflows/fast-forward.yml index b0de4beca..ccafc4a74 100644 --- a/.github/workflows/fast-forward.yml +++ b/.github/workflows/fast-forward.yml @@ -186,6 +186,110 @@ jobs: fi echo "#$PR's head is a fork, and $approvals trusted approval(s) have read the diff." + # THE ONE CONTEXT THE RULESET REQUIRES IS A FAN-IN OVER 4 OF 20 REQUIRED + # CHECKS, so "branch protection passed" is not "the roster is green" + # (CLOUD-1570). Third instance of this file's own class, on the reasoning + # the two steps above already state: the ruleset is precisely the thing + # measured not to hold. + # + # MEASURED on PR #880 head `6eb08e14`: `final` (ci.yml) concluded at + # 02:57:30 and `windows` (rust.yml) at 03:01:50 — 4m20s in which the one + # required context was green while a required check was still running. A + # `/fast-forward` posted in that window is admitted by the host and moves + # `main` to a head whose roster had not answered. Not hypothetical: that is + # how the row was found. + # + # `needs:` CANNOT CLOSE IT, AND THREE SHAPES ARE REFUTED. It does not reach + # another workflow, and the four Rust jobs live in `rust.yml` on purpose: a + # `paths:`-filtered workflow that does not trigger mints NOTHING, which + # `CI_ABSENT_OK_CHECKS` accepts, where a job skipped by an `if:` mints + # `skipped`, which `CI_ANSWERED_CONCLUSIONS` excludes and which wedges + # `land` at exit 3. So calling those jobs from `ci.yml` — by + # `workflow_call` or by moving them back — reintroduces the defect the + # split fixed. And `final` reading the siblings by name is CLOUD-897, + # already walked and refused: it concluded before the analyzer started on + # every recent merged PR, so "a step that cannot decide should not be in + # the one job branch protection requires". + # + # A path-filtered check must be ABSENT to avoid wedging the loop, and a + # ruleset cannot require an absent context. The host therefore cannot + # enforce this roster BY CONSTRUCTION, so the enforcement point is here. + # + # THE PREDICATE IS THE ENGINE'S, REUSED WHOLE. `mise run checks-green` is + # the adapter `land` itself decides on: it fetches the runs with `gh api` + # and hands them to `batten checks green`, a pure function of that TSV. + # Re-deriving "green" here would be a second authority over one word, which + # is CLOUD-363 exactly — `land` counted `cancelled` as an answer, + # `checks-green` read it as red, and the two composed into a branch wedge + # that took a hand-minted SHA to escape. + # + # UNREACHABLE FROM `land`, like the draft step above: `land` runs this same + # predicate before it comments. What this catches is a comment from any + # other OWNER/MEMBER/COLLABORATOR — a maintainer typing `/fast-forward` + # from the web UI while the matrix is still running, which is the measured + # case. + # + # EXIT 3 IS A REFUSAL, and that is the adapter's own contract rather than a + # reading of it: 0 green, 1 red, 2 could-not-look, 3 no answer yet. Only 0 + # may land. Could-not-look is refused too — "a reading this gate cannot + # take is not a pass" is the posture `checks-green` states for its own + # `gh api` failure, and honouring it anywhere else would be a second one. + # + # THE SETUP IS PAID LAST ON PURPOSE. The two refusals above are one + # `gh api` call each, so a draft or an unreviewed fork head is turned away + # before this job installs anything. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: jdx/mise-action@3c2e0cf82a5b2e5249f0d3635a4d83d0ae861518 # v4.2.5 + with: + # 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. + install: false + # BUILDING IS NOT AFFORDABLE HERE: `batten-check` is the CI workflow's pole + # at 683s against this job's 5-minute budget. `install.sh` is the route the + # single-binary claim already owes — POSIX sh, no toolchain, no clone, + # every asset verified against its published SHA-256 digest with no flag to + # skip it (CLOUD-65). `release-artifacts.yml` publishes + # `x86_64-unknown-linux-gnu`, and releases here are cut per merge. + - name: install the released engine + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + sh ./install.sh + batten --version + - name: refuse a head whose required checks have not all answered + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.issue.number }} + run: | + set -euo pipefail + # The head as it is NOW, not as the comment event snapshotted it — the + # same reason the draft step re-reads the draft bit through `gh api`. + head_sha=$(gh api "repos/$REPO/pulls/$PR" --jq .head.sha) + rc=0 + SHA="$head_sha" mise run checks-green || rc=$? + if [ "$rc" -eq 0 ]; then + echo "#$PR at $head_sha is green over the whole required roster." + exit 0 + fi + case "$rc" in + 1) why="a required check is RED" ;; + 3) why="a required check has not answered yet" ;; + *) why="the check runs could not be read, and a reading this gate cannot take is not a pass" ;; + esac + # Pointer-only: the PR, the head, and which arm refused — never a run's + # log, and never the comment body, which is how a substring trigger + # re-arms itself (CLOUD-853). + gh api "repos/$REPO/issues/$PR/comments" -f body="$( + printf '%s' "Refusing to fast-forward #$PR at \`$head_sha\`: $why. Branch protection requires only \`final\`, which fans in over 4 of the 20 names in \`CI_REQUIRED_CHECKS\` — so a green \`final\` is not a green roster (CLOUD-1570). Wait for the matrix, or let \`mise run land\` drive it." + )" >/dev/null + echo "::error:: refusing #$PR at $head_sha: $why (CLOUD-1570)" + exit 1 + # `# v1.0.0`, NOT `# v1`, and the difference is load-bearing rather than # pedantic (CLOUD-676). Upstream publishes exactly one tag — `v1.0.0` — # with no floating major, unlike `actions/checkout` and diff --git a/batten.toml b/batten.toml index ec36ce234..da8953490 100644 --- a/batten.toml +++ b/batten.toml @@ -4823,6 +4823,29 @@ line_sources = ["crates/batten/tests/it/**/*.rs"] module = "policy/fixture-forks.rego" severity = "deny" +# The landing path consults the required-check roster before it moves `main` +# (CLOUD-1570). +# +# NO `base` AND NO `delta_sources`, unlike its neighbour above. `fixture-forks` +# asks whether a change ADDED a fork, so it needs the branch's own diff; this +# asks whether the landing workflow guards the roster AT ALL, which is a +# property of the committed tree rather than of the change. A delta-scoped +# spelling would go silent on every branch that does not touch the workflow — +# including the branch that deletes the guard from a file it is not otherwise +# editing. +# +# `line_sources` IS THE ONE PATH, not `.github/workflows/**`. The module is +# anchored on the landing workflow, and handing it every workflow in the tree +# would make it read `ci.yml` to answer a question about `fast-forward.yml` — +# `fixture-forks`' own reason for narrowing, one surface over. +[[rule]] +id = "landing-roster-guarded" +kind = "policy" +scope = "tree" +line_sources = [".github/workflows/fast-forward.yml"] +module = "policy/landing-roster-guarded.rego" +severity = "deny" + # Advisory rather than blocking: assertions thinning out while the `#[test]` # count holds is the gutting shape, and it is worth saying without blocking — # a refactor legitimately merges assertions. `--fail-on-warning` and @@ -10100,6 +10123,35 @@ id = "source read first" kind = "document" target = "crates/batten/tests/it/common/mod.rs" +# CLOUD-1570. `check` is the declared subject — "one named CI check, or a +# `batten check` run" — and `read never` is the pair `patch read never` already +# spends, so this name costs the vocabulary nothing. +[[verdict]] +id = "check read never" +gloss = "the landing workflow can move `main` without consulting the required-check roster" +class = """ +`protect-main` requires exactly one context, `final`, and `final` declares \ +`needs: [ci, batten-check, bats, perf]` — four of the twenty names in \ +`CI_REQUIRED_CHECKS`. So branch protection passing is not the roster \ +answering. Measured on PR #880 head `6eb08e14`: `final` concluded 02:57:30 and \ +`windows` 03:01:50, and for that 4m20s a `/fast-forward` comment would have \ +moved `main` to a head fifteen of whose required checks the host cannot see. \ +The host cannot close this by construction — a `paths:`-filtered check must be \ +ABSENT rather than `skipped` to avoid wedging `land`, and a ruleset cannot \ +require an absent context — so the landing workflow is where the roster is \ +enforced, and this class fires when it stops asking. +""" + +[[verdict.route]] +id = "patch run first" +kind = "command" +target = "restore the step running `mise run checks-green` against the PR head, before the fast-forward action" + +[[verdict.route]] +id = "source read first" +kind = "document" +target = ".github/workflows/fast-forward.yml" + [[verdict]] id = "source read unread" gloss = "a fixture module in the declared set could not be read, so the gate could not look" diff --git a/crates/batten/tests/it/landing_roster.rs b/crates/batten/tests/it/landing_roster.rs new file mode 100644 index 000000000..2d8b169b2 --- /dev/null +++ b/crates/batten/tests/it/landing_roster.rs @@ -0,0 +1,172 @@ +//! `policy/landing-roster-guarded.rego` over the COMPILED engine (CLOUD-1570). +//! +//! # Why this file exists when the module already has `test_` rules +//! +//! Those are the load-time tier and they pin the PREDICATE. They cannot pin that +//! the engine BUILDS the input the predicate reads: `with input as` fabricates +//! the very shape the engine may be unable to produce, so a module reading a key +//! nothing fills passes its own suite green and enforces nothing. +//! +//! This module reads two things the engine has to resolve rather than a harness +//! hand over — `input.tree.lines` for one declared path, and +//! `input.tree.missing` when that path cannot be read. The second is the one +//! that has gone dead before: `.claude/rules/policy-modules.md` records that +//! `missing` was silently unfillable for its whole early life, that two +//! measurements reported it as an unfilled channel, and that the only thing +//! which distinguishes a live channel from a dead one is an arm that does not +//! itself read the channel. +//! +//! # The case that matters most drives the REAL committed workflow +//! +//! `the_committed_landing_workflow_is_guarded` scans this repository's own tree. +//! A fixture-only suite would pass over a `fast-forward.yml` that had lost the +//! guard, which is the exact regression this module exists to refuse. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use crate::common; + +use std::fs; +use std::path::{Path, PathBuf}; + +use batten::rules::{self, Rule}; + +/// The predicate id the module declares. +const UNGUARDED: &str = "landing-roster-unguarded"; + +/// The one path the module is anchored on. +const LANDING: &str = ".github/workflows/fast-forward.yml"; + +/// A landing workflow that consults the roster, in the spelling the real one +/// uses. +const GUARDED: &str = r#" +jobs: + fast-forward: + steps: + - name: refuse a head whose required checks have not all answered + run: | + SHA="$head_sha" mise run checks-green +"#; + +/// And one that does not: it goes straight to the push. +const UNGUARDED_BODY: &str = r#" +jobs: + fast-forward: + steps: + - uses: sequoia-pgp/fast-forward@ea7628b # v1.0.0 + with: + merge: true +"#; + +/// A fixture tree carrying a landing workflow with `body`, or none at all when +/// `body` is `None` — which is what puts the declared path in `missing` rather +/// than in `lines`. +fn repo(name: &str, body: Option<&str>) -> PathBuf { + let root = common::scratch(name); + if let Some(body) = body { + let full = root.join(LANDING); + fs::create_dir_all(full.parent().expect("the landing path has a parent")) + .expect("scratch workflow dir"); + fs::write(full, body).expect("write the landing workflow"); + } + install_module(&root); + root +} + +/// The COMMITTED module, copied rather than re-typed. A fixture carrying its own +/// copy of the predicate would pass while the shipped one was broken, which is +/// the fidelity failure this tier exists to catch. +fn install_module(root: &Path) { + let source = common::at_root("policy/landing-roster-guarded.rego") + .canonicalize() + .expect("the committed module is where the row says it is"); + fs::create_dir_all(root.join("policy")).expect("scratch policy dir"); + fs::copy(source, root.join("policy/landing-roster-guarded.rego")) + .expect("install committed module"); +} + +/// The committed row's shape, so a registration the loader would reject cannot +/// pass here. +fn row() -> Rule { + serde_json::from_value(serde_json::json!({ + "id": "landing-roster-guarded", + "kind": "policy", + "scope": "tree", + "line_sources": [".github/workflows/fast-forward.yml"], + "module": "policy/landing-roster-guarded.rego", + "severity": "deny", + })) + .expect("the loader accepts the committed row's shape") +} + +fn scan(root: &Path) -> rules::Scan { + let verdicts = common::verdicts_in(root); + // NO PATTERN ROWS, and that is a statement rather than an omission: this + // module resolves none, so supplying any would be input no consumer supplies + // and the cases would pass for the wrong reason. + rules::run_static( + &[row()], + &[], + batten::policy::Vocabulary { + patterns: &[], + verdicts: &verdicts, + recorders: &[], + }, + root, + ) + .expect("the read surface runs a policy row") +} + +fn rules_fired(root: &Path) -> Vec { + scan(root) + .findings + .into_iter() + .map(|finding| finding.rule) + .collect() +} + +/// THE FIDELITY CASE. This repository's own tree, scanned by the engine, against +/// the workflow that actually lands every PR here. `#MUTANT guard-unread` +/// reddens exactly here, and a fixture-only suite could not see the regression +/// this refuses. +#[test] +fn the_committed_landing_workflow_is_guarded() { + let root = common::at_root("batten.toml"); + let root = root.parent().expect("the committed config has a parent"); + assert!( + !rules_fired(root).contains(&UNGUARDED.to_owned()), + "the committed {LANDING} must consult the roster; \ + if this fails the landing path has lost its guard (CLOUD-1570)" + ); +} + +/// THE REFUSAL. Without this the case above is satisfied by a module that +/// refuses nothing. +#[test] +fn a_landing_workflow_that_does_not_consult_the_roster_is_refused() { + let root = repo("landing-roster-unguarded", Some(UNGUARDED_BODY)); + assert_eq!(rules_fired(&root), vec![UNGUARDED.to_owned()]); +} + +/// AND THE PASS SIDE OVER A FIXTURE, so the refusal above is shown to turn on +/// the guard's presence rather than on the fixture being a fixture. +#[test] +fn a_fixture_landing_workflow_that_consults_the_roster_is_clean() { + let root = repo("landing-roster-guarded", Some(GUARDED)); + assert!(rules_fired(&root).is_empty()); +} + +/// THE COULD-NOT-LOOK CHANNEL, DRIVEN BY THE ENGINE. A declared `line_sources` +/// path that is not there must reach `input.tree.missing` and be reported — +/// never read as a clean tree. Asserted here rather than with `with input as` +/// for the reason `.claude/rules/policy-modules.md` gives: fabricating the +/// shape is exactly how a dead `missing` channel survived two measurements. +#[test] +fn an_absent_landing_workflow_is_reported_rather_than_read_as_clean() { + let root = repo("landing-roster-absent", None); + assert_eq!( + rules_fired(&root), + vec![UNGUARDED.to_owned()], + "an absent declared source is could-not-look, and could-not-look is a finding" + ); +} diff --git a/crates/batten/tests/it/main.rs b/crates/batten/tests/it/main.rs index be1f29735..a7c3c0a10 100644 --- a/crates/batten/tests/it/main.rs +++ b/crates/batten/tests/it/main.rs @@ -153,6 +153,7 @@ mod judge_kind; mod land; mod land_hand_stepping; mod landed_check; +mod landing_roster; mod lease_record; mod locator_index; mod lock_complete; diff --git a/mise.toml b/mise.toml index 63e3cf75f..211e87907 100644 --- a/mise.toml +++ b/mise.toml @@ -478,7 +478,7 @@ CI_FANIN_WORKFLOW = ".github/workflows/ci.yml" # which is a property of the world and belongs on a clock (`lock-complete`). REGORUS_OPA_COMPLIANCE = "1.2.0" REGORUS_OPA_COMPLIANCE_FOR = "0.11" -MUTANT_GATES = "mise,attestation-check,engine-config,engine-doctor,engine-landed,engine-pinned,engine-ready,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,ci-cache-declared,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared,worktree-registration" +MUTANT_GATES = "mise,attestation-check,engine-config,engine-doctor,engine-landed,engine-pinned,engine-ready,agentic-experiment-record,awk-regex-check,bats-invocation,batten-glob-check,board-diff-overlap,board-payloads,board-sweep,branch-age-check,cap-drift,ci-cache-declared,ci-hygiene,ci-lease-precondition,ci-parity,ci-slow-needed,ci-suite-lane,ci-tools-check,claim-before-code,claim-order-is-stated,claimed-keys,closing-key-check,coderabbit-config-check,commit-hygiene,connector-allow-guard,connector-allow-resolve,container-preflight,darwin-link,deferral-check,denials-outlive-the-turn,digest-major-agreement,doctor,done-check,done-pr-check,duplicate-close-check,egress-fencing,evaluator-closure-check,evaluator-io-check,filed-here,finding-sink-check,fixture-forks,forge-verdict-required,graph-check,harness-grant,harness-wiring,hk-fix-selection,hk-plan-required,hook-pin-check,hook-skip-local,in-progress-drain,install-check,land,land-divergence-assert,land-lock,land-lock-check,landed-check,landing-loop,landing-roster-guarded,leased-push,license-table-check,linear-check,lock-complete,macos-link-check,mcp-allow-check,mcp-attach-check,mcp-timeout-budget,merged-pr-keys,mise-action-floor,mise-pin-agreement,module-map-check,msrv-pin-agreement,mutation-declared-case,no-doctests,nonverdict-assert,ntia-check,obligations-bound,perf-assert,pinned-toolchain,pipefail-grep-check,plan-complete,pr-partition-restated,pr-unsubscribed,privileged-lane,prose-only,publish-credential-check,ready-cites-check,ready-lint,reclaim-census,release-assets-check,release-due,release-provision-parity,release-tag-shape,release-tracking-check,released,remedy-authorship,repetition-without-progress,report-only-check,review-answered,review-dispatched,run-shape,rust-paths-check,sbom,sbom-inventory,serena-mcp,shell-hygiene,shell-retirement,shell-write-advisory,signing-posture,sonar-gate,spec-ref-check,stop-posture,stop-posture-check,suite-bench-check,suite-subject-retirable,task-substitution,test-targets,timeout-check,token-bench-check,transcript-corpus-check,tree-clean,trunk-based,validator-verdict-clean,verdict-routes-resolve,verified,weakens-declared,worktree-registration" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/policy/landing-roster-guarded.rego b/policy/landing-roster-guarded.rego new file mode 100644 index 000000000..1aa3ca9a2 --- /dev/null +++ b/policy/landing-roster-guarded.rego @@ -0,0 +1,183 @@ +# CLOUD-1570's ratchet: the landing workflow consults the required-check roster +# before it moves `main`. +# +# WHAT IT IS FOR, MEASURED RATHER THAN ASSERTED. `$CI_REQUIRED_CHECKS` +# (`mise.toml`) names 20 checks. `final` — the ONE context `protect-main` +# requires — declares `needs: [ci, batten-check, bats, perf]`, so branch +# protection fans in over **4 of the 20**. On PR #880 head `6eb08e14`, `final` +# concluded at 02:57:30 and `windows` at 03:01:50: 4m20s in which the required +# context was green while a required check was still running. A `/fast-forward` +# posted in that window is admitted by the host. +# +# THE HOST CANNOT CLOSE THIS AND THAT IS STRUCTURAL, not an omission. A +# `paths:`-filtered check must be ABSENT rather than `skipped` — `skipped` is not +# in `CI_ANSWERED_CONCLUSIONS`, so a skipping required check wedges `land` at +# exit 3 — and a ruleset cannot require a context that is legitimately absent. So +# the roster is enforceable only where the landing decision is made, which is +# `fast-forward.yml`, and this module is what stops that guard being deleted. +# +# THIS IS NOT "THE FAN-IN COVERS THE ROSTER". That predicate is false by design +# here and a gate asserting it would refuse the tree forever — the shape +# `fixture-forks.rego` records as the one that gets an exception written for it, +# "and the exception is what rots". What is checkable, and what actually +# regresses silently, is whether the landing path asks the question at all. +# +# WHY A `contains` AND NOT A `[[pattern]]` ROW. The registry exists so one +# CONCEPT has one spelling, and this is not a concept with variants — it is one +# literal invocation of one task in one declared file. `fixture-forks.rego` takes +# a row because `git init` has two live spellings in the tree; this has one. +# `contains` is a string builtin, so no inline regex is being smuggled past the +# load-time refusal. +# +# WHAT IT DELIBERATELY DOES NOT DECIDE. Whether the guard is CORRECT — whether +# the adapter's exit codes are read the right way round, whether the step runs +# before the push. That is a judgement, and a gate resolving to it would be the +# model verdict non-negotiable rule 3 forbids. `crates/batten/tests/it/landing_roster.rs` +# is the tier that drives the engine over the real committed file; the adapter's +# own behaviour is `tests/checks-green.bats`'s and is not restated here. +#MUTANT-SUITE crates/batten/tests/it/landing_roster.rs +#MUTANT guard-unread|s@^\tsome line in input.tree.lines\[landing_workflow\]$@\tsome line in []@|the_committed_landing_workflow_is_guarded +#MUTANT missing-arm-silent|s@^\tsome path, _cause in input.tree.missing$@\tsome path, _cause in {}@|an_unreadable_landing_workflow_is_reported_rather_than_skipped +# +# THE FIRST MUTATION EMPTIES THE LINE WALK rather than negating `contains`. +# Negating the match would make `guarded` hold over any file at all, so the +# REFUSAL cases would still pass and the mutation would survive on them; emptying +# the walk makes `guarded` unreachable, which reddens the PASS case — the one +# asserting the committed file is guarded. A gate that refuses its own mechanism +# is the shape that gets switched off, and that case is what says it does not. +# +# THE SECOND NEUTERS THE COULD-NOT-LOOK ARM, whose case is the only one that can +# redden for it: every other case supplies a readable file. + +# METADATA +# description: | +# Bound to the TREE surface: this row is `scope = "tree"`, so it reads +# `input.tree` and never the mediated call. +# THIS BLOCK IS YAML AND MUST STAY THE LAST COMMENT BLOCK BEFORE `package`. +# schemas: +# - input: schema["policy-input.schema"] +package batten.landing_roster + +import rego.v1 + +rules contains "landing-roster-unguarded" + +# The one workflow that moves `main`. A consumer path in a consumer module, which +# is where non-negotiable rule 1 puts it: `crates/batten` may not name it and +# `batten.toml` is what declares the file this rule reads. +landing_workflow := ".github/workflows/fast-forward.yml" + +# The roster predicate, by the name the landing path invokes it under. `land` +# decides on the same adapter, so this is the one authority over "is this SHA +# green" being consulted rather than a second one being written. +guarded if { + some line in input.tree.lines[landing_workflow] + contains(line, "checks-green") +} + +# THE REFUSAL. The file was read and does not consult the roster, so branch +# protection is the only thing between a `/fast-forward` comment and `main` — +# and branch protection sees 4 of 20. +violation contains { + "rule": "landing-roster-unguarded", + "verdict": "check read never", + "subjects": [{"path": landing_workflow}], +} if { + input.tree.lines[landing_workflow] + not guarded +} + +# COULD NOT LOOK IS A FINDING, NOT A PASS. A declared source that will not parse +# or is not there belongs in `missing` rather than being silently absent, and a +# module that iterates only `lines` reports green over a file it never read. +violation contains { + "rule": "landing-roster-unguarded", + "verdict": "source read unread", + "subjects": [{"path": path}], +} if { + some path, _cause in input.tree.missing + path == landing_workflow +} + +deny contains finding if { + some finding in violation +} + +# --- the module's own tier --------------------------------------------------- +# +# These pin the PREDICATE. What they cannot pin is that the engine BUILDS the +# input the predicate reads — `with input as` fabricates the very shape the +# engine may be unable to produce — so `crates/batten/tests/it/landing_roster.rs` +# runs the same questions over the compiled binary against the real committed +# workflow. Both tiers, per `.claude/rules/policy-modules.md`, and the second is +# not optional. + +tree(lines, missing) := {"tree": {"lines": lines, "missing": missing}} + +guarded_file := [ + " - name: refuse a head whose required checks have not all answered", + " SHA=\"$head_sha\" mise run checks-green || rc=$?", +] + +unguarded_file := [ + " - uses: sequoia-pgp/fast-forward@ea7628b # v1.0.0", + " with:", + " merge: true", +] + +# THE PASS SIDE FIRST: without it every refusal below is satisfied by a module +# that refuses everything. `#MUTANT guard-unread` reddens exactly here. +test_a_landing_workflow_that_consults_the_roster_is_clean if { + count(violation) == 0 with input as tree( + {".github/workflows/fast-forward.yml": guarded_file}, + {}, + ) +} + +test_a_landing_workflow_that_does_not_is_refused if { + count(violation) == 1 with input as tree( + {".github/workflows/fast-forward.yml": unguarded_file}, + {}, + ) +} + +# THE POINTER IS THE FILE a reader opens (rule 4), and the class is the one the +# registry declares for it. +test_the_refusal_points_at_the_landing_workflow if { + some v in violation with input as tree( + {".github/workflows/fast-forward.yml": unguarded_file}, + {}, + ) + v.subjects[0].path == ".github/workflows/fast-forward.yml" + v.verdict == "check read never" +} + +# ANTI-VACUITY ON THE ANCHOR. Another workflow with the same content is not this +# rule's business — without the anchor the rule would refuse every workflow in +# the tree, and with a WRONG anchor it would refuse nothing at all while still +# passing the refusal case above. +test_another_workflow_is_not_this_rules_business if { + count(violation) == 0 with input as tree( + {".github/workflows/ci.yml": unguarded_file}, + {}, + ) +} + +# AND A SOURCE THAT COULD NOT BE READ IS A FINDING RATHER THAN A CLEAN TREE. +# `#MUTANT missing-arm-silent` reddens exactly here. +test_an_unreadable_landing_workflow_is_reported_rather_than_skipped if { + some v in violation with input as tree( + {}, + {".github/workflows/fast-forward.yml": "Unparsed"}, + ) + v.verdict == "source read unread" +} + +# A DIFFERENT PATH IN `missing` IS SOMEBODY ELSE'S COULD-NOT-LOOK, so the arm is +# anchored too rather than firing on any unreadable file in the tree. +test_another_unreadable_path_is_not_this_rules_could_not_look if { + count(violation) == 0 with input as tree( + {}, + {".github/workflows/ci.yml": "Unparsed"}, + ) +} From c2c442ac45000c7c6b2189e8f06f21908db69c77 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 7 Sep 2026 05:48:27 +0000 Subject: [PATCH 2/4] fix(landing): declare the directory, so deleting the guard is not invisible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compiled tier found both defects in the gate landed a commit ago, which is what that tier exists for. A rule whose declared `line_sources` match NO file is SKIPPED rather than evaluated over an empty document. The row named only `.github/workflows/fast-forward.yml`, on the reasoning that a module should not be handed files it does not judge — so a branch whose whole content is `git rm .github/workflows/fast-forward.yml` made the gate not run at all and passed clean. That is the dead-gate shape the module was written to refuse, reached through the module's own declaration. The predicate's body is `not guarded`, which holds over an absent file by construction, and it still produced nothing: measured `left: []` against `right: ["landing-roster-unguarded"]`. Declaring the directory guarantees a source exists, so the predicate fires on a deleted landing workflow exactly as on an unguarded one. The narrowing that matters — which file answers the question — is the module's and is unchanged. The `missing` arm is deleted rather than kept: a glob matching zero files is not an unreadable source but no source, so nothing reaches `lines`, `documents` or `missing`, and the arm was unreachable. THE UNCONDITIONAL-ARM PROBE CANNOT CATCH THIS, and it was run and passed before the tier. A `violation` whose body is `true` confirms the module evaluates on the tree it is run against; it says nothing about whether an arm is reachable, and nothing about a tree where the declared source is gone. Worth stating beside the rule that prescribes the probe. The second defect was narrower: scanning the repo root with a one-rule subset trips `check_registry_is_exhausted`, which reports every class the other rules would have raised. The fidelity case copies the committed bytes into a fixture instead — same assertion, and 0.038s rather than 4.077s. Recorded on CLOUD-1320, which owns the general form; CLOUD-1276 is the adjacent shape where the rule evaluates and one clause cannot fire. Refs: CLOUD-1570 Refs: CLOUD-1320 Admits: 602066abacece58b19a380d2b9751de501d0c9bc1eb25c4c3b1ed4ba23559250 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: batten.toml Admits-anchor: call:7b5bc4c966518336824938e3eb784f58dc1e8360 Admits-epoch: 765fdb3843f7629e80ce79d2be6516ae8d3cb4c2b0341c18f988959f8084e097 Admits-author: alec@wenzowski.com Admits-prev: c4b8b2d2f41d01067e23766049a33c17eb10e86ff7be36c8e53ade0024f2733f Admits-answer-lost: The gate cannot see its own subject being deleted. `landing-roster-guarded` exists to stop the roster check being removed from the landing path, and with the narrow `line_sources` the cheapest way to remove it — delete the file — is exactly the case the rule cannot reach, because the engine skips a rule with no matching source. That is the dead-gate shape the module was written to refuse, reached through the module's own declaration. Measured by `an_absent_landing_workflow_is_refused`, which returned no finding where one was owed. Admits-answer-precondition: A `[[rule]]` row's `line_sources` can only be declared in `batten.toml`, so widening it has no other surface; `[[redirect]]` names a per-path remedy and cannot edit a rule. The change is one line — `.github/workflows/fast-forward.yml` becomes `.github/workflows/*.yml` — and it is forced by a measurement rather than chosen: the compiled tier showed a rule whose declared sources match NO file is skipped rather than evaluated, so with the narrow glob a branch DELETING the landing workflow made the gate not run and passed clean. It lands in the PR closing CLOUD-1570, visible in the diff. Admits-answer-rejected-route: `config read first` points at `batten.toml`, the file being written, and is a read route that cannot change a rule's sources. `patch run first` is `git restore`, which would discard the whole registration landed earlier in this branch. Neither can widen a glob, because the rule table is `batten.toml` and nothing else. --- batten.toml | 18 +++-- crates/batten/tests/it/landing_roster.rs | 81 ++++++++++++------- policy/landing-roster-guarded.rego | 99 ++++++++++++++---------- 3 files changed, 123 insertions(+), 75 deletions(-) diff --git a/batten.toml b/batten.toml index da8953490..3aada79e3 100644 --- a/batten.toml +++ b/batten.toml @@ -4834,15 +4834,23 @@ severity = "deny" # including the branch that deletes the guard from a file it is not otherwise # editing. # -# `line_sources` IS THE ONE PATH, not `.github/workflows/**`. The module is -# anchored on the landing workflow, and handing it every workflow in the tree -# would make it read `ci.yml` to answer a question about `fast-forward.yml` — -# `fixture-forks`' own reason for narrowing, one surface over. +# `line_sources` DECLARES THE DIRECTORY AND THE MODULE READS ONE KEY, and the +# narrow spelling was tried here first and is wrong. It named only +# `.github/workflows/fast-forward.yml`, on `fixture-forks`' reasoning that a +# module should not be handed files it does not judge. Measured by the compiled +# tier: a rule whose declared sources match NO file is SKIPPED rather than +# evaluated over an empty document, so deleting the landing workflow made this +# gate not run at all and the branch passed clean — the dead-gate shape the +# module exists to refuse, reached through its own declaration. The directory +# guarantees a source exists (28 workflows), so the predicate fires on a DELETED +# landing workflow exactly as on an unguarded one. The narrowing that matters — +# which file answers the question — is the module's and is unchanged. +# Recorded on CLOUD-1320, which owns the general form. [[rule]] id = "landing-roster-guarded" kind = "policy" scope = "tree" -line_sources = [".github/workflows/fast-forward.yml"] +line_sources = [".github/workflows/*.yml"] module = "policy/landing-roster-guarded.rego" severity = "deny" diff --git a/crates/batten/tests/it/landing_roster.rs b/crates/batten/tests/it/landing_roster.rs index 2d8b169b2..75f87d93f 100644 --- a/crates/batten/tests/it/landing_roster.rs +++ b/crates/batten/tests/it/landing_roster.rs @@ -7,14 +7,13 @@ //! the very shape the engine may be unable to produce, so a module reading a key //! nothing fills passes its own suite green and enforces nothing. //! -//! This module reads two things the engine has to resolve rather than a harness -//! hand over — `input.tree.lines` for one declared path, and -//! `input.tree.missing` when that path cannot be read. The second is the one -//! that has gone dead before: `.claude/rules/policy-modules.md` records that -//! `missing` was silently unfillable for its whole early life, that two -//! measurements reported it as an unfilled channel, and that the only thing -//! which distinguishes a live channel from a dead one is an arm that does not -//! itself read the channel. +//! What the engine has to resolve here is `input.tree.lines` for one declared +//! path — and, decisively, what it does NOT resolve when that path is absent. +//! The first draft assumed a missing declared source lands in +//! `input.tree.missing`; `an_absent_landing_workflow_is_refused` measured that it +//! does not, because `line_sources` is a glob list and a glob matching nothing is +//! no source rather than an unreadable one. That arm was dead, and a branch +//! deleting the landing workflow passed the gate. //! //! # The case that matters most drives the REAL committed workflow //! @@ -59,15 +58,19 @@ jobs: "#; /// A fixture tree carrying a landing workflow with `body`, or none at all when -/// `body` is `None` — which is what puts the declared path in `missing` rather -/// than in `lines`. +/// `body` is `None` — which is what makes the declared glob match nothing, so +/// the path reaches neither `lines` nor `missing`. fn repo(name: &str, body: Option<&str>) -> PathBuf { let root = common::scratch(name); + let dir = root.join(".github/workflows"); + fs::create_dir_all(&dir).expect("scratch workflow dir"); + // A SIBLING WORKFLOW IS ALWAYS PRESENT, because the real tree always has 27 + // of them and because the declared glob must match something for the rule to + // run at all. A fixture with no workflows would exercise the skipped path + // rather than the predicate. + fs::write(dir.join("ci.yml"), "jobs:\n ci:\n").expect("write the sibling"); if let Some(body) = body { - let full = root.join(LANDING); - fs::create_dir_all(full.parent().expect("the landing path has a parent")) - .expect("scratch workflow dir"); - fs::write(full, body).expect("write the landing workflow"); + fs::write(dir.join("fast-forward.yml"), body).expect("write the landing workflow"); } install_module(&root); root @@ -92,7 +95,7 @@ fn row() -> Rule { "id": "landing-roster-guarded", "kind": "policy", "scope": "tree", - "line_sources": [".github/workflows/fast-forward.yml"], + "line_sources": [".github/workflows/*.yml"], "module": "policy/landing-roster-guarded.rego", "severity": "deny", })) @@ -125,16 +128,19 @@ fn rules_fired(root: &Path) -> Vec { .collect() } -/// THE FIDELITY CASE. This repository's own tree, scanned by the engine, against -/// the workflow that actually lands every PR here. `#MUTANT guard-unread` +/// THE FIDELITY CASE. The COMMITTED bytes of the workflow that actually lands +/// every PR here, scanned by the engine in a fixture — the repo root itself +/// cannot be scanned with a one-rule subset, because `check_registry_is_exhausted` +/// then reports every class the other rules would have raised. `#MUTANT guard-unread` /// reddens exactly here, and a fixture-only suite could not see the regression /// this refuses. #[test] fn the_committed_landing_workflow_is_guarded() { - let root = common::at_root("batten.toml"); - let root = root.parent().expect("the committed config has a parent"); + let committed = fs::read_to_string(common::at_root(LANDING)) + .expect("the committed landing workflow is where the row says it is"); + let root = repo("landing-roster-committed", Some(&committed)); assert!( - !rules_fired(root).contains(&UNGUARDED.to_owned()), + rules_fired(&root).is_empty(), "the committed {LANDING} must consult the roster; \ if this fails the landing path has lost its guard (CLOUD-1570)" ); @@ -156,17 +162,38 @@ fn a_fixture_landing_workflow_that_consults_the_roster_is_clean() { assert!(rules_fired(&root).is_empty()); } -/// THE COULD-NOT-LOOK CHANNEL, DRIVEN BY THE ENGINE. A declared `line_sources` -/// path that is not there must reach `input.tree.missing` and be reported — -/// never read as a clean tree. Asserted here rather than with `with input as` -/// for the reason `.claude/rules/policy-modules.md` gives: fabricating the -/// shape is exactly how a dead `missing` channel survived two measurements. +/// THE CASE THAT CORRECTED THE PREDICATE, kept with its history because the +/// history is the point. +/// +/// The first draft guarded the refusal on the file being present and carried a +/// second arm over `input.tree.missing` for absence. This case returned `[]` +/// where a finding was owed: `line_sources` is a GLOB LIST, and a glob matching +/// zero files is not an unreadable source but no source at all, so nothing +/// enters `documents`, `lines` or `missing`. A branch DELETING the landing +/// workflow passed the gate silently. +/// +/// The unconditional-arm probe `.claude/rules/policy-modules.md` prescribes had +/// already been run and had spoken — it confirms the MODULE evaluates, and says +/// nothing about whether a particular arm is reachable. Only driving the engine +/// over a tree with the file removed can tell. #[test] -fn an_absent_landing_workflow_is_reported_rather_than_read_as_clean() { +fn an_absent_landing_workflow_is_refused() { let root = repo("landing-roster-absent", None); assert_eq!( rules_fired(&root), vec![UNGUARDED.to_owned()], - "an absent declared source is could-not-look, and could-not-look is a finding" + "deleting the landing workflow removes the guard, and must refuse rather than read clean" ); } + +/// ANTI-VACUITY ON THE ANCHOR, over the engine's own projection. Another +/// workflow carrying the guard's text must not satisfy this rule — the failure +/// mode of an anchored rule whose walk quietly reads every path. +#[test] +fn the_guard_is_not_satisfied_from_another_workflow() { + let root = repo("landing-roster-other-file", Some(UNGUARDED_BODY)); + // The decoy carries the guard's own text, in a workflow that is not the + // landing one. + fs::write(root.join(".github/workflows/ci.yml"), GUARDED).expect("write the decoy"); + assert_eq!(rules_fired(&root), vec![UNGUARDED.to_owned()]); +} diff --git a/policy/landing-roster-guarded.rego b/policy/landing-roster-guarded.rego index 1aa3ca9a2..79b3a4700 100644 --- a/policy/landing-roster-guarded.rego +++ b/policy/landing-roster-guarded.rego @@ -22,6 +22,16 @@ # "and the exception is what rots". What is checkable, and what actually # regresses silently, is whether the landing path asks the question at all. # +# WHY THE ROW DECLARES THE WHOLE WORKFLOW DIRECTORY AND THE MODULE READS ONE KEY. +# The narrow spelling — `line_sources` naming only the landing workflow — was +# tried and is WRONG, measured by the compiled tier: a rule whose declared +# sources match no file is SKIPPED rather than evaluated over an empty document, +# so deleting the landing workflow made this gate not run at all and the branch +# passed clean. Declaring the directory guarantees the rule always has a source +# (there are 28 workflows), so `not guarded` fires on a DELETED landing workflow +# exactly as it does on an unguarded one. The module still reads one key, so the +# narrowing that matters — which file answers the question — is unchanged. +# # WHY A `contains` AND NOT A `[[pattern]]` ROW. The registry exists so one # CONCEPT has one spelling, and this is not a concept with variants — it is one # literal invocation of one task in one declared file. `fixture-forks.rego` takes @@ -37,7 +47,7 @@ # own behaviour is `tests/checks-green.bats`'s and is not restated here. #MUTANT-SUITE crates/batten/tests/it/landing_roster.rs #MUTANT guard-unread|s@^\tsome line in input.tree.lines\[landing_workflow\]$@\tsome line in []@|the_committed_landing_workflow_is_guarded -#MUTANT missing-arm-silent|s@^\tsome path, _cause in input.tree.missing$@\tsome path, _cause in {}@|an_unreadable_landing_workflow_is_reported_rather_than_skipped +#MUTANT guard-matches-anything|s@^\tcontains(line, "checks-green")$@\ttrue@|a_landing_workflow_that_does_not_consult_the_roster_is_refused # # THE FIRST MUTATION EMPTIES THE LINE WALK rather than negating `contains`. # Negating the match would make `guarded` hold over any file at all, so the @@ -46,8 +56,10 @@ # asserting the committed file is guarded. A gate that refuses its own mechanism # is the shape that gets switched off, and that case is what says it does not. # -# THE SECOND NEUTERS THE COULD-NOT-LOOK ARM, whose case is the only one that can -# redden for it: every other case supplies a readable file. +# THE SECOND MAKES THE GUARD MATCH ANY LINE, so `guarded` holds over a workflow +# that consults nothing and the REFUSAL case is the one that reddens. The two +# mutations therefore redden different cases — the pass side and the refuse side +# — which is what makes neither of them shadowed by the other. # METADATA # description: | @@ -75,30 +87,39 @@ guarded if { contains(line, "checks-green") } -# THE REFUSAL. The file was read and does not consult the roster, so branch -# protection is the only thing between a `/fast-forward` comment and `main` — -# and branch protection sees 4 of 20. +# THE REFUSAL, AND ITS BODY IS `not guarded` WITH NO PRESENCE CONJUNCT — which +# is a MEASURED shape rather than a shortcut, and the first draft got it wrong. +# +# That draft guarded this arm on `input.tree.lines[landing_workflow]` and carried +# a second arm over `input.tree.missing` for the could-not-look case, on the +# reading that a declared source which cannot be read lands there. It does not. +# `line_sources` is a GLOB LIST, and a glob matching zero files is not an +# unreadable source — it is no source at all, so nothing enters `documents`, +# `lines` OR `missing`. Measured by the compiled tier +# (`an_absent_landing_workflow_is_reported_rather_than_read_as_clean`), which +# returned `[]` where a finding was owed: a branch DELETING the landing workflow +# passed the gate silently, which is the exact dead-gate shape this module exists +# to refuse, reached through this module's own could-not-look clause. +# +# THE UNCONDITIONAL-ARM PROBE COULD NOT HAVE CAUGHT IT, and that is worth writing +# down beside the rule that prescribes the probe. A `violation` whose body is +# `true` confirms the MODULE evaluates; it says nothing about whether a +# particular arm is reachable. Only the tier that drives the engine over a tree +# with the file removed can tell, which is `.claude/rules/policy-modules.md`'s +# own reason for the second tier stated one level down. +# +# So absence and presence-without-the-guard are ONE class here, and they should +# be: both mean the landing path does not consult the roster before it moves +# `main`. Refusing on a whole-tree acquisition failure too is the safe direction +# — a landing workflow that cannot be read is not one that has been checked. violation contains { "rule": "landing-roster-unguarded", "verdict": "check read never", "subjects": [{"path": landing_workflow}], } if { - input.tree.lines[landing_workflow] not guarded } -# COULD NOT LOOK IS A FINDING, NOT A PASS. A declared source that will not parse -# or is not there belongs in `missing` rather than being silently absent, and a -# module that iterates only `lines` reports green over a file it never read. -violation contains { - "rule": "landing-roster-unguarded", - "verdict": "source read unread", - "subjects": [{"path": path}], -} if { - some path, _cause in input.tree.missing - path == landing_workflow -} - deny contains finding if { some finding in violation } @@ -152,32 +173,24 @@ test_the_refusal_points_at_the_landing_workflow if { v.verdict == "check read never" } -# ANTI-VACUITY ON THE ANCHOR. Another workflow with the same content is not this -# rule's business — without the anchor the rule would refuse every workflow in -# the tree, and with a WRONG anchor it would refuse nothing at all while still -# passing the refusal case above. -test_another_workflow_is_not_this_rules_business if { - count(violation) == 0 with input as tree( - {".github/workflows/ci.yml": unguarded_file}, - {}, - ) +# ABSENCE IS THE SAME CLASS. A branch that DELETES the landing workflow has +# removed the guard just as surely as one that edits it, and the compiled tier is +# what proved this arm was unreachable in the first draft. +test_an_absent_landing_workflow_is_refused if { + count(violation) == 1 with input as tree({}, {}) } -# AND A SOURCE THAT COULD NOT BE READ IS A FINDING RATHER THAN A CLEAN TREE. -# `#MUTANT missing-arm-silent` reddens exactly here. -test_an_unreadable_landing_workflow_is_reported_rather_than_skipped if { - some v in violation with input as tree( - {}, - {".github/workflows/fast-forward.yml": "Unparsed"}, - ) - v.verdict == "source read unread" -} - -# A DIFFERENT PATH IN `missing` IS SOMEBODY ELSE'S COULD-NOT-LOOK, so the arm is -# anchored too rather than firing on any unreadable file in the tree. -test_another_unreadable_path_is_not_this_rules_could_not_look if { - count(violation) == 0 with input as tree( +# ANTI-VACUITY ON THE ANCHOR, and it is the STRONG form: another workflow +# carrying the guard's own text does not satisfy this rule. With a wrong anchor — +# or with the walk reading every path rather than the declared one — this passes +# while every case above still passes, which is exactly how an anchored rule goes +# quietly wrong. +test_the_guard_is_not_satisfied_from_another_file if { + count(violation) == 1 with input as tree( + { + ".github/workflows/ci.yml": guarded_file, + ".github/workflows/fast-forward.yml": unguarded_file, + }, {}, - {".github/workflows/ci.yml": "Unparsed"}, ) } From ccbb91de351727aca10f0fd135615ba0446239d2 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 7 Sep 2026 06:29:33 +0000 Subject: [PATCH 3/4] style(test): drop the needless hashes from the unguarded fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `UNGUARDED_BODY` carries no quote, so `r#"…"#` is one delimiter wider than the literal needs and `clippy::needless_raw_string_hashes` refuses it. `GUARDED` keeps its hashes: it holds `SHA="$head_sha"`, which is the case the wider form exists for. Caught by `land`'s own local `verify` rather than by CI, which is the lap working — the PR stayed draft and no runner was spent on a one-line fix. Refs: CLOUD-1570 --- crates/batten/tests/it/landing_roster.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/batten/tests/it/landing_roster.rs b/crates/batten/tests/it/landing_roster.rs index 75f87d93f..be5eee514 100644 --- a/crates/batten/tests/it/landing_roster.rs +++ b/crates/batten/tests/it/landing_roster.rs @@ -48,14 +48,14 @@ jobs: "#; /// And one that does not: it goes straight to the push. -const UNGUARDED_BODY: &str = r#" +const UNGUARDED_BODY: &str = r" jobs: fast-forward: steps: - uses: sequoia-pgp/fast-forward@ea7628b # v1.0.0 with: merge: true -"#; +"; /// A fixture tree carrying a landing workflow with `body`, or none at all when /// `body` is `None` — which is what makes the declared glob match nothing, so From ea307944cd9ec0c1e4f52720f78ce49d08aa7761 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Mon, 7 Sep 2026 09:36:49 +0000 Subject: [PATCH 4/4] fix(landing): anchor the guard on the invocation, not the mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A code review found the third defect in this module, and it is the sharpest: the module's own DOCUMENTATION defeated it. `guarded` matched any line containing `checks-green`. The committed fast-forward.yml carries that substring on FOUR comment lines — 218, 223, 235, 247, the block explaining why the predicate is the engine's and reused whole — against ONE real invocation at 274. So deleting the roster step while leaving its rationale, which is the ordinary shape of a "this step was flaky, dropping it" edit, left the gate green over a landing path that no longer consults the roster. That is precisely the regression the module exists to refuse, reached through the module's own predicate. The more the guard was explained, the deader it got. `mise run checks-green` plus a comment exclusion discriminates an invocation from every mention of one. The committed workflow still passes, now on line 274 rather than on its own commentary. The existing anti-vacuity case rules out another FILE satisfying the rule; it could not see another LINE in the same file doing it. Both tiers gain that case — the load-time one over a fixture, and the compiled one driving the engine over a workflow whose only mention is a comment. A second `#MUTANT` row makes the comment exclusion itself mutable, and it reddens the new case rather than any existing one, so neither mutation shadows the other. Verified: policy test 62 bundles, 785 passed; the compiled tier 6/6; and `batten check --rule landing-roster-guarded` still exits 0 over this tree. Refs: CLOUD-1570 Admits: 307aeaa37c513256d3d454799b3630cb1586fd4085c8320206a0f1ce37d8a546 Admits-rule: protected-mutation Admits-verdict: path write refused Admits-subject: policy/landing-roster-guarded.rego Admits-anchor: call:e27e9f49e7feb1f6b34e5291878846ee0dd0d0cc Admits-epoch: 8bc2db336876b2ce4e32107f889d8142bd3bcc874fee5f67526429cb51694391 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The gate is defeated by its own documentation. `guarded` holds on any line containing `checks-green`, and `.github/workflows/fast-forward.yml` carries that substring on four COMMENT lines (218, 223, 235, 247) against one real invocation (274). So deleting the roster step while leaving its comment block — the ordinary shape of a "this step was flaky, dropping it" edit — leaves the module green over a landing path that no longer consults the roster. That is exactly the regression the module exists to refuse, reached through the module's own predicate. The fix anchors on `mise run checks-green` and excludes comment lines, which discriminates the invocation from every mention of it. Admits-answer-precondition: The class's other two routes cannot carry this change. `config read first` points at `batten.toml`, which declares WHICH paths are protected and what to write instead; it cannot edit a predicate's body. `patch run first` is `git restore`, which would discard the module entirely. The change is one conjunct in `policy/landing-roster-guarded.rego`'s `guarded` rule, and it lands in the PR closing CLOUD-1570 where a reviewer sees it in the diff. Admits-answer-rejected-route: `config read first` points at `batten.toml` and is a read route that cannot change a Rego predicate. `patch run first` is `git restore`, which discards the module this PR is adding rather than repairing its predicate. Neither reaches the body of a `.rego` rule, which lives only in the module file. --- crates/batten/tests/it/landing_roster.rs | 36 ++++++++++++++++++++++++ policy/landing-roster-guarded.rego | 31 ++++++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/batten/tests/it/landing_roster.rs b/crates/batten/tests/it/landing_roster.rs index be5eee514..0e8887d6f 100644 --- a/crates/batten/tests/it/landing_roster.rs +++ b/crates/batten/tests/it/landing_roster.rs @@ -154,6 +154,42 @@ fn a_landing_workflow_that_does_not_consult_the_roster_is_refused() { assert_eq!(rules_fired(&root), vec![UNGUARDED.to_owned()]); } +/// THE DEFECT A CODE REVIEW FOUND, and it is the sharpest of the three because +/// the module's own DOCUMENTATION was what defeated it. +/// +/// `guarded` first matched any line containing `checks-green`. The committed +/// `fast-forward.yml` carries that substring on FOUR comment lines — the block +/// explaining why the predicate is the engine's and reused whole — against ONE +/// real invocation. So deleting the step while leaving its comment block, which +/// is the ordinary shape of a "this step was flaky, dropping it" edit, left the +/// gate green over a landing path that no longer consults the roster. +/// +/// The more the guard was explained, the deader it got. Anchoring on +/// `mise run checks-green` and excluding comment lines is what discriminates the +/// invocation from every mention of it. +/// +/// The module's other anti-vacuity case rules out another FILE satisfying the +/// rule; this one rules out another LINE in the same file. +#[test] +fn a_comment_naming_the_roster_check_does_not_satisfy_the_guard() { + let commentary = "\ +jobs: + fast-forward: + steps: + # THE PREDICATE IS THE ENGINE'S, REUSED WHOLE. `mise run checks-green` is + # the adapter `land` itself decides on, and re-deriving it here would be a + # second authority — `checks-green` read `cancelled` as red (CLOUD-363). + - uses: sequoia-pgp/fast-forward@ea7628b # v1.0.0 +"; + let root = repo("landing-roster-comment-only", Some(commentary)); + assert_eq!( + rules_fired(&root), + vec![UNGUARDED.to_owned()], + "a comment that NAMES the roster check is not an invocation of it; \ + the step was deleted and only its rationale remains" + ); +} + /// AND THE PASS SIDE OVER A FIXTURE, so the refusal above is shown to turn on /// the guard's presence rather than on the fixture being a fixture. #[test] diff --git a/policy/landing-roster-guarded.rego b/policy/landing-roster-guarded.rego index 79b3a4700..b3797a6d0 100644 --- a/policy/landing-roster-guarded.rego +++ b/policy/landing-roster-guarded.rego @@ -32,6 +32,17 @@ # exactly as it does on an unguarded one. The module still reads one key, so the # narrowing that matters — which file answers the question — is unchanged. # +# AND IT ANCHORS ON THE INVOCATION, NOT THE MENTION — the third defect a review +# found in this module, and the sharpest, because the module's own DOCUMENTATION +# was what defeated it. `guarded` first matched any line containing +# `checks-green`, and the committed landing workflow carries that substring on +# FOUR comment lines against ONE real invocation: the block above explaining why +# the predicate is the engine's. So deleting the step while leaving its rationale +# — the ordinary shape of a "this was flaky, dropping it" edit — left the gate +# green over a landing path that no longer consults the roster. The more the +# guard was explained, the deader it got. `mise run checks-green` plus the +# comment exclusion is what tells an invocation from every mention of one. +# # WHY A `contains` AND NOT A `[[pattern]]` ROW. The registry exists so one # CONCEPT has one spelling, and this is not a concept with variants — it is one # literal invocation of one task in one declared file. `fixture-forks.rego` takes @@ -47,7 +58,8 @@ # own behaviour is `tests/checks-green.bats`'s and is not restated here. #MUTANT-SUITE crates/batten/tests/it/landing_roster.rs #MUTANT guard-unread|s@^\tsome line in input.tree.lines\[landing_workflow\]$@\tsome line in []@|the_committed_landing_workflow_is_guarded -#MUTANT guard-matches-anything|s@^\tcontains(line, "checks-green")$@\ttrue@|a_landing_workflow_that_does_not_consult_the_roster_is_refused +#MUTANT guard-matches-anything|s@^\tcontains(line, "mise run checks-green")$@\ttrue@|a_landing_workflow_that_does_not_consult_the_roster_is_refused +#MUTANT comment-counts-as-invocation|s@^\tnot startswith(trim_space(line), "#")$@\ttrue@|a_comment_naming_the_roster_check_does_not_satisfy_the_guard # # THE FIRST MUTATION EMPTIES THE LINE WALK rather than negating `contains`. # Negating the match would make `guarded` hold over any file at all, so the @@ -84,7 +96,8 @@ landing_workflow := ".github/workflows/fast-forward.yml" # green" being consulted rather than a second one being written. guarded if { some line in input.tree.lines[landing_workflow] - contains(line, "checks-green") + contains(line, "mise run checks-green") + not startswith(trim_space(line), "#") } # THE REFUSAL, AND ITS BODY IS `not guarded` WITH NO PRESENCE CONJUNCT — which @@ -146,6 +159,20 @@ unguarded_file := [ " merge: true", ] +comment_only := [ + " # THE PREDICATE IS THE ENGINE'S, REUSED WHOLE. `mise run checks-green`", + " - uses: sequoia-pgp/fast-forward@ea7628b # v1.0.0", +] + +# A COMMENT THAT NAMES THE CHECK IS NOT AN INVOCATION OF IT. +# `#MUTANT comment-counts-as-invocation` reddens exactly here. +test_a_comment_naming_the_roster_check_does_not_satisfy_the_guard if { + count(violation) == 1 with input as tree( + {".github/workflows/fast-forward.yml": comment_only}, + {}, + ) +} + # THE PASS SIDE FIRST: without it every refusal below is satisfied by a module # that refuses everything. `#MUTANT guard-unread` reddens exactly here. test_a_landing_workflow_that_consults_the_roster_is_clean if {