Skip to content

fix(landing): consult the required-check roster before moving main - #895

Draft
wenzowski wants to merge 3 commits into
mainfrom
claude/prose-dispatch-gate-verify-u0fnx3
Draft

fix(landing): consult the required-check roster before moving main#895
wenzowski wants to merge 3 commits into
mainfrom
claude/prose-dispatch-gate-verify-u0fnx3

Conversation

@wenzowski

@wenzowski wenzowski commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Closes CLOUD-1570.

DO-NOT-CLOSE CLOUD-1320
DO-NOT-CLOSE CLOUD-1579

Both are served by commits here and neither is completed by them. CLOUD-1320 is
the general rule — a row's line_sources must supply every path its module
classifies — and this PR only repairs its own module's instance of it.
CLOUD-1579 was filed from this branch and is untouched by it.

Why

protect-main requires exactly one context, final. Required status checks apply to direct pushes, and landing here is a direct push by the /fast-forward bot.

final declares needs: [ci, batten-check, bats, perf]. $CI_REQUIRED_CHECKS names 20. So branch protection fans in over 4 of 20, and fifteen required checks sit outside it — cross, commit-lint, zizmor, darwin-link, semver, windows, and nine action* contexts.

Measured on PR #880 head 6eb08e14:

check workflow run id completed
final ci.yml 34077564803 02:57:30
windows rust.yml 34077564864 03:01:50

final concluded 4m20s before windows. For that window 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 with nothing on the host side refusing. That is how the row was found — by a maintainer commenting in exactly that window.

Three shapes are refuted by the tree, not by argument

The obvious fixes all fail, and the files say why before they are tried:

  • workflow_call from ci.yml. rust.yml's header: a job skipped by an if: still mints a check run concluded skipped; CI_ANSWERED_CONCLUSIONS excludes skipped on purpose, so a skipping required check leaves checks-green at exit 3 and land polling forever. A paths: filter is workflow-level only, and a workflow that does not trigger mints nothing — ABSENT, which CI_ABSENT_OK_CHECKS accepts. Calling those jobs from ci.yml, which triggers on every PR, converts absent into skipped.
  • Moving the four back into ci.yml. Same defect, and it re-buys the 64% of the CI bill CLOUD-398 slice 2 removed.
  • final reading the siblings by name. CLOUD-897, already walked and refused: "this job finished BEFORE the analyzer started on every recent merged PR … a step that cannot decide should not be in the one job branch protection requires."

The host cannot close this by construction. A path-filtered check must be ABSENT rather than skipped to avoid wedging the loop, and a ruleset cannot require an absent context. So the roster is enforceable only where the landing decision is made.

What changed

A third refusal in fast-forward.yml. That file already decides two things itself rather than delegating to the ruleset — the draft head (CLOUD-853) and the unreviewed fork head (CLOUD-867) — both on the stated reasoning that "the ruleset is precisely the thing measured not to hold." Like the draft step it is unreachable from land, which runs the same predicate before it comments; what it catches is a comment from any other OWNER/MEMBER/COLLABORATOR.

The predicate is the engine's, reused whole. mise run checks-green fetches the runs with gh api and hands them to batten checks green, a pure function of that TSV. Re-deriving "green" in the workflow would be the second authority CLOUD-363 measured: land counted cancelled as an answer, checks-green read it as red, and the two composed into "a branch wedge with no exit, and escaping it took a hand-minted SHA."

Exit 3 refuses, and so does exit 2. The adapter's contract is 0 green / 1 red / 2 could-not-look / 3 no answer yet. Only 0 lands. Could-not-look is refused because "a reading this gate cannot take is not a pass" is the posture checks-green already states for its own gh api failure.

Acquiring the binary. Building is not affordable — batten-check is the CI workflow's pole at 683s against this job's 5-minute budget. install.sh is the route: POSIX sh, no toolchain, no clone, every asset digest-verified with no flag to skip it. The two cheap gh api refusals run first, so a draft or unreviewed fork head is turned away before anything is installed.

The gate, and the two defects its own tier found

