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..3aada79e3 100644 --- a/batten.toml +++ b/batten.toml @@ -4823,6 +4823,37 @@ 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` 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/*.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 +10131,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..0e8887d6f --- /dev/null +++ b/crates/batten/tests/it/landing_roster.rs @@ -0,0 +1,235 @@ +//! `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. +//! +//! 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 +//! +//! `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 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 { + fs::write(dir.join("fast-forward.yml"), 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/*.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. 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 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).is_empty(), + "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()]); +} + +/// 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] +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 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_refused() { + let root = repo("landing-roster-absent", None); + assert_eq!( + rules_fired(&root), + vec![UNGUARDED.to_owned()], + "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/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..b3797a6d0 --- /dev/null +++ b/policy/landing-roster-guarded.rego @@ -0,0 +1,223 @@ +# 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 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. +# +# 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 +# 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 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 +# 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 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: | +# 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, "mise run checks-green") + not startswith(trim_space(line), "#") +} + +# 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 { + not guarded +} + +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", +] + +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 { + 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" +} + +# 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({}, {}) +} + +# 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, + }, + {}, + ) +}