fix(landing): consult the required-check roster before moving main - #895
Draft
wenzowski wants to merge 3 commits into
Draft
fix(landing): consult the required-check roster before moving main#895wenzowski wants to merge 3 commits into
wenzowski wants to merge 3 commits into
Conversation
|
Warning Review limit reachedNext included review available in 4 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: 📒 Files selected for processing (6)
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. Comment |
`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
force-pushed
the
claude/prose-dispatch-gate-verify-u0fnx3
branch
from
September 7, 2026 07:37
7ca736e to
c14f4ca
Compare
|
❌ The last analysis has failed. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_sourcesmust supply every path its moduleclassifies — 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-mainrequires exactly one context,final. Required status checks apply to direct pushes, and landing here is a direct push by the/fast-forwardbot.finaldeclaresneeds: [ci, batten-check, bats, perf].$CI_REQUIRED_CHECKSnames 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 nineaction*contexts.Measured on PR #880 head
6eb08e14:finalci.ymlwindowsrust.ymlfinalconcluded 4m20s beforewindows. For that window the one required context was green while a required check was still running, and a/fast-forwardposted in it would have movedmainto 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_callfromci.yml.rust.yml's header: a job skipped by anif:still mints a check run concludedskipped;CI_ANSWERED_CONCLUSIONSexcludesskippedon purpose, so a skipping required check leaveschecks-greenat exit 3 andlandpolling forever. Apaths:filter is workflow-level only, and a workflow that does not trigger mints nothing — ABSENT, whichCI_ABSENT_OK_CHECKSaccepts. Calling those jobs fromci.yml, which triggers on every PR, converts absent into skipped.ci.yml. Same defect, and it re-buys the 64% of the CI bill CLOUD-398 slice 2 removed.finalreading 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 fromland, 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-greenfetches the runs withgh apiand hands them tobatten checks green, a pure function of that TSV. Re-deriving "green" in the workflow would be the second authority CLOUD-363 measured:landcountedcancelledas an answer,checks-greenread 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-greenalready states for its owngh apifailure.Acquiring the binary. Building is not affordable —
batten-checkis the CI workflow's pole at 683s against this job's 5-minute budget.install.shis the route: POSIX sh, no toolchain, no clone, every asset digest-verified with no flag to skip it. The two cheapgh apirefusals 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.regodeliberately 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 shapefixture-forks.regorecords 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 isgit rm .github/workflows/fast-forward.ymlpassed a gate written to stop exactly that —left: []againstright: ["landing-roster-unguarded"]. Declaring the directory guarantees a source exists; the module still reads one key. Themissingarm 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.mdrequires 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 —rosteris not a declared subject — andcheckis "one named CI check, or abatten checkrun".read neveris the pairpatch read neveralready spends.Verification
Measured end to end, not asserted:
green — every required check terminal and green.windowsin flight with nineteen graded → exit 3,pending — 1 required check(s) still running, 19 graded. That is the measured window, refused.mise run policy-test— 61 bundles, 767 passed, 0 failed.crates/batten/tests/it/landing_roster.rs— 5 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-1579 —
override requestresolves the config before it issues, so abatten.tomledit that breaks the load cannot be repaired by an override; the only route isgit 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-1571 —
override requestevaluates all 58 policy bundles when--rulenames 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 atfinalavoids a ruleset edit per job, which is true and which this preserves. Line 1087 already states the four moved jobs are held byCI_REQUIRED_CHECKSrather than byfinal.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