policy/landing-roster-guarded.rego deliberately does not assert that the fan-in covers the roster. That predicate is false by design 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." It asserts the landing path asks the question at all.

The second commit is the compiled tier doing its job on the first:

The gate could not see its own subject deleted. The row named only .github/workflows/fast-forward.yml, on the reasoning that a module should not be handed files it does not judge. Measured: a rule whose declared sources match no file is SKIPPED, not evaluated over an empty document. So a branch whose whole content is git rm .github/workflows/fast-forward.yml passed a gate written to stop exactly that — left: [] against right: ["landing-roster-unguarded"]. Declaring the directory guarantees a source exists; the module still reads one key. The missing arm is deleted rather than kept, because a glob matching zero files is no source rather than an unreadable one.

And the prescribed channel probe cannot catch that class. I ran the unconditional-arm confirmation .claude/rules/policy-modules.md requires and it spoke — exit 2 — while the arm was dead. That probe confirms the module evaluates on the tree you run it against; it says nothing about whether a given arm is reachable, and nothing about a tree where the declared source is gone. Only the tier driving the engine over a fixture with the file removed could tell. Recorded on CLOUD-1320, which owns the general form; CLOUD-1276 is the adjacent shape where the rule evaluates and one clause cannot fire.

Its class is check read never. The vocabulary refused the first name — roster is not a declared subject — and check is "one named CI check, or a batten check run". read never is the pair patch read never already spends.

Verification

Measured end to end, not asserted:

  • The adapter, both directions. All twenty answered → exit 0, green — every required check terminal and green. windows in flight with nineteen graded → exit 3, pending — 1 required check(s) still running, 19 graded. That is the measured window, refused.
  • mise run policy-test61 bundles, 767 passed, 0 failed.
  • crates/batten/tests/it/landing_roster.rs5 passed, including the committed workflow's own bytes, the deleted-workflow case, and a strong anti-vacuity case where a different workflow carries the guard's text and must not satisfy the rule.
  • batten check --rule landing-roster-guarded — exit 0 over the committed tree.

Filed rather than fixed here

CLOUD-1579override request resolves the config before it issues, so a batten.toml edit that breaks the load cannot be repaired by an override; the only route is git restore, which discards the whole edit. Hit twice landing this. An admission is also one-shot per write, which makes a multi-row registration all-or-nothing by construction and is stated nowhere.

CLOUD-1571override request evaluates all 58 policy bundles when --rule names no row, costing 2m22s. Every protected-path write in this PR paid it.

CLOUD-1320 — the deletion trap above, with the note that the blast radius is CLOUD-843's migration: any of the ~130 gates being ported that anchors on a literal path inherits it.

What this does NOT do

ci.yml's fan-in comment is unchanged and correct: it claims only that pointing protection at final avoids a ruleset edit per job, which is true and which this preserves. Line 1087 already states the four moved jobs are held by CI_REQUIRED_CHECKS rather than by final.

batten.toml's [ci].required_checks = ["final"] is unchanged — it projects what the host enforces, and the host still enforces exactly that.

🤖 Generated with Claude Code

https://claude.ai/code/session_015H3zMrSGxu4K3fMZu4KFX1

@coderabbitai

coderabbitai Bot commented Sep 7, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 4ad1fa0e-2a27-4bea-8910-051fd6ac989f

📥 Commits

Reviewing files that changed from the base of the PR and between 26014c7 and c14f4ca.

📒 Files selected for processing (6)
  • .github/workflows/fast-forward.yml
  • batten.toml
  • crates/batten/tests/it/landing_roster.rs
  • crates/batten/tests/it/main.rs
  • mise.toml
  • policy/landing-roster-guarded.rego

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

`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 6eb08e1: `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.
…isible

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.
`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
@wenzowski
wenzowski force-pushed the claude/prose-dispatch-gate-verify-u0fnx3 branch from 7ca736e to c14f4ca Compare September 7, 2026 07:37
@sonarqubecloud

sonarqubecloud Bot commented Sep 7, 2026

Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant